Dwayne Harris 5 years ago
parent
commit
b5f837c68a
  1. 17
      .vscode/launch.json
  2. 2696
      package-lock.json
  3. 6
      package.json
  4. 7
      src/lib/authentication.ts
  5. 14
      src/lib/errors.ts
  6. 97
      src/plugins/api/authentication.ts
  7. 15
      src/plugins/api/index.ts
  8. 90
      src/plugins/api/profile.ts
  9. 2
      src/types/collections.ts

17
.vscode/launch.json

@ -0,0 +1,17 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Launch Program",
"program": "${workspaceFolder}\\dist\\server.js",
"outFiles": [
"${workspaceFolder}/**/*.js"
]
}
]
}

2696
package-lock.json
File diff suppressed because it is too large
View File

6
package.json

@ -6,12 +6,16 @@
"scripts": {
"start": "node dist/server.js",
"build": "tsc",
"watch": "tsc -w"
"watch-server": "nodemon dist/server.js",
"watch-typescript": "tsc -w",
"watch": "npm-run-all --parallel watch-typescript watch-server"
},
"devDependencies": {
"@types/dotenv": "^6.1.1",
"@types/jsonwebtoken": "^8.3.2",
"@types/uuid": "^3.4.5",
"nodemon": "^1.19.1",
"npm-run-all": "^4.1.5",
"pino-pretty": "^3.2.0",
"typescript": "^3.5.3"
},

7
src/lib/authentication.ts

@ -3,14 +3,15 @@ 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' })
return await JWT.sign({ sub: userId }, { expiresIn: process.env.TOKEN_EXPIRATION })
}
export function createRefreshToken(userId: string, userAgent: string): IRefreshToken {
export function createRefreshToken(userId: string, userAgent: string, ip: string): IRefreshToken {
return {
id: v1(),
id: 'r' + v1().replace(/-/g, ''),
userId,
userAgent,
ip,
expires: Date.now() + (1000 * 60 * 60 * 24 * 365),
created: Date.now(),
}

14
src/lib/errors.ts

@ -27,7 +27,17 @@ export function badRequestError(reply: FastifyReply<ServerResponse>, error: stri
}
}
export function serverError(reply: FastifyReply<ServerResponse>, error: string): IHttpError {
export function unauthorizedError(reply: FastifyReply<ServerResponse>): IHttpError {
const statusCode = 401
reply.code(statusCode)
return {
statusCode,
error: 'Unauthorized',
}
}
export function serverError(reply: FastifyReply<ServerResponse>, error: string = 'Server Error'): IHttpError {
const statusCode = 500
reply.code(statusCode)
@ -35,4 +45,4 @@ export function serverError(reply: FastifyReply<ServerResponse>, error: string):
statusCode,
error,
}
}
}

97
src/plugins/api/authentication.ts

