From 3411ae1234b530cf659ee34c4fb754d290657813 Mon Sep 17 00:00:00 2001 From: mifi Date: Tue, 2 May 2023 01:14:23 -0400 Subject: [PATCH] Initial auth library commmit --- lib/app.ts | 27 ++++ lib/auth.ts | 16 ++ lib/constants/strategies.ts | 7 + lib/controllers/auth.ts | 231 ++++++++++++++++++++++++++++ lib/database/database.connection.ts | 11 ++ lib/middleware/authenication.ts | 0 lib/middleware/errorHandler.ts | 13 ++ lib/middleware/performance.ts | 15 ++ lib/model/auth.ts | 4 + lib/schema/auth.ts | 134 ++++++++++++++++ lib/schema/strategy.ts | 25 +++ lib/server.ts | 12 ++ lib/strategies/local.ts | 20 +++ lib/utils/auth.ts | 14 ++ lib/utils/jwt.ts | 31 ++++ lib/utils/password.ts | 12 ++ lib/utils/tokens.ts | 13 ++ 17 files changed, 585 insertions(+) create mode 100644 lib/app.ts create mode 100644 lib/auth.ts create mode 100644 lib/constants/strategies.ts create mode 100644 lib/controllers/auth.ts create mode 100644 lib/database/database.connection.ts create mode 100644 lib/middleware/authenication.ts create mode 100644 lib/middleware/errorHandler.ts create mode 100644 lib/middleware/performance.ts create mode 100644 lib/model/auth.ts create mode 100644 lib/schema/auth.ts create mode 100644 lib/schema/strategy.ts create mode 100644 lib/server.ts create mode 100644 lib/strategies/local.ts create mode 100644 lib/utils/auth.ts create mode 100644 lib/utils/jwt.ts create mode 100644 lib/utils/password.ts create mode 100644 lib/utils/tokens.ts diff --git a/lib/app.ts b/lib/app.ts new file mode 100644 index 0000000..350fdf1 --- /dev/null +++ b/lib/app.ts @@ -0,0 +1,27 @@ +import Koa from 'koa'; +import bodyParser from 'koa-bodyparser'; +import cookie from 'koa-cookie'; +import passport from 'koa-passport'; +import session from 'koa-session'; + +import { performanceLogger, perfromanceTimer } from './middleware/performance'; +import { errorHandler } from './middleware/errorHandler'; + +const app: Koa = new Koa(); + +app.use(errorHandler); +app.use(perfromanceTimer); +app.use(performanceLogger); +app.use(bodyParser()); +app.use(cookie()); + +app.keys = [process.env.SESSION_KEYS as string]; +app.use(session({}, app)); + +app.use(passport.initialize()) +app.use(passport.session()) + +// Application error logging. +app.on('error', console.error); + +export default app; \ No newline at end of file diff --git a/lib/auth.ts b/lib/auth.ts new file mode 100644 index 0000000..3b207c5 --- /dev/null +++ b/lib/auth.ts @@ -0,0 +1,16 @@ +import passport from 'koa-passport'; + +import Users from 'grow-db/lib/models/users'; +import { User } from 'grow-db/lib/schemas/user'; + +passport.serializeUser((user: User, done) => { done(null, user._id); }); + +passport.deserializeUser(async (id, done) => { + const user = await Users.findById(id); + + if (user) { + done(null, user); + } + + done('user not found', null); +}); diff --git a/lib/constants/strategies.ts b/lib/constants/strategies.ts new file mode 100644 index 0000000..ea91c22 --- /dev/null +++ b/lib/constants/strategies.ts @@ -0,0 +1,7 @@ +export enum STRATEGIES { + LOCAL, + APPLE, + FACEBOOK, + FIDO2, + GOOGLE, +} diff --git a/lib/controllers/auth.ts b/lib/controllers/auth.ts new file mode 100644 index 0000000..8fc1ddc --- /dev/null +++ b/lib/controllers/auth.ts @@ -0,0 +1,231 @@ +// const errors = require('restify-errors'); + +// const config = require('../config'); + +// const handlePassportResponse = (req, res, next) => (err, user, info) => { +// if (err) { +// return next(err); +// } + +// const isVerifiedUser = user && +// user.isRegistrationVerified(); + +// if (user && isVerifiedUser) { +// return res.send({ ...user.toAuthJSON() }); +// } else if (user && !isVerifiedUser){ +// return res.send({ +// registrationSuccess: true, +// nextSteps: 'Check your email for our confirmation email, you will not be able to login without confirming.' +// }); +// } + +// return res.send(400, info); +// }; + +// module.exports = function (server, auth) { +// const { passport } = auth; + +// /* Local Auth */ +// server.post('/auth', (req, res, next) => { +// const { body: { username = null, password = null } = {} } = req; + +// if (!username || !password) { +// let errors = {}; + +// if (!username) { +// errors.username = 'is required'; +// } + +// if (!password) { +// errors.password = 'is required'; +// } + +// return res.send(422, { errors }); +// } + +// const callback = handlePassportResponse(req, res, next); +// return passport.authenticate('local', { session: false }.then(callback)(req, res, next); +// }); + +// /** +// * SERVICES +// */ + +// /* Google */ +// server.get( +// '/auth/google', +// passport.authenticate('google', { scope: 'profile email', session: false }), +// ); + +// server.get( +// '/auth/google/callback', +// (req, res, next) => { +// const callback = handlePassportResponse(req, res, next); +// return passport.authenticate( +// 'google', +// { failureRedirect: '/login' }, +// callback, +// )(req, res, next); +// }, +// ); + +// /* Facebook */ +// server.get( +// '/auth/facebook/login', +// passport.authenticate('facebook', { +// scope: ['email', 'public_profile'], +// session: false, +// }), +// ); + +// server.get( +// '/auth/facebook/loggedin', +// (req, res, next) => { +// const callback = handlePassportResponse(req, res, next); +// return passport.authenticate( +// 'facebook', +// { failureRedirect: '/login' }, +// callback, +// )(req, res, next); +// } +// ); + +// server.get( +// '/auth/facebook/link', +// auth.secure, +// (req, res, next) => { +// req.user.record.setLinkCheckBit((err, linkCheckBit) => { +// passport.authenticate('facebookLink', { +// scope: ['email', 'public_profile'], +// session: false, +// state: linkCheckbit, +// })(req, res, next); +// }); +// }, +// ); +// +// server.get( +// '/auth/facebook/linked', +// (req, res, next) => { +// const linkCheckBit = req.query.state; +// +// return passport.authenticate( +// 'facebook', +// { failureRedirect: '/profile' }, +// (err, profile) => { +// if (err) { +// return next(err); +// } +// +// User.linkFacebookProfile(linkCheckBit, profile, (err, user) => { +// if (err) { +// return next(err); +// } +// +// if (!user) { +// return next(err, false, 'Linking the account to Facebook was unsuccessful, please try again.'); +// } +// +// res.send({ +// success: true, +// info: 'Facerbook account successfully linked', +// }); +// }); +// }, +// )(req, res, next); +// } +// ); +}; + +import Koa from 'koa'; +import Router from 'koa-router'; +import { StatusCodes } from 'http-status-codes'; + +import Users from 'grow-db/lib/models/users'; + +const handlePassportResponse = (ctx: Koa.Context) => (err, user, info) => { + if (err) { + return next(err); + } + + const isVerifiedUser = user && + user.isRegistrationVerified(); + + if (user && isVerifiedUser) { + return res.send({ ...user.toAuthJSON() }); + } else if (user && !isVerifiedUser){ + return res.send({ + registrationSuccess: true, + nextSteps: 'Check your email for our confirmation email, you will not be able to login without confirming.' + }); + } + + return res.send(400, info); +}; + +const routerOpts: Router.IRouterOptions = { + prefix: '/auth', +}; + +const router: Router = new Router(routerOpts); + +router.get('/', async (ctx: Koa.Context) => { + const data = await Customers.find({}).exec(); + ctx.body = { data }; +}); + + +router.get('/:customer_id', async (ctx: Koa.Context) => { + const data = await Customers.findById(ctx.params.customer_id).populate('person').exec(); + if (!data) { + ctx.throw(StatusCodes.NOT_FOUND); + } + ctx.body = { data }; +}); + +router.delete('/:customer_id', async (ctx: Koa.Context) => { + const data = await Customers.findByIdAndDelete(ctx.params.customer_id).exec(); + if (!data) { + ctx.throw(StatusCodes.NOT_FOUND); + } + ctx.body = { success: true, data }; +}); + + +router.post('/', async (ctx: Koa.Context) => { + const data = await Customers.create(ctx.body); + data.save(); + ctx.body = { success: true, data }; +}); + + +router.post('/', async (ctx: Koa.Context) => { + const { body: { username = null, password = null } = {} } = ctx; + + if (!username || !password) { + let errors = {}; + + if (!username) { + errors.username = 'is required'; + } + + if (!password) { + errors.password = 'is required'; + } + + ctx.status = StatusCodes.UNPROCESSABLE_ENTITY; + ctx.throw(422, { errors }); + } + + const callback = handlePassportResponse(req, res, next); + return passport.authenticate('local', { session: false }, callback)(req, res, next); +}); + + +router.patch('/:customer_id', async (ctx: Koa.Context) => { + const data = await Customers.findByIdAndUpdate(ctx.params.customer_id); + if (!data) { + ctx.throw(StatusCodes.NOT_FOUND); + } + ctx.body = { success: true, data }; +}); \ No newline at end of file diff --git a/lib/database/database.connection.ts b/lib/database/database.connection.ts new file mode 100644 index 0000000..4675347 --- /dev/null +++ b/lib/database/database.connection.ts @@ -0,0 +1,11 @@ +import mongoose from 'mongoose'; + +const DB_USER = process.env.DB_USER || 'test'; +const DB_PASS = process.env.DB_PASSWORD || 'test'; +const DB_HOST = process.env.DB_HOST || 'mongodb'; +const DB_PORT = process.env.DB_PORT || 27017; +const DB_NAME = process.env.DB_NAME || 'auth'; + +export const connection = mongoose.connect( + `${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/${DB_NAME}` +); diff --git a/lib/middleware/authenication.ts b/lib/middleware/authenication.ts new file mode 100644 index 0000000..e69de29 diff --git a/lib/middleware/errorHandler.ts b/lib/middleware/errorHandler.ts new file mode 100644 index 0000000..27dfea6 --- /dev/null +++ b/lib/middleware/errorHandler.ts @@ -0,0 +1,13 @@ +import { StatusCodes } from "http-status-codes"; +import { Context, Next } from "koa"; + +export const errorHandler = async (ctx: Context, next: Next) => { + try { + await next(); + } catch (error: any) { + ctx.status = error.statusCode || error.status || StatusCodes.INTERNAL_SERVER_ERROR; + error.status = ctx.status; + ctx.body = { error }; + ctx.app.emit('error', error, ctx); + } +}; diff --git a/lib/middleware/performance.ts b/lib/middleware/performance.ts new file mode 100644 index 0000000..54d4c66 --- /dev/null +++ b/lib/middleware/performance.ts @@ -0,0 +1,15 @@ +import { Next } from 'koa'; +import { KoaContext } from '../types/KoaContext'; + +export const performanceLogger = async (ctx: KoaContext, next: Next) => { + await next(); + const rt = ctx.response.get('X-Response-Time'); + console.log(`${ctx.method} ${ctx.url} - ${rt}`); +}; + +export const perfromanceTimer = async (ctx: KoaContext, next: Next) => { + const start = Date.now(); + await next(); + const ms = Date.now() - start; + ctx.set('X-Response-Time', `${ms}ms`); +}; diff --git a/lib/model/auth.ts b/lib/model/auth.ts new file mode 100644 index 0000000..827d358 --- /dev/null +++ b/lib/model/auth.ts @@ -0,0 +1,4 @@ +import mongoose from 'mongoose'; +import { AuthModel, AuthPrivate, AuthSchema } from '../schema/auth'; + +export default mongoose.model('Auth', AuthSchema); diff --git a/lib/schema/auth.ts b/lib/schema/auth.ts new file mode 100644 index 0000000..cd603d4 --- /dev/null +++ b/lib/schema/auth.ts @@ -0,0 +1,134 @@ +import { JwtPayload } from 'jsonwebtoken'; +import { Document, InferSchemaType, Model, Schema, StringSchemaDefinition, Types } from 'mongoose'; + +import { Strategy } from './strategy'; +import { STRATEGIES } from '../constants/strategies'; +import { TokenProps, sign, verify as verifyJwt } from '../utils/jwt'; +import { encrypt, verify as verifyPassword } from '../utils/password'; +import { generateResetToken } from '../utils/tokens'; + +export type Auth = { + is2FA?: boolean; + record: StringSchemaDefinition; + username: string; +} + +export type AuthPrivate = Auth & { + strategies: Types.ArraySubdocument; +} + +export interface AuthMethods { + authenticate(password: string): boolean; + getAuthStrategy(method?: STRATEGIES): Strategy | false; + getResetLink(route: string): Promise; + getResetToken(): Promise; + getToken(props?: Omit): string; + setPassword(password: string): Promise; +} + +export interface AuthModel extends Model { + authenticate(password: any): boolean; + findByUsername(username: string): Promise; + isUsernameAvailable(username: string): Promise; + findUserForReset(strategy: STRATEGIES, token: string): Promise; + resetPassword(token: string, password: string): Promise; +} + +export const AuthSchema = new Schema( + { + is2FA: { type: Boolean, default: false }, + record: { type: Types.ObjectId }, + strategies: { type: Types.ArraySubdocument, required: true }, + username: { type: String, required: true, unique: true }, + }, + { + minimize: true, + timestamps: true, + }, +); + +AuthSchema.methods = { + authenticate: function(password: string) { + const strategy = this.getAuthStrategy(STRATEGIES.LOCAL); + return !!strategy && verifyPassword(password, strategy.key); + }, + + getAuthStrategy: function(method = STRATEGIES.LOCAL) { + return this.strategies.filter((strategy: Strategy) => strategy.method === method).pop() || false; + }, + + getToken: function(props = {}) { + return sign({ + sub: this._id, + ...props, + }); + }, + + getResetLink: async function (route) { + const resetToken = await this.getResetToken(); + if (resetToken) { + let resetRoute = route; + resetRoute = resetRoute.replace(':user_id', this._id); + resetRoute = resetRoute.replace(':reset_token?', resetToken); + const resetUrl = `${process.env.URL}${resetRoute}`; + console.log('[sendPasswordReset] resetUrl:', resetUrl); + return resetUrl; + } + }, + + getResetToken: async function () { + const { key, token } = generateResetToken(this._id); + this.resetCheckBit = key; + await this.save().catch(() => undefined); + return token; + }, + + setPassword: async function (password) { + const key = encrypt(password); + const hasLocalStrategy = !!this.getAuthStrategy(STRATEGIES.LOCAL); + const strategy = { + key, + method: STRATEGIES.LOCAL, + resetToken: undefined, + }; + + if (hasLocalStrategy) { + await this.model('User').findOneAndUpdate( + { _id: this._id, 'strategies.method': STRATEGIES.LOCAL }, + { $set: { 'strategies.$': strategy } }, + { upsert: true }, + ).catch(); + return true; + } + this.credentials.push(strategy); + await this.save().catch(() => false); + return true; + }, +}; + +AuthSchema.statics = { + // authenticateAndGetRecordLocator: async function (username, password) { + // const auth = await this.findByUserName(username); + // if (auth && auth.authenticate(password)) { + // return auth?.record; + // } + // return false; + // }, + + findByUsername: async function (username) { + return this.findOne({ username }); + }, + + isUsernameAvailable: async function (username) { + return !!!this.findByUsername(username); + }, + + resetPassword: async function (token, password) { + const decoded = verifyJwt(token); + const { sub, key } = decoded as JwtPayload; + const auth = await this.findOne({ _id: sub, 'strategies.resetToken': key }).catch(); + return !!auth && auth.setPassword(password); + }, +}; + +export type AuthSchema = InferSchemaType; diff --git a/lib/schema/strategy.ts b/lib/schema/strategy.ts new file mode 100644 index 0000000..11d450c --- /dev/null +++ b/lib/schema/strategy.ts @@ -0,0 +1,25 @@ +import { InferSchemaType, Schema, Types } from 'mongoose'; +import { STRATEGIES } from '../constants/strategies'; + +export const Strategy = new Schema( + { + method: { + type: Number, + enum: Object.values(STRATEGIES), + index: true, + required: true, + unique: true, + }, + externalId: { type: String, index: true }, + key: { type: String, required: true, trim: true }, + profile: {}, + resetToken: { type: String }, + forceReset: { type: Boolean }, + }, + { + minimize: true, + timestamps: true, + }, +); + +export type Strategy = InferSchemaType; diff --git a/lib/server.ts b/lib/server.ts new file mode 100644 index 0000000..f05dc4a --- /dev/null +++ b/lib/server.ts @@ -0,0 +1,12 @@ +import dotenv from 'dotenv'; +dotenv.config(); + +import app from './app'; +import { connection } from './database/database.connection'; + +const PORT: number = Number(process.env.PORT) || 9000; + +connection.then( + () => app.listen(PORT), + (err) => console.error('ERROR!', err), +); diff --git a/lib/strategies/local.ts b/lib/strategies/local.ts new file mode 100644 index 0000000..85236d6 --- /dev/null +++ b/lib/strategies/local.ts @@ -0,0 +1,20 @@ +import passport from 'koa-passport'; +import { Strategy } from 'passport-local'; +import bcrypt from 'bcrypt'; + +import Auth from '../model/auth'; +import { AuthSchema } from '../schema/auth'; + +export const LocalStrategy = passport.use(new Strategy(async (username, password, done) => { + const user = await Auth.findOne({ + where: { + username, + } + }).catch(); + if (user && user.authenticate(password)) { + done(null, user); + } else { + done(null, false); + } + } +)); diff --git a/lib/utils/auth.ts b/lib/utils/auth.ts new file mode 100644 index 0000000..d5893a4 --- /dev/null +++ b/lib/utils/auth.ts @@ -0,0 +1,14 @@ +import Auth from '../model/auth'; +import { AuthDocument, AuthModel, AuthPrivate } from '../schema/auth'; +import { sign } from './jwt'; + +export const getAuthenticationBundle = async (username: string, password: string) => { + const auth = await Auth.findByUsername(username).catch(); + const isAuthenticated = !!auth && (auth as AuthModel).authenticate(password); + const token = isAuthenticated ? (auth as AuthModel).getToken() : sign(); + const record = isAuthenticated ? (auth as AuthPrivate).record : null; + return { + record, + token, + }; +}; diff --git a/lib/utils/jwt.ts b/lib/utils/jwt.ts new file mode 100644 index 0000000..053a1c8 --- /dev/null +++ b/lib/utils/jwt.ts @@ -0,0 +1,31 @@ +import jwt, { JwtPayload } from 'jsonwebtoken'; + +export interface TokenProps { + aud?: string; + exp?: number | Date; + iss?: string; + sub: string | null; + [key: string]: any; +} + +export type SignProps = string | TokenProps | void; + +export const sign = (props: SignProps) => { + const today = new Date(); + const { sub = null, ...rest }: TokenProps = typeof props === 'string' || typeof props === 'undefined' ? { sub: props || null } : props; + let exp = rest.exp; + if (!exp) { + exp = new Date(today); + exp.setDate(today.getDate() + parseInt(process.env.JWT_DAYS_VALID as string)); + exp = exp.getTime() / 1000; + } + return jwt.sign({ + aud: rest.aud || process.env.JWT_AUDIENCE, + exp, + iat: today.getTime(), + iss: rest.iss || process.env.JWT_ISSUER, + sub, + }, process.env.JWT_SECRET || 'secret'); +}; + +export const verify = (token: string) => jwt.verify(token, process.env.JWT_SECRET || 'secret'); \ No newline at end of file diff --git a/lib/utils/password.ts b/lib/utils/password.ts new file mode 100644 index 0000000..2bb45c7 --- /dev/null +++ b/lib/utils/password.ts @@ -0,0 +1,12 @@ +import crypto from 'crypto'; + +export const encrypt = (password: string) => { + const salt = crypto.randomBytes(16).toString('hex'); + const hash = crypto.pbkdf2Sync(password, salt, 10000, 512, 'sha512').toString('hex'); + return `${salt}:${hash}`; +}; + +export const verify = (test: string, secret: string) => { + const [salt, hash] = secret.split(':'); + return crypto.pbkdf2Sync(test, salt, 10000, 512, 'sha512').toString('hex') === hash; +}; diff --git a/lib/utils/tokens.ts b/lib/utils/tokens.ts new file mode 100644 index 0000000..eb8aa90 --- /dev/null +++ b/lib/utils/tokens.ts @@ -0,0 +1,13 @@ +import crypto from 'crypto'; + +import { sign } from "./jwt"; + +export const generateResetToken = (sub: string) => { + const key = crypto.randomBytes(16).toString('hex'); + const token = sign({ + sub, + key, + exp: (Date.now() + (24 * 60 * 60 * 1000)), + }); + return { key, token }; +}; \ No newline at end of file