Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add mfa verify #27

Merged
merged 3 commits into from
Jan 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .yarn/versions/5909353f.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
releases:
"@aoi-js/server": patch
62 changes: 55 additions & 7 deletions apps/server/src/routes/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'))

Expand Down Expand Up @@ -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) ?? {}
}
)

Expand All @@ -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',
{
Expand Down
53 changes: 35 additions & 18 deletions apps/server/src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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<typeof SUserPayload>
const userPayload = TypeCompiler.Compile(SUserPayload)

declare module '@fastify/jwt' {
interface FastifyJWT {
user: {
userId: BSON.UUID
tags?: string[]
}
user: Omit<UserPayload, 'userId'> & { userId: UUID }
}
}

Expand All @@ -43,16 +49,11 @@ declare module 'fastify' {
_container: IContainer
provide<T>(point: InjectionPoint<T>, value: T): void
inject<T>(point: InjectionPoint<T>): T
loadMembership(orgId: BSON.UUID): Promise<IOrgMembership | null>
loadMembership(orgId: UUID): Promise<IOrgMembership | null>
verifyMfa(token: string): string
}
}

const userPayload = TypeCompiler.Compile(
Type.Object({
userId: Type.String()
})
)

function decoratedProvide<T>(this: FastifyRequest, point: InjectionPoint<T>, value: T) {
return provide(this._container, point, value)
}
Expand All @@ -63,23 +64,37 @@ function decoratedInject<T>(this: FastifyRequest, point: InjectionPoint<T>): T {

async function decoratedLoadMembership(
this: FastifyRequest,
orgId: BSON.UUID
orgId: UUID
): Promise<IOrgMembership | null> {
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<UserPayload>(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<UserPayload, 'userId'> & { userId: UUID }
}
throw s.httpErrors.badRequest()
}
Expand All @@ -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(
Expand Down
10 changes: 4 additions & 6 deletions apps/server/src/routes/user/scoped.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) ?? {}
}
)

Expand Down Expand Up @@ -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)
}
)
})
2 changes: 1 addition & 1 deletion apps/server/src/server/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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])
}
)
Expand Down
2 changes: 1 addition & 1 deletion apps/server/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"composite": true,
"rootDir": "src",
"target": "ESNext",
"lib": [],
"lib": ["ESNext"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"declaration": true,
Expand Down