From 2d341e5a9a9d0569ef03a7e3c7d7e695af978455 Mon Sep 17 00:00:00 2001 From: mifi Date: Tue, 23 May 2023 14:15:34 -0400 Subject: [PATCH] Breaking down mega-package. Hello auth-db 1.0.0! --- lib/api/authenticate.ts | 17 +++++++ lib/api/deleteStrategy.ts | 15 ++++++ lib/api/resetPasswordGet.ts | 18 +++++++ lib/api/resetPasswordPost.ts | 38 +++++++++++++++ lib/constants/action.ts | 9 ++++ lib/constants/auth.ts | 8 +++ lib/constants/db.ts | 10 ++++ lib/constants/env.ts | 20 ++++++++ lib/constants/errors.ts | 12 +++++ lib/constants/strategies.ts | 7 +++ lib/constants/tokens.ts | 4 ++ lib/dao/create.ts | 37 ++++++++++++++ lib/dao/deleteById.ts | 15 ++++++ lib/dao/readAll.ts | 9 ++++ lib/dao/readOneById.ts | 5 ++ lib/dao/readOneByRecord.ts | 5 ++ lib/dao/readOneByUsername.ts | 3 ++ lib/index.ts | 17 +++++++ lib/model/auth.ts | 6 +++ lib/model/log.ts | 6 +++ lib/model/strategy.ts | 6 +++ lib/model/token.ts | 6 +++ lib/schema/auth.ts | 75 ++++++++++++++++++++++++++++ lib/schema/log.ts | 45 +++++++++++++++++ lib/schema/strategy.ts | 81 +++++++++++++++++++++++++++++++ lib/schema/token.ts | 65 +++++++++++++++++++++++++ lib/utils/getDefaultExpiresFor.ts | 15 ++++++ lib/utils/getLoginToken.ts | 11 +++++ lib/utils/jwt.ts | 35 +++++++++++++ lib/utils/links.ts | 5 ++ lib/utils/parseTimeoutToMs.ts | 13 +++++ lib/utils/password.ts | 12 +++++ lib/utils/tokens.ts | 11 +++++ 33 files changed, 641 insertions(+) create mode 100644 lib/api/authenticate.ts create mode 100644 lib/api/deleteStrategy.ts create mode 100644 lib/api/resetPasswordGet.ts create mode 100644 lib/api/resetPasswordPost.ts create mode 100644 lib/constants/action.ts create mode 100644 lib/constants/auth.ts create mode 100644 lib/constants/db.ts create mode 100644 lib/constants/env.ts create mode 100644 lib/constants/errors.ts create mode 100644 lib/constants/strategies.ts create mode 100644 lib/constants/tokens.ts create mode 100644 lib/dao/create.ts create mode 100644 lib/dao/deleteById.ts create mode 100644 lib/dao/readAll.ts create mode 100644 lib/dao/readOneById.ts create mode 100644 lib/dao/readOneByRecord.ts create mode 100644 lib/dao/readOneByUsername.ts create mode 100644 lib/index.ts create mode 100644 lib/model/auth.ts create mode 100644 lib/model/log.ts create mode 100644 lib/model/strategy.ts create mode 100644 lib/model/token.ts create mode 100644 lib/schema/auth.ts create mode 100644 lib/schema/log.ts create mode 100644 lib/schema/strategy.ts create mode 100644 lib/schema/token.ts create mode 100644 lib/utils/getDefaultExpiresFor.ts create mode 100644 lib/utils/getLoginToken.ts create mode 100644 lib/utils/jwt.ts create mode 100644 lib/utils/links.ts create mode 100644 lib/utils/parseTimeoutToMs.ts create mode 100644 lib/utils/password.ts create mode 100644 lib/utils/tokens.ts diff --git a/lib/api/authenticate.ts b/lib/api/authenticate.ts new file mode 100644 index 0000000..a630ac2 --- /dev/null +++ b/lib/api/authenticate.ts @@ -0,0 +1,17 @@ +import { Auth, Log } from '..'; +import { Action } from '../../constants/action'; +import { getLoginToken } from '../utils/getLoginToken'; + +export const authenticate = async (username: string, password: string) => { + const doc = await Auth.findByUsername(username).catch(); + if (!!doc && (await doc.authenticate(password))) { + Log.add(doc.id, Action.AUTHENTICATE); + return { ...doc, token: getLoginToken(doc) }; + } + + if (doc) { + Log.add(doc.id, Action.AUTHENTICATE_FAILURE); + } + + return false; +}; diff --git a/lib/api/deleteStrategy.ts b/lib/api/deleteStrategy.ts new file mode 100644 index 0000000..676d63d --- /dev/null +++ b/lib/api/deleteStrategy.ts @@ -0,0 +1,15 @@ +import { StringSchemaDefinition } from 'mongoose'; +import { Auth, Strategy } from '..'; + +export const deleteStrategy = async (id: StringSchemaDefinition) => { + const strategy = await Strategy.findById(id); + + if (strategy) { + const parentId = strategy.parent; + await strategy.deleteOne(); + await Auth.findOneAndUpdate({ id: parentId, strategies: { $pull: id } }); + return true; + } + + return false; +}; diff --git a/lib/api/resetPasswordGet.ts b/lib/api/resetPasswordGet.ts new file mode 100644 index 0000000..e8025a5 --- /dev/null +++ b/lib/api/resetPasswordGet.ts @@ -0,0 +1,18 @@ +import { readOneByUsername } from '../dao/readOneByUsername'; +import { Log, Token } from '..'; +import { TokenType } from '../../constants/tokens'; +import { Action } from '../../constants/action'; + +export const resetPasswordGet = async (username: string) => { + const doc = await readOneByUsername(username); + + if (doc) { + Log.add(doc._id, Action.RESET_REQUEST); + return { + record: doc.record, + token: Token.getToken(TokenType.RESET, doc._id), + }; + } + + return false; +}; diff --git a/lib/api/resetPasswordPost.ts b/lib/api/resetPasswordPost.ts new file mode 100644 index 0000000..c89e79f --- /dev/null +++ b/lib/api/resetPasswordPost.ts @@ -0,0 +1,38 @@ +import { Types } from 'mongoose'; + +import { Log, Strategy, Token } from '..'; +import { STRATEGIES } from '../../constants/strategies'; +import { AuthDocument } from '../schema/auth'; +import { getLoginToken } from '../utils/getLoginToken'; +import { StrategyDocument } from '../schema/strategy'; +import { Action } from '../../constants/action'; + +export const resetPasswordPost = async (token: string, password: string) => { + const parentId = await Token.validateResetToken(token); + + if (parentId) { + let parent: AuthDocument; + let strategy: StrategyDocument | null = await Strategy.findOne({ parent: parentId, method: STRATEGIES.LOCAL }); + + if (strategy) { + parent = await strategy.getAuthRecord(); + strategy.key = password; + await strategy.save(); + } else { + strategy = await Strategy.create({ + key: password, + method: STRATEGIES.LOCAL, + parent: parentId, + }); + + parent = await strategy.getAuthRecord(); + parent.strategies.push(strategy._id); + await parent.save(); + } + + Log.add(parent._id, Action.RESET); + return { record: parent.record, token: getLoginToken(parent) }; + } + + return false; +}; diff --git a/lib/constants/action.ts b/lib/constants/action.ts new file mode 100644 index 0000000..a10460e --- /dev/null +++ b/lib/constants/action.ts @@ -0,0 +1,9 @@ +export enum Action { + AUTHENTICATE = 'AUTHENTICATE', + AUTHENTICATE_FAILURE = 'AUTHENTICATE_FAILURE', + CREATE = 'CREATE', + DELETE = 'DELETE', + RESET = 'RESET', + RESET_REQUEST = 'RESET_REQUEST', + UPDATE = 'UPDATE', +} diff --git a/lib/constants/auth.ts b/lib/constants/auth.ts new file mode 100644 index 0000000..9e2094c --- /dev/null +++ b/lib/constants/auth.ts @@ -0,0 +1,8 @@ +export enum Status { + ACTIVE, + BLOCK_HARD, + BLOCK_SOFT, + DELETED, + INACTIVE, + UNVERIFIED, +} diff --git a/lib/constants/db.ts b/lib/constants/db.ts new file mode 100644 index 0000000..ae52656 --- /dev/null +++ b/lib/constants/db.ts @@ -0,0 +1,10 @@ +export const DB_HOST = process.env.DB_HOST; +export const DB_PORT = process.env.DB_PORT || 27017; +export const DB_USERNAME = process.env.DB_USERNAME; +export const DB_PASSWORD = process.env.DB_PASSWORD; +export const DB_NAME = process.env.DB_NAME; + +export const COLL_AUTH = 'Auth'; +export const COLL_LOG = 'Log'; +export const COLL_STRATEGY = 'Strategy'; +export const COLL_TOKEN = 'Token'; diff --git a/lib/constants/env.ts b/lib/constants/env.ts new file mode 100644 index 0000000..929d33c --- /dev/null +++ b/lib/constants/env.ts @@ -0,0 +1,20 @@ +export const PACKAGE_NAME = '@mifi/auth'; +export const PORT = process.env.PORT || 9000; + +export const SESSION_KEY = process.env.SESSION_KEY || 'secret-key'; + +export const JWT_AUDIENCE = process.env.JWT_AUDIENCE || 'mifi.dev'; +export const JWT_ISSUER = process.env.JWT_ISSUER || PACKAGE_NAME; +export const JWT_SECRET = process.env.JWT_SECRET || 'secret'; + +export const LOGIN_VALID_TIMEOUT = process.env.LOGIN_VALID_TIMEOUT || '12h'; // ###d|h|m +export const RESET_VALID_TIMEOUT = process.env.RESET_VALID_TIMEOUT || '15m'; // ###d|h|m +export const VERIFY_VALID_TIMEOUT = process.env.VERIFY_VALID_TIMEOUT || '60d'; // ###d|h|m +export const DEFAULT_TOKEN_DAYS = process.env.DEFAULT_TOKEN_DAYS || 365; + +export const ROUTE_PREFIX = process.env.ROUTE_PREFIX || '/auth'; +export const LOGIN_ROUTE = process.env.LOGIN_ROUTE || '/login'; +export const RESET_ROUTE = process.env.RESET_ROUTE || '/reset'; +export const VERIFICATION_ROUTE = process.env.VERIFICATION_ROUTE || '/verification'; + +export const REQUIRE_VERIFICATION = process.env.REQUIRE_VERIFICATION || true; diff --git a/lib/constants/errors.ts b/lib/constants/errors.ts new file mode 100644 index 0000000..8761979 --- /dev/null +++ b/lib/constants/errors.ts @@ -0,0 +1,12 @@ +export enum ErrorCodes { + RESET_REQUEST_DATA = 'RESET_REQUEST_DATA', +} + +export const ErrorMessages = { + [ErrorCodes.RESET_REQUEST_DATA]: 'A valid username and password must be provided', +}; + +export const getErrorBody = (code: ErrorCodes) => ({ + code, + message: ErrorMessages[code], +}); 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/constants/tokens.ts b/lib/constants/tokens.ts new file mode 100644 index 0000000..b9dbd84 --- /dev/null +++ b/lib/constants/tokens.ts @@ -0,0 +1,4 @@ +export enum TokenType { + RESET = 'RESET', + VERIFICATION = 'VERIFICATION', +} diff --git a/lib/dao/create.ts b/lib/dao/create.ts new file mode 100644 index 0000000..f27b26c --- /dev/null +++ b/lib/dao/create.ts @@ -0,0 +1,37 @@ +import { DatabaseError } from '@mifi/services-common/domain/errors/DatabaseError'; + +import { Auth, Log, Strategy, Token } from '..'; +import { Auth as AuthProps } from '../schema/auth'; +import { STRATEGIES } from '../constants/strategies'; +import { REQUIRE_VERIFICATION } from '../constants/env'; +import { TokenType } from '../constants/tokens'; +import { Status } from '../constants/auth'; +import { Action } from '../constants/action'; + +export const create = async ({ record, username, password }: AuthProps & { password: string }) => { + const status = REQUIRE_VERIFICATION ? Status.UNVERIFIED : Status.ACTIVE; + const doc = await Auth.create({ + record, + status, + username, + }).catch((err) => { + throw new DatabaseError('failed to create user', { err }); + }); + if (doc) { + const strategy = await Strategy.create({ method: STRATEGIES.LOCAL, key: password, parent: doc._id }).catch( + (err) => { + throw new DatabaseError('failed to create strategy', { err }); + }, + ); + if (strategy) { + doc.strategies.push(strategy._id); + await doc.save(); + Log.add(doc._id, Action.CREATE); + return { doc, token: REQUIRE_VERIFICATION && (await Token.getToken(TokenType.VERIFICATION, doc._id)) }; + } + await doc.deleteOne((err) => { + throw new DatabaseError('failed to remove invalid auth record', { err, doc }); + }); + } + return null; +}; diff --git a/lib/dao/deleteById.ts b/lib/dao/deleteById.ts new file mode 100644 index 0000000..c141cab --- /dev/null +++ b/lib/dao/deleteById.ts @@ -0,0 +1,15 @@ +import { StringSchemaDefinition } from 'mongoose'; + +import { Auth, Log, Strategy, Token } from '..'; +import { Status } from '../constants/auth'; +import { Action } from '../constants/action'; + +export const deleteById = async (id: StringSchemaDefinition) => { + if (await Auth.findByIdAndUpdate(id, { status: Status.DELETED, strategies: [] }).catch()) { + await Strategy.deleteMany({ parent: id }); + await Token.deleteMany({ auth: id }); + Log.add(id, Action.DELETE); + return true; + } + return false; +}; diff --git a/lib/dao/readAll.ts b/lib/dao/readAll.ts new file mode 100644 index 0000000..316197a --- /dev/null +++ b/lib/dao/readAll.ts @@ -0,0 +1,9 @@ +import { FilterQuery } from 'mongoose'; + +import { Auth } from '..'; +import { Status } from '../constants/auth'; +import { AuthDocument } from '../schema/auth'; + +export const readAll = async (query: FilterQuery = {}) => Auth.find(query); + +export const readAllActive = async () => readAll({ status: { $ne: Status.DELETED } }); diff --git a/lib/dao/readOneById.ts b/lib/dao/readOneById.ts new file mode 100644 index 0000000..24f53f2 --- /dev/null +++ b/lib/dao/readOneById.ts @@ -0,0 +1,5 @@ +import { Types } from 'mongoose'; + +import { Auth } from '..'; + +export const readOneById = async (id: Types.ObjectId) => Auth.findById(id); diff --git a/lib/dao/readOneByRecord.ts b/lib/dao/readOneByRecord.ts new file mode 100644 index 0000000..47fd086 --- /dev/null +++ b/lib/dao/readOneByRecord.ts @@ -0,0 +1,5 @@ +import { Types } from 'mongoose'; + +import { Auth } from '..'; + +export const readOneByRecord = async (record: Types.ObjectId) => Auth.findOne({ record }); diff --git a/lib/dao/readOneByUsername.ts b/lib/dao/readOneByUsername.ts new file mode 100644 index 0000000..562dd15 --- /dev/null +++ b/lib/dao/readOneByUsername.ts @@ -0,0 +1,3 @@ +import { Auth } from '..'; + +export const readOneByUsername = async (username: string) => Auth.findOne({ username }); diff --git a/lib/index.ts b/lib/index.ts new file mode 100644 index 0000000..19da8a1 --- /dev/null +++ b/lib/index.ts @@ -0,0 +1,17 @@ +import mongoose from 'mongoose'; + +import { DB_HOST, DB_NAME, DB_PASSWORD, DB_PORT, DB_USERNAME } from '../constants/db'; +import { Auth } from './model/auth'; +import { Log } from './model/log'; +import { Strategy } from './model/strategy'; +import { Token } from './model/token'; + +const connection = mongoose + .connect(`mongodb://${DB_USERNAME}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}`) + .then((c) => console.debug('Database connection established', { connection: c })) + .catch((error) => { + console.error('Mongo connection failure', error); + process.exit(1); + }); + +export { connection, Auth, Log, Strategy, Token }; diff --git a/lib/model/auth.ts b/lib/model/auth.ts new file mode 100644 index 0000000..5658dcb --- /dev/null +++ b/lib/model/auth.ts @@ -0,0 +1,6 @@ +import mongoose from 'mongoose'; + +import { AuthDocument, AuthModel, AuthSchema } from '../schema/auth'; +import { COLL_AUTH } from '../constants/db'; + +export const Auth = mongoose.model(COLL_AUTH, AuthSchema); diff --git a/lib/model/log.ts b/lib/model/log.ts new file mode 100644 index 0000000..4aacf04 --- /dev/null +++ b/lib/model/log.ts @@ -0,0 +1,6 @@ +import mongoose from 'mongoose'; + +import { LogModel, Log as LogDocument, LogSchema } from '../schema/log'; +import { COLL_LOG } from '../constants/db'; + +export const Log = mongoose.model(COLL_LOG, LogSchema); diff --git a/lib/model/strategy.ts b/lib/model/strategy.ts new file mode 100644 index 0000000..6a5f022 --- /dev/null +++ b/lib/model/strategy.ts @@ -0,0 +1,6 @@ +import mongoose from 'mongoose'; + +import { StrategyDocument, StrategyModel, StrategySchema } from '../schema/strategy'; +import { COLL_STRATEGY } from '../constants/db'; + +export const Strategy = mongoose.model(COLL_STRATEGY, StrategySchema); diff --git a/lib/model/token.ts b/lib/model/token.ts new file mode 100644 index 0000000..796bf33 --- /dev/null +++ b/lib/model/token.ts @@ -0,0 +1,6 @@ +import mongoose from 'mongoose'; + +import { TokenModel, Token as TokenDocument, TokenSchema } from '../schema/token'; +import { COLL_TOKEN } from '../constants/db'; + +export const Token = mongoose.model(COLL_TOKEN, TokenSchema); diff --git a/lib/schema/auth.ts b/lib/schema/auth.ts new file mode 100644 index 0000000..0d54871 --- /dev/null +++ b/lib/schema/auth.ts @@ -0,0 +1,75 @@ +import { Document, InferSchemaType, Model, Schema, StringSchemaDefinition, Types } from 'mongoose'; + +import { Status } from '../constants/auth'; +import { COLL_STRATEGY } from '../constants/db'; +import { STRATEGIES } from '../constants/strategies'; +import { StrategyDocument } from './strategy'; +import { verify } from '../utils/password'; + +export interface Auth { + is2FA?: boolean; + record: StringSchemaDefinition; + username: string; + status: Status; + strategies: Types.ObjectId[] | StrategyDocument[]; +} + +interface AuthBaseDocument extends Auth, Document { + authenticate(password: string): Promise; + getStrategy(method?: STRATEGIES): Promise; +} + +export interface AuthDocument extends AuthBaseDocument { + strategies: Types.ObjectId[]; +} + +export interface AuthPopulatedDocument extends AuthBaseDocument { + strategies: StrategyDocument[]; +} + +export interface AuthModel extends Model { + findByUsername(username: string): Promise; + getLocalStrategyForUsername(username: string): Promise; + isUsernameAvailable(username: string): Promise; +} + +export const AuthSchema = new Schema( + { + is2FA: { type: Boolean, default: false }, + record: { type: Types.ObjectId, unique: true }, + status: { type: Number, enum: Status, default: Status.UNVERIFIED, index: true }, + strategies: [{ type: Types.ObjectId, ref: COLL_STRATEGY, default: [] }], + username: { type: String, required: true, unique: true }, + }, + { + minimize: true, + timestamps: true, + }, +); + +AuthSchema.methods.authenticate = async function (this: AuthBaseDocument, password: string) { + const strategy = await this.getStrategy(); + return !!strategy && verify(password, strategy.key); +}; + +AuthSchema.methods.getStrategy = async function (this: AuthBaseDocument, method = STRATEGIES.LOCAL) { + const doc = await this.populate<{ strategies: StrategyDocument[] }>('strategies'); + return doc.strategies.filter((strategy) => strategy.method === method).pop() || null; +}; + +AuthSchema.statics = { + async findByUsername(username) { + return this.findOne({ username }); + }, + + async getLocalStrategyForUsername(username) { + const doc = await this.findByUsername(username); + return !!doc && doc.getStrategy(); + }, + + async isUsernameAvailable(username) { + return !this.findByUsername(username); + }, +}; + +export type AuthSchema = InferSchemaType; diff --git a/lib/schema/log.ts b/lib/schema/log.ts new file mode 100644 index 0000000..731da9a --- /dev/null +++ b/lib/schema/log.ts @@ -0,0 +1,45 @@ +import { InferSchemaType, Model, Schema, StringSchemaDefinition, Types } from 'mongoose'; + +import { Payload } from '@mifi/services-common/types/Payload'; + +import { Action } from '../constants/action'; + +export interface Log { + action: Action; + auth: StringSchemaDefinition; + payload?: Payload; +} + +export interface LogModel extends Model { + add(id: StringSchemaDefinition, action: Action, payload?: Payload): void; + historyForUser(id: StringSchemaDefinition, action?: Action): Array; + loginsForUser(id: StringSchemaDefinition): Array; +} + +export const LogSchema = new Schema( + { + action: { type: String, enum: Action, required: true }, + auth: { type: Types.ObjectId, index: true, required: true }, + payload: { type: Object }, + }, + { + minimize: true, + timestamps: true, + }, +); + +LogSchema.statics = { + add(id, action, payload) { + this.create({ action, auth: id, payload }).catch(); + }, + + async historyForUser(id, action) { + return this.find({ auth: id, action }); + }, + + async loginsForUser(id) { + return this.find({ auth: id, action: Action.AUTHENTICATE }); + }, +}; + +export type LogSchema = InferSchemaType; diff --git a/lib/schema/strategy.ts b/lib/schema/strategy.ts new file mode 100644 index 0000000..d2d32ba --- /dev/null +++ b/lib/schema/strategy.ts @@ -0,0 +1,81 @@ +import { Document, InferSchemaType, Model, Schema, StringSchemaDefinition, Types } from 'mongoose'; + +import { STRATEGIES } from '../constants/strategies'; +import { encrypt } from '../utils/password'; +import { COLL_AUTH } from '../constants/db'; +import { AuthDocument } from './auth'; +import { Strategy } from '..'; + +export interface Strategy { + method: STRATEGIES; + parent: StringSchemaDefinition | AuthDocument; + externalId?: string; + key: string; + profile?: { [key: string]: string | boolean | number }; + forceReset?: boolean; +} + +interface StrategyBaseDocument extends Strategy, Document { + getAuthRecord(): Promise; + getPopulatedStrategy(): Promise; +} + +export interface StrategyDocument extends StrategyBaseDocument { + parent: StringSchemaDefinition; +} + +export interface StrategyPopulatedDocument extends StrategyBaseDocument { + parent: AuthDocument; +} + +export type StrategyModel = Model; + +export const StrategySchema = new Schema( + { + method: { + type: Number, + enum: STRATEGIES, + index: true, + }, + externalId: { type: String, index: true }, + forceReset: { type: Boolean }, + key: { type: String, required: true, trim: true }, + parent: { + type: Types.ObjectId, + ref: COLL_AUTH, + required: true, + }, + profile: {}, + }, + { + minimize: true, + timestamps: true, + }, +); + +StrategySchema.methods.getPopulatedStrategy = async function (this: StrategyModel) { + return this.populate('parent'); +}; + +StrategySchema.methods.getAuthRecord = async function (this: StrategyModel) { + return (await this.getPopulatedStrategy()).parent; +}; + +StrategySchema.pre('save', async function save(next) { + if (typeof this.method === 'undefined') { + return next(new Error(`Strategy requires a method.`)); + } + + if (await Strategy.findOne({ method: this.method, parent: this.parent })) { + return next(new Error(`${this.method} strategy already exists for this user.`)); + } + + if (this.method !== STRATEGIES.LOCAL || !this.isModified('key')) { + return next(); + } + + this.key = encrypt(this.key); + return next(); +}); + +export type StrategySchema = InferSchemaType; diff --git a/lib/schema/token.ts b/lib/schema/token.ts new file mode 100644 index 0000000..aa4fd13 --- /dev/null +++ b/lib/schema/token.ts @@ -0,0 +1,65 @@ +import { InferSchemaType, Model, Schema, StringSchemaDefinition, Types } from 'mongoose'; + +import { TokenType } from '../constants/tokens'; +import { getDefaultExpiresFor } from '../utils/getDefaultExpiresFor'; +import { sign, verify } from '../utils/jwt'; + +export interface Token { + auth: StringSchemaDefinition; + expires?: number; + type: TokenType; +} + +export interface TokenModel extends Model { + cleanupExpiredTokens(): { success: boolean; deletedCount: number }; + getToken(type: TokenType, auth: Types.ObjectId, expires?: number): string; + validateResetToken(token: string): Types.ObjectId | false; +} + +export const TokenSchema = new Schema( + { + auth: { type: Types.ObjectId, index: true }, + expires: { type: Number, required: true }, + type: { type: String, enum: TokenType, required: true }, + }, + { + minimize: true, + timestamps: true, + }, +); + +TokenSchema.statics = { + async cleanupExpiredTokens() { + const { acknowledged, deletedCount } = await this.deleteMany({ expires: { $lte: Date.now() } }); + return { success: acknowledged, deletedCount }; + }, + + async getToken(type: TokenType, auth: StringSchemaDefinition, expires?: number) { + const existing = await this.findOne({ type, auth }); + if (existing) { + await existing.deleteOne(); + } + + const doc = await this.create({ type, auth, expires: expires || getDefaultExpiresFor(type) }); + return sign({ + sub: `${doc._id}`, + exp: doc.expires, + }); + }, + + async validateResetToken(token: string) { + const { sub } = verify(token); + + if (sub) { + const record = await this.findById(sub); + if (record) { + await record.deleteOne(); + return !!record?.expires && record.expires >= Date.now() && record.auth; + } + } + + return false; + }, +}; + +export type TokenSchema = InferSchemaType; diff --git a/lib/utils/getDefaultExpiresFor.ts b/lib/utils/getDefaultExpiresFor.ts new file mode 100644 index 0000000..c9d86d8 --- /dev/null +++ b/lib/utils/getDefaultExpiresFor.ts @@ -0,0 +1,15 @@ +import { LOGIN_VALID_TIMEOUT, RESET_VALID_TIMEOUT, VERIFY_VALID_TIMEOUT } from '../constants/env'; +import { TokenType } from '../constants/tokens'; +import { parseTimeoutToMs } from '../utils/parseTimeoutToMs'; + +export const getDefaultExpiresFor = (type: TokenType | void) => { + if (type === TokenType.RESET) { + return Date.now() + parseTimeoutToMs(RESET_VALID_TIMEOUT); + } + + if (type === TokenType.VERIFICATION) { + return Date.now() + parseTimeoutToMs(VERIFY_VALID_TIMEOUT); + } + + return Date.now() + parseTimeoutToMs(LOGIN_VALID_TIMEOUT); +}; diff --git a/lib/utils/getLoginToken.ts b/lib/utils/getLoginToken.ts new file mode 100644 index 0000000..4f98d35 --- /dev/null +++ b/lib/utils/getLoginToken.ts @@ -0,0 +1,11 @@ +import { sign } from '../utils/jwt'; +import { LOGIN_VALID_TIMEOUT } from '../constants/env'; +import { parseTimeoutToMs } from '../utils/parseTimeoutToMs'; +import { AuthDocument } from '../schema/auth'; + +export const getLoginToken = ({ record: sub, status }: AuthDocument) => + sign({ + sub: sub, + status, + exp: Date.now() + parseTimeoutToMs(LOGIN_VALID_TIMEOUT), + }); diff --git a/lib/utils/jwt.ts b/lib/utils/jwt.ts new file mode 100644 index 0000000..b6a9a51 --- /dev/null +++ b/lib/utils/jwt.ts @@ -0,0 +1,35 @@ +import jwt from 'jsonwebtoken'; +import { JWT_AUDIENCE, JWT_ISSUER, JWT_SECRET } from '../constants/env'; +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; + 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( + { + exp, + sub, + aud: rest.aud || JWT_AUDIENCE, + iat: today.getTime(), + iss: rest.iss || JWT_ISSUER, + }, + JWT_SECRET, + ); +}; + +export const verify = (token: string) => jwt.verify(token, JWT_SECRET); diff --git a/lib/utils/links.ts b/lib/utils/links.ts new file mode 100644 index 0000000..306ce13 --- /dev/null +++ b/lib/utils/links.ts @@ -0,0 +1,5 @@ +import { RESET_ROUTE, ROUTE_PREFIX, VERIFICATION_ROUTE } from '../constants/env'; + +export const getPasswordResetPath = (token: string) => `${ROUTE_PREFIX}${RESET_ROUTE}?t=${token}`; + +export const getVerificationPath = (token: string) => `${ROUTE_PREFIX}${VERIFICATION_ROUTE}?t=${token}`; diff --git a/lib/utils/parseTimeoutToMs.ts b/lib/utils/parseTimeoutToMs.ts new file mode 100644 index 0000000..f966836 --- /dev/null +++ b/lib/utils/parseTimeoutToMs.ts @@ -0,0 +1,13 @@ +export const parseTimeoutToMs = (timeout: string) => { + const match = timeout.match(/(?\d+)(?d|h|m)/gi)?.groups || {}; + const { number, unit } = match; + switch (unit) { + case 'd': + return 1000 * 60 * 60 * 24 * parseInt(number); + case 'h': + return 1000 * 60 * 60 * parseInt(number); + case 'm': + default: + return 1000 * 60 * parseInt(number) || 1; + } +}; diff --git a/lib/utils/password.ts b/lib/utils/password.ts new file mode 100644 index 0000000..922f138 --- /dev/null +++ b/lib/utils/password.ts @@ -0,0 +1,12 @@ +import { pbkdf2Sync, randomBytes } from 'crypto'; + +export const encrypt = (password: string) => { + const salt = randomBytes(16).toString('hex'); + const hash = pbkdf2Sync(password, salt, 10000, 512, 'sha512').toString('hex'); + return `${salt}:${hash}`; +}; + +export const verify = (test: string, secret: string) => { + const [salt, hash] = secret.split(':'); + return 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..e9edda5 --- /dev/null +++ b/lib/utils/tokens.ts @@ -0,0 +1,11 @@ +import { sign } from './jwt'; +import { LOGIN_VALID_TIMEOUT } from '../constants/env'; +import { Status } from '../constants/auth'; +import { parseTimeoutToMs } from './parseTimeoutToMs'; + +export const generateLoginToken = (sub: string, status: Status) => + sign({ + sub, + status, + exp: Date.now() + parseTimeoutToMs(LOGIN_VALID_TIMEOUT), + });