@ -1,9 +1,10 @@
import fastify, {
import {
FastifyInstance,
Plugin,
DefaultQuery,
DefaultParams,
DefaultHeaders,
DefaultBody,
RouteShorthandOptions,
JSONSchema,
} from 'fastify'
@ -13,7 +14,7 @@ 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 { errorSchema, badRequestError, serverError, unauthorizedError } from '../../lib/errors'
import { tokenFromHeader } from '../../lib/http'
import { IUser, IRefreshToken } from '../../types/collections'
@ -64,7 +65,7 @@ function registerRoute(server: FastifyInstance<Server, IncomingMessage, ServerRe
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 (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')
@ -73,7 +74,8 @@ function registerRoute(server: FastifyInstance<Server, IncomingMessage, ServerRe
if (!server.database) return serverError(reply, 'Database error')
const userContainer = await containerFor(server.database.client, 'Users')
const existingUser = await userContainer.item(id, undefined)
const existingUserItem = await userContainer.item(id, undefined)
const { resource: existingUser } = await existingUserItem.read<IUser>()
if (existingUser) return badRequestError(reply, 'User id already taken', 'id')
@ -81,6 +83,7 @@ function registerRoute(server: FastifyInstance<Server, IncomingMessage, ServerRe
id,
name,
email,
emailVerified: false,
passwordHash: await hashPassword(password),
installations: [],
status: 'default',
@ -92,7 +95,7 @@ function registerRoute(server: FastifyInstance<Server, IncomingMessage, ServerRe
await userContainer.items.create(user)
const tokenContainer = await containerFor(server.database.client, 'Tokens')
const refreshToken = createRefreshToken(id, '')
const refreshToken = createRefreshToken(id, request.headers['user-agent'], request.ip)
await tokenContainer.items.create(refreshToken)
@ -104,7 +107,7 @@ function registerRoute(server: FastifyInstance<Server, IncomingMessage, ServerRe
})
}
function authenticateRoute(server: fastify.FastifyInstance<Server, IncomingMessage, ServerResponse>) {
function authenticateRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
interface Body {
id?: string
email?: string
@ -115,10 +118,9 @@ function authenticateRoute(server: fastify.FastifyInstance<Server, IncomingMessa
schema: {
body: {
type: 'object',
required: ['password'],
required: ['id', 'password'],
properties: {
id: { type: 'string' },
email: { type: 'string' },
password: { type: 'string' },
},
},
@ -130,14 +132,11 @@ function authenticateRoute(server: fastify.FastifyInstance<Server, IncomingMessa
}
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 { password } = request.body
const id = normalize(request.body.id)
const container = await containerFor(server.database.client, 'Users')
const { resources: results } = await container.items.query<IUser>(query, {}).fetchAll()
const user = results[0]
const userItem = container.item(id, id)
const { resource: user } = await userItem.read<IUser>()
if (!user) return badRequestError(reply, 'User not found')
@ -145,7 +144,7 @@ function authenticateRoute(server: fastify.FastifyInstance<Server, IncomingMessa
if (!result) return badRequestError(reply, 'Incorrect credentials')
const tokenContainer = await containerFor(server.database.client, 'Tokens')
const refreshToken = createRefreshToken(user.id, '')
const refreshToken = createRefreshToken(user.id, request.headers['user-agent'], request.ip)
await tokenContainer.items.create(refreshToken)
@ -157,9 +156,10 @@ function authenticateRoute(server: fastify.FastifyInstance<Server, IncomingMessa
})
}
function refreshRoute(server: fastify.FastifyInstance<Server, IncomingMessage, ServerResponse>) {
function refreshRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
interface Headers {
authentication: string
[key: string]: string
authorization: string
}
interface Body {
@ -171,11 +171,12 @@ function refreshRoute(server: fastify.FastifyInstance<Server, IncomingMessage, S
headers: {
type: 'object',
properties: {
authentication: { type: 'string' },
authorization: { type: 'string' },
},
},
body: {
type: 'object',
required: ['refresh'],
properties: {
refresh: { type: 'string' },
},
@ -188,16 +189,22 @@ function refreshRoute(server: fastify.FastifyInstance<Server, IncomingMessage, S
}
server.post<DefaultQuery, DefaultParams, Headers, Body>('/api/refresh', options, async (request, reply) => {
const tokenString = tokenFromHeader(request.headers.authentication)
const tokenString = tokenFromHeader(request.headers.authorization)
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')
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 userId = tokenData.sub
const container = await containerFor(server.database.client, 'Tokens')
const tokenItem = await container.item(request.body.refresh, userId)
@ -205,24 +212,56 @@ function refreshRoute(server: fastify.FastifyInstance<Server, IncomingMessage, S
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, '')
const newRefreshToken = createRefreshToken(userId, request.headers['user-agent'], request.ip)
await container.items.create(newRefreshToken)
return {
id: tokenData.sub,
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.user) return unauthorizedError(reply)
const container = await containerFor(server.database.client, 'Users')
const userItem = await container.item(request.user.id, request.user.id)
const { resource: user } = await userItem.read<IUser>()
if (!user) return unauthorizedError(reply)
return {
id: user.id,
name: user.name,
}
})
}
const plugin: Plugin<Server, IncomingMessage, ServerResponse, PluginOptions> = async server => {
registerRoute(server)
authenticateRoute(server)
refreshRoute(server)
selfRoute(server)
}
export default plugin

15
src/plugins/api/index.ts

@ -6,6 +6,7 @@ import { JWT } from '../../lib/crypto'
import { tokenFromHeader } from '../../lib/http'
import authentication from './authentication'
import profile from './profile'
interface IDatabase {
client: CosmosClient
@ -18,6 +19,9 @@ interface IUserInfo {
declare module "fastify" {
interface FastifyInstance {
database?: IDatabase
}
interface FastifyRequest{
user?: IUserInfo
}
}
@ -35,17 +39,17 @@ const plugin: Plugin<Server, IncomingMessage, ServerResponse, PluginOptions> = a
}
server.decorate('database', database)
server.decorate('user', null)
server.decorateRequest('user', null)
server.addHook('preHandler', async (req, reply) => {
server.addHook('preHandler', async req => {
const token = tokenFromHeader(req.headers.authorization)
if (!token) return
try {
const data: JWT.IJWTData = await JWT.verify(token)
server.user = {
id: data.sub,
req.user = {
id: data.sub
}
} catch (err) {
server.log.error('Unable to decode token')
@ -54,6 +58,7 @@ const plugin: Plugin<Server, IncomingMessage, ServerResponse, PluginOptions> = a
})
server.register(authentication)
server.register(profile)
}
export default plugin
export default plugin

90
src/plugins/api/profile.ts

@ -0,0 +1,90 @@
import fastify, {
Plugin,
DefaultQuery,
DefaultParams,
RouteShorthandOptions,
} from 'fastify'
import { Server, IncomingMessage, ServerResponse } from 'http'
import { unauthorizedError, serverError } from '../../lib/errors'
import { containerFor } from '../../lib/database'
import { IUser } from '../../types/collections'
interface PluginOptions {
}
function updateRoute(server: fastify.FastifyInstance<Server, IncomingMessage, ServerResponse>) {
interface Headers {
authorization: string
}
interface Body {
name?: string
about?: string
}
const options: RouteShorthandOptions = {
schema: {
headers: {
type: 'object',
properties: {
authorization: { type: 'string' },
},
},
body: {
type: 'object',
properties: {
name: { type: 'string' },
about: { type: 'string' },
},
},
response: {
200: {
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
},
},
},
},
}
server.put<DefaultQuery, DefaultParams, Headers, Body>('/api/self', options, async (request, reply) => {
if (!request.user) return unauthorizedError(reply)
const container = await containerFor(server.database.client, 'Users')
const userItem = await container.item(request.user.id, request.user.id)
const { resource: user } = await userItem.read<IUser>()
if (!user) return serverError(reply)
if (request.body.name) {
const name = request.body.name.trim()
if (name !== '') {
user.name = name
}
}
if (request.body.about) {
const about = request.body.about.trim()
if (about !== '') {
user.about = about
}
}
await userItem.replace<IUser>(user)
return {
id: user.id,
name: user.name,
}
})
}
const plugin: Plugin<Server, IncomingMessage, ServerResponse, PluginOptions> = async server => {
updateRoute(server)
}
export default plugin

2
src/types/collections.ts

@ -19,6 +19,7 @@ export interface IUser {
id: string // Partition Key
name: string
email: string
emailVerified: boolean
passwordHash: string
installations: IInstallation[]
status: IAccountStatus
@ -31,6 +32,7 @@ export interface IRefreshToken {
id: string
userId: string
userAgent: string
ip: string
created: number
expires: number
}

Loading…
Cancel
Save