Dwayne Harris 5 years ago
parent
commit
d8d764b695
  1. 7
      README.md
  2. 1157
      package-lock.json
  3. 28
      package.json
  4. 17
      src/lib/authentication.ts
  5. 35
      src/lib/crypto.ts
  6. 28
      src/lib/database.ts
  7. 38
      src/lib/errors.ts
  8. 8
      src/lib/http.ts
  9. 228
      src/plugins/api/authentication.ts
  10. 59
      src/plugins/api/index.ts
  11. 28
      src/server.ts
  12. 110
      src/types/collections.ts
  13. 22
      tsconfig.json

7
README.md

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

28
package.json

@ -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"
}
}

17
src/lib/authentication.ts

@ -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(),
}
}

35
src/lib/crypto.ts

@ -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)
})
})
}
}

28
src/lib/database.ts

@ -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()
}

38
src/lib/errors.ts

@ -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,
}
}

8
src/lib/http.ts

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

228
src/plugins/api/authentication.ts

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

59
src/plugins/api/index.ts

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

28
src/server.ts

@ -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()

110
src/types/collections.ts

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

22
tsconfig.json

@ -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/**/*",
],
}
Loading…
Cancel
Save