[ABANDONED] API server for Flexor social network.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 

278 lines
9.1 KiB

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