Dwayne Harris
5 years ago
13 changed files with 1765 additions and 0 deletions
-
7README.md
-
1157package-lock.json
-
28package.json
-
17src/lib/authentication.ts
-
35src/lib/crypto.ts
-
28src/lib/database.ts
-
38src/lib/errors.ts
-
8src/lib/http.ts
-
228src/plugins/api/authentication.ts
-
59src/plugins/api/index.ts
-
28src/server.ts
-
110src/types/collections.ts
-
22tsconfig.json
@ -0,0 +1,7 @@ |
|||
# flexor-api |
|||
|
|||
API server for Flexor. |
|||
|
|||
## License |
|||
|
|||
Copyright © 2019 Flexor.cc |
1157
package-lock.json
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
@ -0,0 +1,28 @@ |
|||
{ |
|||
"name": "flexor-api", |
|||
"description": "Flexor API server.", |
|||
"version": "1.0.0", |
|||
"private": true, |
|||
"scripts": { |
|||
"start": "node dist/server.js", |
|||
"build": "tsc", |
|||
"watch": "tsc -w" |
|||
}, |
|||
"devDependencies": { |
|||
"@types/dotenv": "^6.1.1", |
|||
"@types/jsonwebtoken": "^8.3.2", |
|||
"@types/uuid": "^3.4.5", |
|||
"pino-pretty": "^3.2.0", |
|||
"typescript": "^3.5.3" |
|||
}, |
|||
"dependencies": { |
|||
"@azure/cosmos": "^3.1.0", |
|||
"argon2": "^0.24.0", |
|||
"dotenv": "^8.0.0", |
|||
"fastify": "^2.7.1", |
|||
"fastify-helmet": "^3.0.1", |
|||
"jsonwebtoken": "^8.5.1", |
|||
"uuid": "^3.3.2", |
|||
"winston": "^3.2.1" |
|||
} |
|||
} |
@ -0,0 +1,17 @@ |
|||
import { v1 } from 'uuid' |
|||
import { JWT } from '../lib/crypto' |
|||
import { IRefreshToken } from '../types/collections' |
|||
|
|||
export async function createAccessToken(userId: string): Promise<string> { |
|||
return await JWT.sign({ sub: userId }, { expiresIn: '1d' }) |
|||
} |
|||
|
|||
export function createRefreshToken(userId: string, userAgent: string): IRefreshToken { |
|||
return { |
|||
id: v1(), |
|||
userId, |
|||
userAgent, |
|||
expires: Date.now() + (1000 * 60 * 60 * 24 * 365), |
|||
created: Date.now(), |
|||
} |
|||
} |
@ -0,0 +1,35 @@ |
|||
import argon2 from 'argon2' |
|||
import jwt, { SignOptions, VerifyOptions } from 'jsonwebtoken' |
|||
|
|||
export async function hashPassword(password: string): Promise<string> { |
|||
return await argon2.hash(password) |
|||
} |
|||
|
|||
export async function verifyPassword(hash: string, password: string): Promise<boolean> { |
|||
return await argon2.verify(hash, password) |
|||
} |
|||
|
|||
export namespace JWT { |
|||
export interface IJWTData { |
|||
sub?: string |
|||
exp?: number |
|||
} |
|||
|
|||
export function sign(data: IJWTData, options?: SignOptions): Promise<string> { |
|||
return new Promise<string>((resolve, reject) => { |
|||
jwt.sign(data, process.env.TOKEN_SECRET, options, (err, token) => { |
|||
if (err) return reject(err) |
|||
resolve(token) |
|||
}) |
|||
}) |
|||
} |
|||
|
|||
export function verify(token: string, options?: VerifyOptions): Promise<IJWTData> { |
|||
return new Promise<IJWTData>((resolve, reject) => { |
|||
jwt.verify(token, process.env.TOKEN_SECRET, options, (err, data: IJWTData) => { |
|||
if (err) return reject(err) |
|||
resolve(data) |
|||
}) |
|||
}) |
|||
} |
|||
} |
@ -0,0 +1,28 @@ |
|||
import { CosmosClient, Container, SqlQuerySpec } from '@azure/cosmos' |
|||
|
|||
interface IQueryParams { |
|||
[key: string]: string |
|||
} |
|||
|
|||
export async function containerFor(client: CosmosClient, containerId: string): Promise<Container> { |
|||
const { database } = await client.databases.createIfNotExists({ id: 'Flexor' }) |
|||
const { container } = await database.containers.createIfNotExists({ id: containerId }) |
|||
|
|||
return container |
|||
} |
|||
|
|||
export function createQuerySpec(query: string, params: IQueryParams): SqlQuerySpec { |
|||
return { |
|||
query, |
|||
parameters: Object.entries(params).map(([key, value]) => { |
|||
return { |
|||
name: key.startsWith('@') ? key : `@${key}`, |
|||
value, |
|||
} |
|||
}) |
|||
} |
|||
} |
|||
|
|||
export function normalize(text: string): string { |
|||
return text.replace(/[^A-Za-z0-9]/g, '-').toLowerCase() |
|||
} |
@ -0,0 +1,38 @@ |
|||
import { FastifyReply, JSONSchema } from 'fastify' |
|||
import { ServerResponse } from 'http' |
|||
|
|||
interface IHttpError { |
|||
statusCode: number |
|||
error: string |
|||
field?: string |
|||
} |
|||
|
|||
export const errorSchema: JSONSchema = { |
|||
type: 'object', |
|||
properties: { |
|||
statusCode: { type: 'number' }, |
|||
error: { type: 'string' }, |
|||
field: { type: ['string', 'null'] }, |
|||
} |
|||
} |
|||
|
|||
export function badRequestError(reply: FastifyReply<ServerResponse>, error: string, field?: string): IHttpError { |
|||
const statusCode = 400 |
|||
reply.code(statusCode) |
|||
|
|||
return { |
|||
statusCode, |
|||
error, |
|||
field, |
|||
} |
|||
} |
|||
|
|||
export function serverError(reply: FastifyReply<ServerResponse>, error: string): IHttpError { |
|||
const statusCode = 500 |
|||
reply.code(statusCode) |
|||
|
|||
return { |
|||
statusCode, |
|||
error, |
|||
} |
|||
} |
@ -0,0 +1,8 @@ |
|||
export function tokenFromHeader(authorization: string): string { |
|||
if (!authorization) return |
|||
|
|||
const [type, token] = authorization.split(' ') |
|||
if (type.toLowerCase() !== 'bearer') return |
|||
|
|||
return token |
|||
} |
@ -0,0 +1,228 @@ |
|||
import fastify, { |
|||
FastifyInstance, |
|||
Plugin, |
|||
DefaultQuery, |
|||
DefaultParams, |
|||
DefaultHeaders, |
|||
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, createQuerySpec, normalize } from '../../lib/database' |
|||
import { errorSchema, badRequestError, serverError } from '../../lib/errors' |
|||
import { tokenFromHeader } from '../../lib/http' |
|||
|
|||
import { IUser, IRefreshToken } 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 |
|||
} |
|||
|
|||
const options: RouteShorthandOptions = { |
|||
schema: { |
|||
body: { |
|||
type: 'object', |
|||
required: ['id', 'email', 'password'], |
|||
properties: { |
|||
id: { type: 'string' }, |
|||
name: { type: 'string' }, |
|||
email: { type: 'string' }, |
|||
password: { type: 'string' }, |
|||
}, |
|||
}, |
|||
response: { |
|||
201: tokenResponseSchema, |
|||
400: errorSchema, |
|||
}, |
|||
}, |
|||
} |
|||
|
|||
server.post<DefaultQuery, DefaultParams, DefaultHeaders, Body>('/api/register', options, async (request, reply) => { |
|||
const { name, email, password } = 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.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') |
|||
|
|||
if (!server.database) return serverError(reply, 'Database error') |
|||
|
|||
const userContainer = await containerFor(server.database.client, 'Users') |
|||
const existingUser = await userContainer.item(id, undefined) |
|||
|
|||
if (existingUser) return badRequestError(reply, 'User id already taken', 'id') |
|||
|
|||
const user: IUser = { |
|||
id, |
|||
name, |
|||
email, |
|||
passwordHash: await hashPassword(password), |
|||
installations: [], |
|||
status: 'default', |
|||
points: 0, |
|||
about: '', |
|||
created: Date.now(), |
|||
} |
|||
|
|||
await userContainer.items.create(user) |
|||
|
|||
const tokenContainer = await containerFor(server.database.client, 'Tokens') |
|||
const refreshToken = createRefreshToken(id, '') |
|||
|
|||
await tokenContainer.items.create(refreshToken) |
|||
|
|||
return { |
|||
id, |
|||
access: await createAccessToken(id), |
|||
refresh: refreshToken.id, |
|||
} |
|||
}) |
|||
} |
|||
|
|||
function authenticateRoute(server: fastify.FastifyInstance<Server, IncomingMessage, ServerResponse>) { |
|||
interface Body { |
|||
id?: string |
|||
email?: string |
|||
password: string |
|||
} |
|||
|
|||
const options: RouteShorthandOptions = { |
|||
schema: { |
|||
body: { |
|||
type: 'object', |
|||
required: ['password'], |
|||
properties: { |
|||
id: { type: 'string' }, |
|||
email: { type: 'string' }, |
|||
password: { type: 'string' }, |
|||
}, |
|||
}, |
|||
response: { |
|||
200: tokenResponseSchema, |
|||
400: errorSchema, |
|||
}, |
|||
}, |
|||
} |
|||
|
|||
server.post<DefaultQuery, DefaultParams, DefaultHeaders, Body>('/api/authenticate', options, async (request, reply) => { |
|||
const { id, email, password } = request.body |
|||
|
|||
if (!id && !email) return badRequestError(reply, 'id or email required') |
|||
|
|||
const query = createQuerySpec('SELECT u.id, u.passwordHash FROM Users u WHERE u.id = @id OR u.email = @email', { id, email }) |
|||
const container = await containerFor(server.database.client, 'Users') |
|||
const { resources: results } = await container.items.query<IUser>(query, {}).fetchAll() |
|||
const user = results[0] |
|||
|
|||
if (!user) return badRequestError(reply, 'User not found') |
|||
|
|||
const result = await verifyPassword(user.passwordHash, password) |
|||
if (!result) return badRequestError(reply, 'Incorrect credentials') |
|||
|
|||
const tokenContainer = await containerFor(server.database.client, 'Tokens') |
|||
const refreshToken = createRefreshToken(user.id, '') |
|||
|
|||
await tokenContainer.items.create(refreshToken) |
|||
|
|||
return { |
|||
id, |
|||
access: await createAccessToken(user.id), |
|||
refresh: refreshToken.id, |
|||
} |
|||
}) |
|||
} |
|||
|
|||
function refreshRoute(server: fastify.FastifyInstance<Server, IncomingMessage, ServerResponse>) { |
|||
interface Headers { |
|||
authentication: string |
|||
} |
|||
|
|||
interface Body { |
|||
refresh: string |
|||
} |
|||
|
|||
const options: RouteShorthandOptions = { |
|||
schema: { |
|||
headers: { |
|||
type: 'object', |
|||
properties: { |
|||
authentication: { type: 'string' }, |
|||
}, |
|||
}, |
|||
body: { |
|||
type: 'object', |
|||
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.authentication) |
|||
if (!tokenString) return badRequestError(reply, 'Access token required') |
|||
|
|||
const tokenData = await JWT.verify(tokenString, { |
|||
ignoreExpiration: true, |
|||
}) |
|||
|
|||
if (tokenData.exp > Date.now()) return badRequestError(reply, 'Token must be expired') |
|||
|
|||
const userId = tokenData.sub |
|||
const container = await containerFor(server.database.client, 'Tokens') |
|||
const tokenItem = await container.item(request.body.refresh, userId) |
|||
|
|||
if (!tokenItem) return badRequestError(reply, 'Invalid refresh token') |
|||
const { resource: token } = await tokenItem.read<IRefreshToken>() |
|||
|
|||
if (token.expires < Date.now()) return badRequestError(reply, 'Refresh token expired') |
|||
if (token.userId !== tokenData.sub) return badRequestError(reply, 'Invalid') |
|||
|
|||
await tokenItem.delete() |
|||
|
|||
const newRefreshToken = createRefreshToken(userId, '') |
|||
|
|||
return { |
|||
id: tokenData.sub, |
|||
access: await createAccessToken(userId), |
|||
refresh: newRefreshToken.id |
|||
} |
|||
}) |
|||
} |
|||
|
|||
const plugin: Plugin<Server, IncomingMessage, ServerResponse, PluginOptions> = async server => { |
|||
registerRoute(server) |
|||
authenticateRoute(server) |
|||
refreshRoute(server) |
|||
} |
|||
|
|||
export default plugin |
@ -0,0 +1,59 @@ |
|||
import { CosmosClient } from '@azure/cosmos' |
|||
import { Plugin } from 'fastify' |
|||
import { Server, IncomingMessage, ServerResponse } from 'http' |
|||
|
|||
import { JWT } from '../../lib/crypto' |
|||
import { tokenFromHeader } from '../../lib/http' |
|||
|
|||
import authentication from './authentication' |
|||
|
|||
interface IDatabase { |
|||
client: CosmosClient |
|||
} |
|||
|
|||
interface IUserInfo { |
|||
id: string |
|||
} |
|||
|
|||
declare module "fastify" { |
|||
interface FastifyInstance { |
|||
database?: IDatabase |
|||
user?: IUserInfo |
|||
} |
|||
} |
|||
|
|||
interface PluginOptions { |
|||
|
|||
} |
|||
|
|||
const plugin: Plugin<Server, IncomingMessage, ServerResponse, PluginOptions> = async (server, options) => { |
|||
const database: IDatabase = { |
|||
client: new CosmosClient({ |
|||
endpoint: process.env.DATABASE_ENDPOINT, |
|||
key: process.env.DATABASE_PRIMARY_KEY, |
|||
}), |
|||
} |
|||
|
|||
server.decorate('database', database) |
|||
server.decorate('user', null) |
|||
|
|||
server.addHook('preHandler', async (req, reply) => { |
|||
const token = tokenFromHeader(req.headers.authorization) |
|||
if (!token) return |
|||
|
|||
try { |
|||
const data: JWT.IJWTData = await JWT.verify(token) |
|||
|
|||
server.user = { |
|||
id: data.sub, |
|||
} |
|||
} catch (err) { |
|||
server.log.error('Unable to decode token') |
|||
server.log.error(err) |
|||
} |
|||
}) |
|||
|
|||
server.register(authentication) |
|||
} |
|||
|
|||
export default plugin |
@ -0,0 +1,28 @@ |
|||
import { config } from 'dotenv' |
|||
import fastify from 'fastify' |
|||
import helmet from 'fastify-helmet' |
|||
|
|||
import api from './plugins/api' |
|||
|
|||
config() |
|||
|
|||
const server = fastify({ |
|||
logger: { |
|||
level: process.env.LOGGER_LEVEL, |
|||
prettyPrint: process.env.LOGGER_PRETTY_PRINT === 'true', |
|||
}, |
|||
}) |
|||
|
|||
server.register(helmet) |
|||
server.register(api) |
|||
|
|||
const start = async () => { |
|||
try { |
|||
await server.listen(process.env.PORT) |
|||
} catch (err) { |
|||
server.log.error(err) |
|||
process.exit(1) |
|||
} |
|||
} |
|||
|
|||
start() |
@ -0,0 +1,110 @@ |
|||
// Containers
|
|||
//
|
|||
// Users
|
|||
// - Partition Key: id
|
|||
// Tokens
|
|||
// - Parition Key: userId
|
|||
// Posts
|
|||
// - Partition Key: id
|
|||
// UserPosts
|
|||
// - Partition Key: by (userId)
|
|||
// ForUserPosts
|
|||
// - Partition Key: for (userId)
|
|||
// PostPosts
|
|||
// - Partition Key: root (postId)
|
|||
|
|||
export type IAccountStatus = 'default' | 'platinum' |
|||
|
|||
export interface IUser { |
|||
id: string // Partition Key
|
|||
name: string |
|||
email: string |
|||
passwordHash: string |
|||
installations: IInstallation[] |
|||
status: IAccountStatus |
|||
points: number |
|||
about: string |
|||
created: number // Timestamp
|
|||
} |
|||
|
|||
export interface IRefreshToken { |
|||
id: string |
|||
userId: string |
|||
userAgent: string |
|||
created: number |
|||
expires: number |
|||
} |
|||
|
|||
export interface IInstallation { |
|||
id: string |
|||
appId: string |
|||
settings: object |
|||
created: number |
|||
} |
|||
|
|||
interface IAward { |
|||
imageUrl: string |
|||
text: string |
|||
received: number |
|||
points: number |
|||
} |
|||
|
|||
interface IAppRevision { |
|||
version: string |
|||
displayName: string |
|||
imageUrl: string |
|||
coverImageUrl: string |
|||
description: string |
|||
websiteUrl: string |
|||
companyName: string |
|||
composerUrl: string |
|||
composerSchema: object |
|||
rendererUrl: string |
|||
rendererSchema: object |
|||
initCallbackUrl: string |
|||
composeCallbackUrl: string |
|||
} |
|||
|
|||
interface IApp { |
|||
id: string |
|||
userId: string |
|||
name: string |
|||
version: string |
|||
rating: number |
|||
revisions: IAppRevision[] |
|||
currentRevisionIndex: number |
|||
publicKey: string |
|||
privateKey: string |
|||
created: number |
|||
} |
|||
|
|||
interface PostAttachment { |
|||
imageUrl: string |
|||
caption?: string |
|||
cover?: string |
|||
} |
|||
|
|||
interface IPartialUser { |
|||
id: string |
|||
name: string |
|||
} |
|||
|
|||
interface IStatus { |
|||
imageUrl: string |
|||
text: string |
|||
created: number |
|||
} |
|||
|
|||
interface IPost { |
|||
id: string |
|||
user: IPartialUser |
|||
parents: string[] // Post IDs
|
|||
text?: string |
|||
cover?: string |
|||
attachments: PostAttachment[] |
|||
status?: IStatus |
|||
visible: boolean |
|||
category: string // Content, Badge, Profile Attribute
|
|||
awards: IAward[] |
|||
created: number |
|||
} |
@ -0,0 +1,22 @@ |
|||
{ |
|||
"compilerOptions": { |
|||
"module": "commonjs", |
|||
"esModuleInterop": true, |
|||
"allowSyntheticDefaultImports": true, |
|||
"target": "es6", |
|||
"noImplicitAny": true, |
|||
"moduleResolution": "node", |
|||
"sourceMap": true, |
|||
"outDir": "dist", |
|||
"baseUrl": ".", |
|||
"paths": { |
|||
"*": [ |
|||
"node_modules/*", |
|||
"src/types/*", |
|||
], |
|||
}, |
|||
}, |
|||
"include": [ |
|||
"src/**/*", |
|||
], |
|||
} |
Write
Preview
Loading…
Cancel
Save
Reference in new issue