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