Browse Source

feat: Install feathers authentication

Owen Diffey 4 months ago
parent
commit
9ebe0c59f8

+ 21 - 0
server/config/default.json

@@ -12,5 +12,26 @@
   "postgresql": {
     "client": "pg",
     "connection": "postgres://musare:PASSWORD@postgres:5432/musare"
+  },
+  "authentication": {
+    "entity": "user",
+    "service": "users",
+    "secret": "9imbItLfHsGpCWPtt71nRGRXtSA0TzmN",
+    "authStrategies": [
+      "jwt",
+      "local"
+    ],
+    "jwtOptions": {
+      "header": {
+        "typ": "access"
+      },
+      "audience": "https://yourdomain.com",
+      "algorithm": "HS256",
+      "expiresIn": "1d"
+    },
+    "local": {
+      "usernameField": "email",
+      "passwordField": "password"
+    }
   }
 }

+ 15 - 0
server/migrations/20241117173947_user.ts

@@ -0,0 +1,15 @@
+// For more information about this file see https://dove.feathersjs.com/guides/cli/knexfile.html
+import type { Knex } from 'knex'
+
+export async function up(knex: Knex): Promise<void> {
+  await knex.schema.createTable('users', (table) => {
+    table.increments('id')
+
+    table.string('email').unique()
+    table.string('password')
+  })
+}
+
+export async function down(knex: Knex): Promise<void> {
+  await knex.schema.dropTable('users')
+}

+ 2 - 0
server/package.json

@@ -49,6 +49,8 @@
     "@feathersjs/adapter-commons": "^5.0.31",
     "@feathersjs/authentication": "^5.0.31",
     "@feathersjs/authentication-client": "^5.0.31",
+    "@feathersjs/authentication-local": "^5.0.31",
+    "@feathersjs/authentication-oauth": "^5.0.31",
     "@feathersjs/configuration": "^5.0.31",
     "@feathersjs/errors": "^5.0.31",
     "@feathersjs/feathers": "^5.0.31",

+ 2 - 0
server/src/app.ts

@@ -8,6 +8,7 @@ import { configurationValidator } from './configuration'
 import type { Application } from './declarations'
 import { logError } from './hooks/log-error'
 import { postgresql } from './postgresql'
+import { authentication } from './authentication'
 import { services } from './services/index'
 import { channels } from './channels'
 
@@ -33,6 +34,7 @@ app.configure(
   })
 )
 app.configure(postgresql)
+app.configure(authentication)
 app.configure(services)
 app.configure(channels)
 

+ 20 - 0
server/src/authentication.ts

@@ -0,0 +1,20 @@
+// For more information about this file see https://dove.feathersjs.com/guides/cli/authentication.html
+import { AuthenticationService, JWTStrategy } from '@feathersjs/authentication'
+import { LocalStrategy } from '@feathersjs/authentication-local'
+
+import type { Application } from './declarations'
+
+declare module './declarations' {
+  interface ServiceTypes {
+    authentication: AuthenticationService
+  }
+}
+
+export const authentication = (app: Application) => {
+  const authentication = new AuthenticationService(app)
+
+  authentication.register('jwt', new JWTStrategy())
+  authentication.register('local', new LocalStrategy())
+
+  app.use('authentication', authentication)
+}

+ 4 - 0
server/src/client.ts

@@ -4,6 +4,9 @@ import type { TransportConnection, Application } from '@feathersjs/feathers'
 import authenticationClient from '@feathersjs/authentication-client'
 import type { AuthenticationClientOptions } from '@feathersjs/authentication-client'
 
+import { userClient } from './services/users/users.shared'
+export type { User, UserData, UserQuery, UserPatch } from './services/users/users.shared'
+
 export interface Configuration {
   connection: TransportConnection<ServiceTypes>
 }
@@ -30,5 +33,6 @@ export const createClient = <Configuration = any,>(
   client.configure(authenticationClient(authenticationOptions))
   client.set('connection', connection)
 
+  client.configure(userClient)
   return client
 }

+ 9 - 0
server/src/declarations.ts

@@ -3,6 +3,8 @@ import { HookContext as FeathersHookContext, NextFunction } from '@feathersjs/fe
 import { Application as FeathersApplication } from '@feathersjs/koa'
 import { ApplicationConfiguration } from './configuration'
 
+import { User } from './services/users/users'
+
 export type { NextFunction }
 
 // The types for app.get(name) and app.set(name)
@@ -18,3 +20,10 @@ export type Application = FeathersApplication<ServiceTypes, Configuration>
 
 // The context for hook functions - can be typed with a service class
 export type HookContext<S = any> = FeathersHookContext<Application, S>
+
+// Add the user as an optional property to all params
+declare module '@feathersjs/feathers' {
+  interface Params {
+    user?: User
+  }
+}

