import { FastifyInstance, Plugin, DefaultQuery, DefaultParams, DefaultHeaders, DefaultBody, RouteShorthandOptions, } from 'fastify' import { Server, IncomingMessage, ServerResponse } from 'http' import { MIN_ID_LENGTH, MAX_ID_LENGTH, MAX_NAME_LENGTH, MIN_PASSWORD_LENGTH, INSTALLATION_PARTITION_KEY } from '../../constants' import { tokenResponseSchema, selfSchema, errorSchema } from '../../schemas' import { createAccessToken, createRefreshToken } from '../../lib/authentication' import { getUser } from '../../lib/collections' import { hashPassword, comparePassword, JWT } from '../../lib/crypto' import { containerFor, getItem, queryItems, normalize, createQuerySpec } from '../../lib/database' import { badRequestError, badRequestFormError, unauthorizedError, serverError } from '../../lib/errors' import { tokenFromHeader } from '../../lib/http' import { attachMedia } from '../../lib/media' import { createInstallationId } from '../../lib/utils' import { User, UserToken, Group, GroupInvitation, GroupMembership, UserItemType, UserPrivacyType, GroupItemType, GroupMembershipType, GroupRegistrationType, App, Installation, } from '../../types/collections' import { PluginOptions } from '../../types' function registerRoute(server: FastifyInstance) { interface Body { id: string name: string email: string password: string requiresApproval: boolean privacy: string about?: string imageUrl?: string coverImageUrl?: string group?: string invitation?: string } const options: RouteShorthandOptions = { schema: { body: { type: 'object', required: ['id', 'email', 'password'], properties: { id: { type: 'string', minLength: MIN_ID_LENGTH, maxLength: MAX_ID_LENGTH, }, name: { type: 'string', maxLength: MAX_NAME_LENGTH, }, email: { type: 'string', format: 'email', }, password: { type: 'string', minLength: MIN_PASSWORD_LENGTH, }, requiresApproval: { type: 'boolean' }, privacy: { type: 'string', enum: ['public', 'group', 'subscribers', 'private'], }, about: { type: 'string' }, imageUrl: { type: 'string' }, coverImageUrl: { type: 'string' }, group: { type: 'string' }, invitation: { type: 'string' }, }, }, response: { 201: tokenResponseSchema, 400: errorSchema, }, }, } server.post('/api/register', options, async (request, reply) => { if (!server.database) return serverError(reply) const { name, email, password, requiresApproval, privacy, about, imageUrl, coverImageUrl, invitation: code } = request.body const id = normalize(request.body.id) const userContainer = containerFor(server.database.client, 'Users') const groupContainer = containerFor(server.database.client, 'Groups') const existingUser = await getItem({ container: userContainer, id }) if (existingUser) return badRequestFormError(reply, 'id', 'User id already taken') let userPending = false let invitation: GroupInvitation | undefined let group: Group | undefined if (request.body.group) { group = await getItem({ container: groupContainer, id: request.body.group }) if (!group) return badRequestFormError(reply, 'group', 'Group not found') if (code) { const invitationQuery = createQuerySpec(` SELECT * FROM Groups g WHERE g.id = @code g.pk = @group AND g.t = @type AND g.active = true AND g.expiration < GETCURRENTTIMESTAMP() `, { code, group: group.id, type: GroupItemType.Invitation, }) const invitations = await queryItems({ container: groupContainer, query: invitationQuery, logger: request.log, }) if (invitations.length === 0) return badRequestFormError(reply, 'invitation', 'Invalid invitation code') invitation = invitations[0] } if (group.registration === GroupRegistrationType.Closed && !invitation) return badRequestFormError(reply, 'group', 'Group registration closed') if (group.registration === GroupRegistrationType.Approval) userPending = true } const appContainer = containerFor(server.database.client, 'Apps') const apps = await queryItems({ container: appContainer, query: 'SELECT * FROM Apps a WHERE a.active = true AND a.preinstall = true', logger: request.log, }) const installations: string[] = [] for (const app of apps) { const installation: Installation = { id: createInstallationId(), pk: INSTALLATION_PARTITION_KEY, userId: id, appId: app.id, settings: {}, created: Date.now(), } await appContainer.items.create(installation) installations.push(installation.id) } const user: User = { id, pk: id, t: UserItemType.User, groupId: group ? group.id : undefined, name, about, email, emailVerified: false, passwordHash: await hashPassword(password), imageUrl, coverImageUrl, installations, awards: 0, points: 0, balance: 0, posts: 0, subscriberCount: 0, subscribedCount: 0, pending: userPending, requiresApproval, privacy: privacy as UserPrivacyType, paid: false, active: true, created: Date.now(), } const refreshToken = createRefreshToken(id, request.headers['user-agent'], request.ip) await userContainer.items.create(user) await userContainer.items.create(refreshToken) if (group) { await groupContainer.items.create({ pk: group.id, t: GroupItemType.Membership, userId: user.id, pending: userPending, membership: GroupMembershipType.Member, invitation: code, created: Date.now(), }) if (invitation) { const invitationItem = groupContainer.item(invitation.id, group.id) const uses = invitation.uses + 1 await invitationItem.replace({ ...invitation, uses, active: invitation.limit ? uses > invitation.limit : true, }) } } const mediaContainer = containerFor(server.database.client, 'Media') if (imageUrl) await attachMedia(mediaContainer, imageUrl) if (coverImageUrl) await attachMedia(mediaContainer, coverImageUrl) return { id, access: await createAccessToken(id), refresh: refreshToken.id, } }) } function authenticateRoute(server: FastifyInstance) { interface Body { id: 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('/api/authenticate', options, async (request, reply) => { if (!server.database) return serverError(reply) const container = containerFor(server.database.client, 'Users') const id = normalize(request.body.id) const user = await getItem({ container, id }) if (!user) return badRequestFormError(reply, 'id', 'User not found') const result = await comparePassword(request.body.password, user.passwordHash) if (!result) return badRequestFormError(reply, 'password', 'Incorrect password') 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) { 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: { 201: tokenResponseSchema, 400: errorSchema, }, }, } server.post('/api/refresh', options, async (request, reply) => { if (!server.database) return serverError(reply) const tokenString = tokenFromHeader(request.headers.authorization) if (!tokenString) return badRequestError(reply, 'Access token required') let userId: string | undefined try { const tokenData = await JWT.verify(tokenString, { ignoreExpiration: true, }) if (!tokenData.sub) return badRequestError(reply, 'Invalid token') if (!tokenData.exp) return badRequestError(reply, 'Invalid token') if ((tokenData.exp * 1000) > Date.now()) return badRequestError(reply, 'Token must be expired') userId = tokenData.sub } catch (err) { request.log.error(err) return badRequestError(reply, 'Invalid token') } const container = 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() 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) reply.code(201) return { id: userId, access: await createAccessToken(userId), refresh: newRefreshToken.id, expires: newRefreshToken.expires, } }) } function selfRoute(server: FastifyInstance) { const options: RouteShorthandOptions = { schema: { response: { 200: selfSchema, 400: errorSchema, }, }, } server.get('/api/self', options, async (request, reply) => { if (!server.database) return serverError(reply) if (!request.viewer) return unauthorizedError(reply) const viewer = await getUser(server.database.client, request.viewer.id) if (!viewer) return unauthorizedError(reply) return viewer }) } const plugin: Plugin = async server => { registerRoute(server) authenticateRoute(server) refreshRoute(server) selfRoute(server) } export default plugin