Skip to content

Commit

Permalink
feat: add mfa verify (#27)
Browse files Browse the repository at this point in the history
* fix: auth provider keys

* feat: implemented verify

* chore: tidy up code
  • Loading branch information
thezzisu authored Jan 20, 2024
1 parent 5df587a commit 195308d
Show file tree
Hide file tree
Showing 6 changed files with 98 additions and 33 deletions.
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

0 comments on commit 195308d

Please sign in to comment.