+ 2 - 0
server/src/services/index.ts

@@ -1,6 +1,8 @@
+import { user } from './users/users'
 // For more information about this file see https://dove.feathersjs.com/guides/cli/application.html#configure-functions
 import type { Application } from '../declarations'
 
 export const services = (app: Application) => {
+  app.configure(user)
   // All services will be registered here
 }

+ 27 - 0
server/src/services/users/users.class.ts

@@ -0,0 +1,27 @@
+// For more information about this file see https://dove.feathersjs.com/guides/cli/service.class.html#database-services
+import type { Params } from '@feathersjs/feathers'
+import { KnexService } from '@feathersjs/knex'
+import type { KnexAdapterParams, KnexAdapterOptions } from '@feathersjs/knex'
+
+import type { Application } from '../../declarations'
+import type { User, UserData, UserPatch, UserQuery } from './users.schema'
+
+export type { User, UserData, UserPatch, UserQuery }
+
+export interface UserParams extends KnexAdapterParams<UserQuery> {}
+
+// By default calls the standard Knex adapter service methods but can be customized with your own functionality.
+export class UserService<ServiceParams extends Params = UserParams> extends KnexService<
+  User,
+  UserData,
+  UserParams,
+  UserPatch
+> {}
+
+export const getOptions = (app: Application): KnexAdapterOptions => {
+  return {
+    paginate: app.get('paginate'),
+    Model: app.get('postgresqlClient'),
+    name: 'users'
+  }
+}

+ 70 - 0
server/src/services/users/users.schema.ts

@@ -0,0 +1,70 @@
+// // For more information about this file see https://dove.feathersjs.com/guides/cli/service.schemas.html
+import { resolve } from '@feathersjs/schema'
+import { Type, getValidator, querySyntax } from '@feathersjs/typebox'
+import type { Static } from '@feathersjs/typebox'
+import { passwordHash } from '@feathersjs/authentication-local'
+
+import type { HookContext } from '../../declarations'
+import { dataValidator, queryValidator } from '../../validators'
+import type { UserService } from './users.class'
+
+// Main data model schema
+export const userSchema = Type.Object(
+  {
+    id: Type.Number(),
+    email: Type.String(),
+    password: Type.Optional(Type.String())
+  },
+  { $id: 'User', additionalProperties: false }
+)
+export type User = Static<typeof userSchema>
+export const userValidator = getValidator(userSchema, dataValidator)
+export const userResolver = resolve<User, HookContext<UserService>>({})
+
+export const userExternalResolver = resolve<User, HookContext<UserService>>({
+  // The password should never be visible externally
+  password: async () => undefined
+})
+
+// Schema for creating new entries
+export const userDataSchema = Type.Pick(userSchema, ['email', 'password'], {
+  $id: 'UserData'
+})
+export type UserData = Static<typeof userDataSchema>
+export const userDataValidator = getValidator(userDataSchema, dataValidator)
+export const userDataResolver = resolve<User, HookContext<UserService>>({
+  password: passwordHash({ strategy: 'local' })
+})
+
+// Schema for updating existing entries
+export const userPatchSchema = Type.Partial(userSchema, {
+  $id: 'UserPatch'
+})
+export type UserPatch = Static<typeof userPatchSchema>
+export const userPatchValidator = getValidator(userPatchSchema, dataValidator)
+export const userPatchResolver = resolve<User, HookContext<UserService>>({
+  password: passwordHash({ strategy: 'local' })
+})
+
+// Schema for allowed query properties
+export const userQueryProperties = Type.Pick(userSchema, ['id', 'email'])
+export const userQuerySchema = Type.Intersect(
+  [
+    querySyntax(userQueryProperties),
+    // Add additional query properties here
+    Type.Object({}, { additionalProperties: false })
+  ],
+  { additionalProperties: false }
+)
+export type UserQuery = Static<typeof userQuerySchema>
+export const userQueryValidator = getValidator(userQuerySchema, queryValidator)
+export const userQueryResolver = resolve<UserQuery, HookContext<UserService>>({
+  // If there is a user (e.g. with authentication), they are only allowed to see their own data
+  id: async (value, user, context) => {
+    if (context.params.user) {
+      return context.params.user.id
+    }
+
+    return value
+  }
+})

+ 27 - 0
server/src/services/users/users.shared.ts

