diff --git a/.yarn/versions/5909353f.yml b/.yarn/versions/5909353f.yml new file mode 100644 index 0000000..1a06f8b --- /dev/null +++ b/.yarn/versions/5909353f.yml @@ -0,0 +1,2 @@ +releases: + "@aoi-js/server": patch diff --git a/apps/server/src/routes/auth/index.ts b/apps/server/src/routes/auth/index.ts index 52620e1..4132878 100644 --- a/apps/server/src/routes/auth/index.ts +++ b/apps/server/src/routes/auth/index.ts @@ -12,7 +12,7 @@ const signupEnabled = loadEnv('SIGNUP_ENABLED', (x) => !!JSON.parse(x), true) export const authRoutes = defineRoutes(async (s) => { s.addHook('onRoute', (route) => { - ;(route.schema ??= {}).security = [] + ;(route.schema ??= {}).security ??= [] }) s.addHook('onRoute', swaggerTagMerger('auth')) @@ -48,9 +48,8 @@ export const authRoutes = defineRoutes(async (s) => { }, async (req, rep) => { const { provider, payload } = req.body - const providerInstance = authProviders[provider] - if (!providerInstance || !providerInstance.preLogin) return rep.badRequest() - return providerInstance.preLogin(payload, req, rep) + if (!Object.hasOwn(authProviders, provider)) return rep.badRequest() + return authProviders[provider].preLogin?.(payload, req, rep) ?? {} } ) @@ -72,14 +71,63 @@ export const authRoutes = defineRoutes(async (s) => { }, async (req, rep) => { const { provider, payload } = req.body - const providerInstance = authProviders[provider] - if (!providerInstance) return rep.badRequest() - const [userId, tags] = await providerInstance.login(payload, req, rep) + if (!Object.hasOwn(authProviders, provider)) return rep.badRequest() + const [userId, tags] = await authProviders[provider].login(payload, req, rep) const token = await rep.jwtSign({ userId: userId.toString(), tags }, { expiresIn: '7d' }) return { token } } ) + s.post( + '/preVerify', + { + schema: { + security: [{ bearerAuth: [] }], + body: Type.Object({ + provider: Type.String(), + payload: Type.Unknown() + }), + response: { + 200: Type.Unknown() + } + } + }, + async (req, rep) => { + const { provider, payload } = req.body + if (!Object.hasOwn(authProviders, provider)) return rep.badRequest() + return authProviders[provider].preVerify?.(req.user.userId, payload, req, rep) ?? {} + } + ) + + s.post( + '/verify', + { + schema: { + security: [{ bearerAuth: [] }], + body: Type.Object({ + provider: Type.String(), + payload: Type.Unknown() + }), + response: { + 200: Type.Object({ + token: Type.String() + }) + } + } + }, + async (req, rep) => { + const { provider, payload } = req.body + if (!Object.hasOwn(authProviders, provider)) return rep.badRequest() + const verified = await authProviders[provider].verify(req.user.userId, payload, req, rep) + if (!verified) return rep.forbidden() + const token = await rep.jwtSign( + { userId: req.user.userId.toString(), tags: [], mfa: provider }, + { expiresIn: '30min' } + ) + return { token } + } + ) + s.post( '/signup', { diff --git a/apps/server/src/routes/index.ts b/apps/server/src/routes/index.ts index 5f2c1f0..fa8578e 100644 --- a/apps/server/src/routes/index.ts +++ b/apps/server/src/routes/index.ts @@ -5,10 +5,10 @@ import { adminRoutes } from './admin/index.js' import { defineRoutes } from './common/index.js' import { problemRoutes } from './problem/index.js' import { solutionRoutes } from './solution/index.js' -import { BSON } from 'mongodb' +import { UUID } from 'mongodb' import { runnerRoutes } from './runner/index.js' import fastifyJwt from '@fastify/jwt' -import { Type } from '@sinclair/typebox' +import { Type, Static } from '@sinclair/typebox' import { TypeCompiler } from '@sinclair/typebox/compiler' import { loadEnv } from '../utils/config.js' import { groupRoutes } from './group/index.js' @@ -27,13 +27,19 @@ import { import type { FastifyRequest } from 'fastify' import { publicRoutes } from './public/index.js' import { IOrgMembership, orgMemberships } from '../db/index.js' +import { authProviders } from '../auth/index.js' + +const SUserPayload = Type.Object({ + userId: Type.UUID(), + tags: Type.Optional(Type.Array(Type.String())), + mfa: Type.Optional(Type.String()) +}) +type UserPayload = Static +const userPayload = TypeCompiler.Compile(SUserPayload) declare module '@fastify/jwt' { interface FastifyJWT { - user: { - userId: BSON.UUID - tags?: string[] - } + user: Omit & { userId: UUID } } } @@ -43,16 +49,11 @@ declare module 'fastify' { _container: IContainer provide(point: InjectionPoint, value: T): void inject(point: InjectionPoint): T - loadMembership(orgId: BSON.UUID): Promise + loadMembership(orgId: UUID): Promise + verifyMfa(token: string): string } } -const userPayload = TypeCompiler.Compile( - Type.Object({ - userId: Type.String() - }) -) - function decoratedProvide(this: FastifyRequest, point: InjectionPoint, value: T) { return provide(this._container, point, value) } @@ -63,23 +64,37 @@ function decoratedInject(this: FastifyRequest, point: InjectionPoint): T { async function decoratedLoadMembership( this: FastifyRequest, - orgId: BSON.UUID + orgId: UUID ): Promise { if (!this.user) return null return orgMemberships.findOne({ userId: this.user.userId, orgId }) } +function decoratedVerifyMfa(this: FastifyRequest, token: string): string { + if (!this.user) throw this.server.httpErrors.forbidden() + const payload = this.server.jwt.verify(token) + if (userPayload.Check(payload)) { + if (this.user.userId !== new UUID(payload.userId)) throw this.server.httpErrors.forbidden() + if (!payload.mfa) throw this.server.httpErrors.forbidden() + if (!Object.hasOwn(authProviders, payload.mfa)) throw this.server.httpErrors.badRequest() + return payload.mfa + } + throw this.server.httpErrors.badRequest() +} + export const apiRoutes = defineRoutes(async (s) => { s.decorateRequest('_container', null) s.decorateRequest('provide', decoratedProvide) s.decorateRequest('inject', decoratedInject) s.decorateRequest('loadMembership', decoratedLoadMembership) + s.decorateRequest('verifyMfa', decoratedVerifyMfa) s.register(fastifyJwt, { secret: loadEnv('JWT_SECRET', String), formatUser(payload) { if (userPayload.Check(payload)) { - return { userId: new BSON.UUID(payload.userId) } + payload.userId = new UUID(payload.userId) + return payload as Omit & { userId: UUID } } throw s.httpErrors.badRequest() } @@ -99,9 +114,11 @@ export const apiRoutes = defineRoutes(async (s) => { } } - // JWT is the default security scheme - if ('security' in req.routeOptions.schema) return - if (!req.user) return rep.forbidden() + // Check JWT + const { security } = req.routeOptions.schema + if (!security || security.some((sec) => Object.hasOwn(sec, 'bearerAuth'))) { + if (!req.user) return rep.forbidden() + } }) s.get( diff --git a/apps/server/src/routes/user/scoped.ts b/apps/server/src/routes/user/scoped.ts index 3e28bf0..bba95c6 100644 --- a/apps/server/src/routes/user/scoped.ts +++ b/apps/server/src/routes/user/scoped.ts @@ -138,9 +138,8 @@ export const userScopedRoutes = defineRoutes(async (s) => { return rep.forbidden() const { provider, payload } = req.body - const providerInstance = authProviders[provider] - if (!providerInstance || !providerInstance.preBind) return rep.badRequest() - return providerInstance.preBind(ctx._userId, payload, req, rep) + if (!Object.hasOwn(authProviders, provider)) return rep.badRequest() + return authProviders[provider].preBind?.(ctx._userId, payload, req, rep) ?? {} } ) @@ -169,9 +168,8 @@ export const userScopedRoutes = defineRoutes(async (s) => { return rep.forbidden() const { provider, payload } = req.body - const providerInstance = authProviders[provider] - if (!providerInstance) return rep.badRequest() - return providerInstance.bind(ctx._userId, payload, req, rep) + if (!Object.hasOwn(authProviders, provider)) return rep.badRequest() + return authProviders[provider].bind(ctx._userId, payload, req, rep) } ) }) diff --git a/apps/server/src/server/schemas.ts b/apps/server/src/server/schemas.ts index c47c23a..ea703fa 100644 --- a/apps/server/src/server/schemas.ts +++ b/apps/server/src/server/schemas.ts @@ -16,7 +16,7 @@ export const schemaRoutes: FastifyPluginAsyncTypebox = async (s) => { } }, (req, rep) => { - if (!schemas[req.params.name]) return rep.notFound() + if (!Object.hasOwn(schemas, req.params.name)) return rep.notFound() return rep.header('content-type', 'application/json').send(schemas[req.params.name]) } ) diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json index 9140d0e..04472d9 100644 --- a/apps/server/tsconfig.json +++ b/apps/server/tsconfig.json @@ -4,7 +4,7 @@ "composite": true, "rootDir": "src", "target": "ESNext", - "lib": [], + "lib": ["ESNext"], "module": "NodeNext", "moduleResolution": "NodeNext", "declaration": true,