|
|
import { FastifyInstance, Plugin, DefaultQuery, DefaultParams, DefaultHeaders, DefaultBody, RouteShorthandOptions, JSONSchema, } from 'fastify'
import { Server, IncomingMessage, ServerResponse } from 'http'
import { createAccessToken, createRefreshToken } from '../../lib/authentication' import { hashPassword, verifyPassword, JWT } from '../../lib/crypto' import { containerFor, normalize } from '../../lib/database' import { errorSchema, badRequestError, unauthorizedError } from '../../lib/errors' import { tokenFromHeader } from '../../lib/http'
import { IUser, IUserToken, IGroup } from '../../types/collections'
interface PluginOptions {
}
const tokenResponseSchema: JSONSchema = { type: 'object', properties: { id: { type: 'string' }, access: { type: 'string' }, refresh: { type: 'string' }, }, }
function registerRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) { interface Body { id: string name: string email: string password: string groupId: string }
const options: RouteShorthandOptions = { schema: { body: { type: 'object', required: ['id', 'email', 'password'], properties: { id: { type: 'string' }, name: { type: 'string' }, email: { type: 'string' }, password: { type: 'string' }, groupId: { type: 'string' }, }, }, response: { 201: tokenResponseSchema, 400: errorSchema, }, }, }
server.post<DefaultQuery, DefaultParams, DefaultHeaders, Body>('/api/register', options, async (request, reply) => { const { name, email, password, groupId } = request.body const id = normalize(request.body.id)
if (!id || id === '') return badRequestError(reply, 'id is required', 'id') if (id.length < 5 || id.length > 20) return badRequestError(reply, 'id must be between 5 and 20 characters', 'id') if (name && name.length > 40) return badRequestError(reply, 'name must be less than 40 characters', 'name') if (!email || email === '') return badRequestError(reply, 'email is required', 'email') if (email.length < 5) return badRequestError(reply, 'email must be at least 5 characters', 'email') if (!password || password === '') return badRequestError(reply, 'password is required', 'password') if (password.length < 5) return badRequestError(reply, 'password must be at least 5 characters', 'password')
const userContainer = await containerFor(server.database.client, 'Users') const { resource: existingUser } = await userContainer.item(id, id).read<IUser>() if (existingUser) return badRequestError(reply, 'User id already taken', 'id') let userPending = false
if (groupId) { const groupContainer = await containerFor(server.database.client, 'Groups') const { resource: group } = await groupContainer.item(groupId, groupId).read<IGroup>()
if (!group) return badRequestError(reply, 'Group not found', 'groupId') if (!group.open) return badRequestError(reply, 'Group registration closed', 'groupId') if (group.requiresApproval) userPending = true }
const user: IUser = { id, partitionKey: id, type: 'user', group: groupId, name, email, emailVerified: false, passwordHash: await hashPassword(password), installations: [], points: 0, subscriberCount: 0, subscribedCount: 0, pending: userPending, privacy: 'public', paid: false, about: '', created: Date.now(), }
const refreshToken = createRefreshToken(id, request.headers['user-agent'], request.ip)
await userContainer.items.create(user) await userContainer.items.create(refreshToken)
return { id, access: await createAccessToken(id), refresh: refreshToken.id, } }) }
function authenticateRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) { interface Body { id?: string email?: string password: string }
const options: RouteShorthandOptions = { schema: { body: { type: 'object', required: ['id', 'password'], properties: { id: { type: 'string' }, password: { type: 'string' }, }, }, response: { 200: tokenResponseSchema, 400: errorSchema, }, }, }
server.post<DefaultQuery, DefaultParams, DefaultHeaders, Body>('/api/authenticate', options, async (request, reply) => { const { password } = request.body const id = normalize(request.body.id) const container = await containerFor(server.database.client, 'Users') const { resource: user } = await container.item(id, id).read<IUser>()
if (!user) return badRequestError(reply, 'User not found')
const result = await verifyPassword(user.passwordHash, password) if (!result) return badRequestError(reply, 'Incorrect credentials')
const refreshToken = createRefreshToken(user.id, request.headers['user-agent'], request.ip)
await container.items.create(refreshToken)
return { id, access: await createAccessToken(user.id), refresh: refreshToken.id, } }) }
function refreshRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) { interface Headers { [key: string]: string authorization: string }
interface Body { refresh: string }
const options: RouteShorthandOptions = { schema: { headers: { type: 'object', properties: { authorization: { type: 'string' }, }, }, body: { type: 'object', required: ['refresh'], properties: { refresh: { type: 'string' }, }, }, response: { 200: tokenResponseSchema, 400: errorSchema, }, }, }
server.post<DefaultQuery, DefaultParams, Headers, Body>('/api/refresh', options, async (request, reply) => { const tokenString = tokenFromHeader(request.headers.authorization) if (!tokenString) return badRequestError(reply, 'Access token required')
let userId: string = ''
try { const tokenData = await JWT.verify(tokenString, { ignoreExpiration: true, }) if ((tokenData.exp * 1000) > Date.now()) return badRequestError(reply, 'Token must be expired') userId = tokenData.sub } catch (err) { return badRequestError(reply, 'Invalid token') }
const container = await containerFor(server.database.client, 'Users') const tokenItem = container.item(request.body.refresh, userId)
if (!tokenItem) return badRequestError(reply, 'Invalid refresh token') const { resource: token } = await tokenItem.read<IUserToken>()
if (token.expires < Date.now()) return badRequestError(reply, 'Refresh token expired')
await tokenItem.delete()
const newRefreshToken = createRefreshToken(userId, request.headers['user-agent'], request.ip) await container.items.create(newRefreshToken)
return { id: userId, access: await createAccessToken(userId), refresh: newRefreshToken.id } }) }
function selfRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) { const options: RouteShorthandOptions = { schema: { response: { 200: { type: 'object', properties: { id: { type: 'string' }, name: { type: 'string' }, }, }, }, }, }
server.get<DefaultQuery, DefaultParams, DefaultHeaders, DefaultBody>('/api/self', options, async (request, reply) => { if (!request.viewer) return unauthorizedError(reply)
const container = await containerFor(server.database.client, 'Users') const { resource: viewer } = await container.item(request.viewer.id, request.viewer.id).read<IUser>()
if (!viewer) return unauthorizedError(reply)
return { id: viewer.id, name: viewer.name, } }) }
const plugin: Plugin<Server, IncomingMessage, ServerResponse, PluginOptions> = async server => { registerRoute(server) authenticateRoute(server) refreshRoute(server) selfRoute(server) }
export default plugin
|