@@ -0,0 +1,27 @@
+// For more information about this file see https://dove.feathersjs.com/guides/cli/service.shared.html
+import type { Params } from '@feathersjs/feathers'
+import type { ClientApplication } from '../../client'
+import type { User, UserData, UserPatch, UserQuery, UserService } from './users.class'
+
+export type { User, UserData, UserPatch, UserQuery }
+
+export type UserClientService = Pick<UserService<Params<UserQuery>>, (typeof userMethods)[number]>
+
+export const userPath = 'users'
+
+export const userMethods: Array<keyof UserService> = ['find', 'get', 'create', 'patch', 'remove']
+
+export const userClient = (client: ClientApplication) => {
+  const connection = client.get('connection')
+
+  client.use(userPath, connection.service(userPath), {
+    methods: userMethods
+  })
+}
+
+// Add this service to the client service type index
+declare module '../../client' {
+  interface ServiceTypes {
+    [userPath]: UserClientService
+  }
+}

+ 66 - 0
server/src/services/users/users.ts

@@ -0,0 +1,66 @@
+// For more information about this file see https://dove.feathersjs.com/guides/cli/service.html
+import { authenticate } from '@feathersjs/authentication'
+
+import { hooks as schemaHooks } from '@feathersjs/schema'
+
+import {
+  userDataValidator,
+  userPatchValidator,
+  userQueryValidator,
+  userResolver,
+  userExternalResolver,
+  userDataResolver,
+  userPatchResolver,
+  userQueryResolver
+} from './users.schema'
+
+import type { Application } from '../../declarations'
+import { UserService, getOptions } from './users.class'
+import { userPath, userMethods } from './users.shared'
+
+export * from './users.class'
+export * from './users.schema'
+
+// A configure function that registers the service and its hooks via `app.configure`
+export const user = (app: Application) => {
+  // Register our service on the Feathers application
+  app.use(userPath, new UserService(getOptions(app)), {
+    // A list of all methods this service exposes externally
+    methods: userMethods,
+    // You can add additional custom events to be sent to clients here
+    events: []
+  })
+  // Initialize hooks
+  app.service(userPath).hooks({
+    around: {
+      all: [schemaHooks.resolveExternal(userExternalResolver), schemaHooks.resolveResult(userResolver)],
+      find: [authenticate('jwt')],
+      get: [authenticate('jwt')],
+      create: [],
+      update: [authenticate('jwt')],
+      patch: [authenticate('jwt')],
+      remove: [authenticate('jwt')]
+    },
+    before: {
+      all: [schemaHooks.validateQuery(userQueryValidator), schemaHooks.resolveQuery(userQueryResolver)],
+      find: [],
+      get: [],
+      create: [schemaHooks.validateData(userDataValidator), schemaHooks.resolveData(userDataResolver)],
+      patch: [schemaHooks.validateData(userPatchValidator), schemaHooks.resolveData(userPatchResolver)],
+      remove: []
+    },
+    after: {
+      all: []
+    },
+    error: {
+      all: []
+    }
+  })
+}
+
+// Add this service to the service type index
+declare module '../../declarations' {
+  interface ServiceTypes {
+    [userPath]: UserService
+  }
+}

+ 37 - 4
server/test/client.test.ts

@@ -1,18 +1,51 @@
+// For more information about this file see https://dove.feathersjs.com/guides/cli/client.test.html
 import assert from 'assert'
 import axios from 'axios'
-import type { Server } from 'http'
-import { app } from '../src/app'
-import { createClient } from '../src/client'
 
 import rest from '@feathersjs/rest-client'
+import authenticationClient from '@feathersjs/authentication-client'
+import { app } from '../src/app'
+import { createClient } from '../src/client'
+import type { UserData } from '../src/client'
 
 const port = app.get('port')
 const appUrl = `http://${app.get('host')}:${port}`
 
-describe('client tests', () => {
+describe('application client tests', () => {
   const client = createClient(rest(appUrl).axios(axios))
 
+  before(async () => {
+    await app.listen(port)
+  })
+
+  after(async () => {
+    await app.teardown()
+  })
+
   it('initialized the client', () => {
     assert.ok(client)
   })
+
+  it('creates and authenticates a user with email and password', async () => {
+    const userData: UserData = {
+      email: 'someone@example.com',
+      password: 'supersecret'
+    }
+
+    await client.service('users').create(userData)
+
+    const { user, accessToken } = await client.authenticate({
+      strategy: 'local',
+      ...userData
+    })
+
+    assert.ok(accessToken, 'Created access token for user')
+    assert.ok(user, 'Includes user in authentication data')
+    assert.strictEqual(user.password, undefined, 'Password is hidden to clients')
+
+    await client.logout()
+
+    // Remove the test user on the server
+    await app.service('users').remove(user.id)
+  })
 })

+ 11 - 0
server/test/services/users/users.test.ts

@@ -0,0 +1,11 @@
+// For more information about this file see https://dove.feathersjs.com/guides/cli/service.test.html
+import assert from 'assert'
+import { app } from '../../../src/app'
+
+describe('users service', () => {
+  it('registered the service', () => {
+    const service = app.service('users')
+
+    assert.ok(service, 'Registered the service')
+  })
+})