[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.
 

496 lines
16 KiB

// authentication.ts
// Copyright (C) 2020 Dwayne Harris
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import {
FastifyInstance,
Plugin,
DefaultQuery,
DefaultParams,
DefaultHeaders,
DefaultBody,
RouteShorthandOptions,
} from 'fastify'
import { Server, IncomingMessage, ServerResponse } from 'http'
import { tokenResponseSchema, selfSchema, errorSchema } from '../../schemas'
import { createAccessToken, createRefreshToken } from '../../lib/authentication'
import { getUser, getUserIdFromEmail, getUserIdFromPhone } 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 {
MIN_ID_LENGTH,
MAX_ID_LENGTH,
MAX_NAME_LENGTH,
MIN_PASSWORD_LENGTH,
INSTALLATION_PARTITION_KEY,
APP_PARTITION_KEY,
USER_LISTING_PARTITION_KEY,
ADMINS,
} from '../../constants'
import {
User,
UserToken,
Group,
GroupInvitation,
GroupMembership,
UserItemType,
UserPrivacyType,
GroupItemType,
GroupMembershipType,
GroupRegistrationType,
App,
Installation,
UserListing,
} from '../../types/collections'
import { PluginOptions } from '../../types'
function registerRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
interface Body {
id: string
name: string
email: string
password: string
requiresApproval: boolean
privacy: string
about?: string
phone?: string
imageUrl?: string
coverImageUrl?: string
theme: string
group?: string
invitation?: string
intro?: string
}
const options: RouteShorthandOptions = {
schema: {
description: 'Create a new User.',
tags: ['authentication', 'user'],
body: {
type: 'object',
required: ['id', 'email', 'password', 'theme'],
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' },
phone: { type: 'string' },
imageUrl: { type: 'string' },
coverImageUrl: { type: 'string' },
theme: { type: 'string' },
group: { type: 'string' },
invitation: { type: 'string' },
intro: { type: 'string' },
},
},
response: {
201: tokenResponseSchema,
400: errorSchema,
},
},
}
server.post<DefaultQuery, DefaultParams, DefaultHeaders, Body>('/v1/register', options, async (request, reply) => {
if (!server.database) return serverError(reply)
const {
name,
email,
password,
requiresApproval,
privacy,
about,
phone,
imageUrl,
coverImageUrl,
theme,
invitation: code,
intro,
} = request.body
const id = normalize(request.body.id)
const emailT = email.trim()
const phoneT = phone ? phone.trim() : undefined
const userContainer = containerFor(server.database.client, 'Users')
const groupContainer = containerFor(server.database.client, 'Groups')
const existingUser = await getItem<User>({ container: userContainer, id })
if (existingUser) return badRequestFormError(reply, 'id', 'User id already taken')
const emailListingId = await getUserIdFromEmail(server.database.client, emailT, request.log)
if (emailListingId) return badRequestFormError(reply, 'email', 'Email address already used')
if (phoneT) {
const phoneListingId = await getUserIdFromPhone(server.database.client, phoneT, request.log)
if (phoneListingId) return badRequestFormError(reply, 'phone', 'Phone number already used')
}
let userPending = false
let invitation: GroupInvitation | undefined
let group: Group | undefined
if (request.body.group) {
group = await getItem<Group>({ 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<GroupInvitation>({
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<App>({
container: appContainer,
query: `SELECT * FROM Apps a WHERE a.pk = '${APP_PARTITION_KEY}' AND 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>(installation)
installations.push(installation.id)
}
const user: User = {
id,
pk: id,
t: UserItemType.User,
groupId: group?.id,
name,
about,
email,
emailVerified: false,
phone,
phoneVerified: false,
passwordHash: await hashPassword(password),
imageUrl,
coverImageUrl,
theme,
settings: {
allowThemeChange: true,
alwaysReveal: false,
autoPlayGifs: true,
},
installations,
posts: 0,
subscriberCount: 0,
subscribedCount: 0,
pending: userPending,
requiresApproval,
privacy: privacy as UserPrivacyType,
active: true,
created: Date.now(),
}
const directoryContainer = containerFor(server.database.client, 'Directory')
await directoryContainer.items.create<UserListing>({
id,
pk: USER_LISTING_PARTITION_KEY,
groupId: group?.id,
email,
phone,
created: Date.now(),
})
const refreshToken = createRefreshToken(id, request.headers['user-agent'], request.ip)
await userContainer.items.create<User>(user)
await userContainer.items.create<UserToken>(refreshToken)
if (group) {
await groupContainer.items.create<GroupMembership>({
pk: group.id,
t: GroupItemType.Membership,
userId: user.id,
pending: userPending,
membership: GroupMembershipType.Member,
invitation: code,
intro,
created: Date.now(),
})
if (invitation) {
const invitationItem = groupContainer.item(invitation.id, group.id)
const uses = invitation.uses + 1
await invitationItem.replace<GroupInvitation>({
...invitation,
uses,
active: invitation.limit ? uses > invitation.limit : true,
})
}
if (!userPending) {
const groupItem = groupContainer.item(group.id, group.id)
await groupItem.replace<Group>({
...group,
members: group.members + 1,
})
}
}
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<Server, IncomingMessage, ServerResponse>) {
interface Body {
id: string
password: string
}
const options: RouteShorthandOptions = {
schema: {
description: 'Authenticate a User.',
tags: ['authentication'],
body: {
type: 'object',
required: ['id', 'password'],
properties: {
id: { type: 'string' },
password: { type: 'string' },
},
},
response: {
200: tokenResponseSchema,
400: errorSchema,
},
},
}
server.post<DefaultQuery, DefaultParams, DefaultHeaders, Body>('/v1/authenticate', options, async (request, reply) => {
if (!server.database) return serverError(reply)
let id: string | undefined
const isEmail = /^\S+@\S+\.\S+$/.test(request.body.id)
if (isEmail) {
const listings = await queryItems<UserListing>({
container: containerFor(server.database.client, 'Directory'),
query: createQuerySpec(`SELECT * FROM Directory d WHERE d.pk = ${USER_LISTING_PARTITION_KEY} AND d.email = @email`, { email: request.body.id }),
logger: request.log,
})
if (listings.length < 1) return badRequestFormError(reply, 'id', 'Email address not found')
id = listings[0].id
} else {
id = normalize(request.body.id)
}
const container = containerFor(server.database.client, 'Users')
const user = await getItem<User>({ 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<Server, IncomingMessage, ServerResponse>) {
interface Headers {
[key: string]: string
authorization: string
}
interface Body {
refresh: string
}
const options: RouteShorthandOptions = {
schema: {
description: 'Get new Access and Refresh tokens.',
tags: ['authentication'],
headers: {
type: 'object',
properties: {
authorization: { type: 'string' },
},
},
body: {
type: 'object',
required: ['refresh'],
properties: {
refresh: { type: 'string' },
},
},
response: {
201: tokenResponseSchema,
400: errorSchema,
},
},
}
server.post<DefaultQuery, DefaultParams, Headers, Body>('/v1/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)
const { resource: token } = await tokenItem.read<UserToken>()
if (!token) return badRequestError(reply, 'Invalid refresh token')
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<Server, IncomingMessage, ServerResponse>) {
const options: RouteShorthandOptions = {
schema: {
description: 'Get authenticated User.',
tags: ['authentication', 'user'],
response: {
200: selfSchema,
400: errorSchema,
},
},
}
server.get<DefaultQuery, DefaultParams, DefaultHeaders, DefaultBody>('/v1/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,
admin: ADMINS.includes(viewer.email)
}
})
}
const plugin: Plugin<Server, IncomingMessage, ServerResponse, PluginOptions> = async server => {
registerRoute(server)
authenticateRoute(server)
refreshRoute(server)
selfRoute(server)
}
export default plugin