From 929ebd96d645fc579d209328df40822528e5fc2d Mon Sep 17 00:00:00 2001 From: mifi Date: Tue, 23 May 2023 14:28:43 -0400 Subject: [PATCH] Package breakdown - initial commit 1.0.0 --- .gitignore | 132 ++++++++++++ Dockerfile | 28 +++ README.md | 2 + babel.config.js | 6 + docker-compose.dev.yml | 49 +++++ docker-compose.staging-build.yml | 58 ++++++ docker-compose.staging-image.yml | 43 ++++ docker-entrypoint-initdb.d/mongo-init-4.4.sh | 14 ++ .../mongo-init-6.0.5.sh | 14 ++ jest.config.ts | 195 ++++++++++++++++++ lib/app.ts | 32 +++ 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/controllers/auth.ts | 80 +++++++ lib/index.ts | 11 + lib/middleware/authenication.ts | 13 ++ lib/middleware/errorHandler.ts | 13 ++ lib/middleware/performance.ts | 14 ++ lib/passport/index.ts | 23 +++ lib/passport/strategies/jwt.ts | 17 ++ lib/passport/strategies/local.ts | 9 + 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 + package.json | 84 ++++++++ tsconfig.json | 12 ++ 33 files changed, 995 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 babel.config.js create mode 100644 docker-compose.dev.yml create mode 100644 docker-compose.staging-build.yml create mode 100644 docker-compose.staging-image.yml create mode 100644 docker-entrypoint-initdb.d/mongo-init-4.4.sh create mode 100644 docker-entrypoint-initdb.d/mongo-init-6.0.5.sh create mode 100644 jest.config.ts create mode 100644 lib/app.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/controllers/auth.ts create mode 100644 lib/index.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/passport/index.ts create mode 100644 lib/passport/strategies/jwt.ts create mode 100644 lib/passport/strategies/local.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 create mode 100644 package.json create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ceaea36 --- /dev/null +++ b/.gitignore @@ -0,0 +1,132 @@ +# ---> Node +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..279007e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +ARG ENV=production +ARG MONGO_VERSION=latest +ARG PORT=9001 + +## mongo build stage +FROM mongo:$MONGO_VERSION AS database +COPY docker-entrypoint-initdb.d/mongo-init-$MONGO_VERSION.sh ./docker-entrypoint-initdb.d/mongo-init.sh + +## stage one, build the service +FROM node:20-alpine AS build +ENV NODE_ENV development +WORKDIR /home/node/app +COPY package*.json ./ +COPY tsconfig.json ./ +COPY lib ./lib +RUN ls -a +RUN yarn install +RUN yarn build + +## this is stage two , where the app actually runs +FROM node:20-alpine AS containerize +ENV NODE_ENV $ENV +WORKDIR /home/node/app +COPY package*.json ./ +RUN yarn install --frozen-lockfile --production +COPY --from=build /home/node/app/dist . +EXPOSE $PORT +CMD ["node","server/index.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..1a251b3 --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# @mifi/auth + diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 0000000..6432e2b --- /dev/null +++ b/babel.config.js @@ -0,0 +1,6 @@ +module.exports = { + presets: [ + ['@babel/preset-env', { targets: { node: 'current' } }], + '@babel/preset-typescript', + ], +}; \ No newline at end of file diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..97429c8 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,49 @@ +version: '3.8' + +services: + auth-service_mongo: + env_file: .env.dev + container_name: ${CONTAINER_PREFIX}-auth-service_mongo + build: + context: . + target: database + args: + MONGO_VERSION: 6.0.5 + ports: + - 27017:27017 + networks: + - backend + volumes: + - auth-db:/data/db + - auth-db:/data/configdb + restart: unless-stopped + image: mongo:latest + auth-service: + env_file: .env.dev + build: + context: . + target: containerize + args: + - PORT + - ENV + container_name: ${CONTAINER_PREFIX}-auth-service + ports: + - 9001:9001 + environment: + - DB_HOST=${CONTAINER_PREFIX}-auth-service_mongo + networks: + - labs-net + - backend + restart: unless-stopped + image: node:20-alpine + depends_on: + - auth-service_mongo +networks: + backend: + name: backend + labs-net: + name: labs-net + +volumes: + auth-db: + external: false diff --git a/docker-compose.staging-build.yml b/docker-compose.staging-build.yml new file mode 100644 index 0000000..a6620f1 --- /dev/null +++ b/docker-compose.staging-build.yml @@ -0,0 +1,58 @@ +version: '3.8' + +services: + auth-service_mongo: + container_name: ${CONTAINER_PREFIX}-auth-service_mongo + env_file: + - staging.env + build: + context: . + target: database + args: + MONGO_VERSION: 4.4 + networks: + - auth-backend + volumes: + - 'auth-db:/data/db' + - 'auth-db:/data/configdb' + restart: unless-stopped + image: mongo:4.4 + auth-service: + container_name: ${CONTAINER_PREFIX}-auth-service + env_file: + - staging.env + build: + context: . + target: containerize + args: + - PORT + - ENV + environment: + - DB_HOST=${CONTAINER_PREFIX}-auth-service_mongo + labels: + - 'traefik.enable=true' + - 'traefik.docker.network=docknet' + - 'traefik.http.routers.labs-auth.rule=Host(`${HOST}`) && PathPrefix(`${ROUTE_PREFIX}`)' + - 'traefik.http.routers.labs-auth.entrypoints=websecure' + - 'traefik.http.routers.labs-auth.tls=true' + - 'traefik.http.routers.labs-auth.tls.certresolver=letsencrypt' + - 'traefik.http.routers.labs-auth.service=labs-auth-service' + - 'traefik.http.services.labs-auth-service.loadbalancer.server.port=${PORT}' + networks: + - auth-backend + - docknet + restart: unless-stopped + image: node:20-alpine + depends_on: + - auth-service_mongo +networks: + auth-backend: + driver: bridge + external: false + docknet: + name: docknet + external: true + +volumes: + auth-db: + external: false diff --git a/docker-compose.staging-image.yml b/docker-compose.staging-image.yml new file mode 100644 index 0000000..e464bef --- /dev/null +++ b/docker-compose.staging-image.yml @@ -0,0 +1,43 @@ +version: '3.8' + +services: + auth-service_mongo: + container_name: ${CONTAINER_PREFIX}-auth-service_mongo + env_file: + - staging.env + networks: + - docknet + volumes: + - auth-db:/data + - ./mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js:ro + restart: unless-stopped + image: mongo:4.4 + auth-service: + env_file: + - staging.env + container_name: ${CONTAINER_PREFIX}-auth-service + environment: + - DB_HOST=${CONTAINER_PREFIX}-auth-service_mongo + labels: + - 'traefik.enable=true' + - 'traefik.http.routers.grow.rule=Host(`${HOST}`) && Path(`${ROUTE_PREFIX}`)' + - 'traefik.http.routers.grow.entrypoints=websecure' + - 'traefik.http.routers.grow.tls=true' + - 'traefik.http.routers.grow.tls.certresolver=letsencrypt' + - 'traefik.http.routers.grow.service=grow-service' + - 'traefik.http.services.grow-service.loadbalancer.server.port=${PORT}' + networks: + - docknet + restart: unless-stopped + depends_on: + - auth-service_mongo + image: git.mifi.dev/mifi/mifi/auth:latest + +networks: + docknet: + name: docknet + external: true + +volumes: + auth-db: + external: false diff --git a/docker-entrypoint-initdb.d/mongo-init-4.4.sh b/docker-entrypoint-initdb.d/mongo-init-4.4.sh new file mode 100644 index 0000000..a3ddfd9 --- /dev/null +++ b/docker-entrypoint-initdb.d/mongo-init-4.4.sh @@ -0,0 +1,14 @@ +set -e + +mongo <" + // ], + + // Allows you to use a custom runner instead of Jest's default test runner + // runner: "jest-runner", + + // The paths to modules that run some code to configure or set up the testing environment before each test + // setupFiles: [], + + // A list of paths to modules that run some code to configure or set up the testing framework before each test + // setupFilesAfterEnv: [], + + // The number of seconds after which a test is considered as slow and reported as such in the results. + // slowTestThreshold: 5, + + // A list of paths to snapshot serializer modules Jest should use for snapshot testing + // snapshotSerializers: [], + + // The test environment that will be used for testing + // testEnvironment: "jest-environment-node", + + // Options that will be passed to the testEnvironment + // testEnvironmentOptions: {}, + + // Adds a location field to test results + // testLocationInResults: false, + + // The glob patterns Jest uses to detect test files + // testMatch: [ + // "**/__tests__/**/*.[jt]s?(x)", + // "**/?(*.)+(spec|test).[tj]s?(x)" + // ], + + // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped + // testPathIgnorePatterns: [ + // "/node_modules/" + // ], + + // The regexp pattern or array of patterns that Jest uses to detect test files + // testRegex: [], + + // This option allows the use of a custom results processor + // testResultsProcessor: undefined, + + // This option allows use of a custom test runner + // testRunner: "jest-circus/runner", + + // A map from regular expressions to paths to transformers + // transform: undefined, + + // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation + // transformIgnorePatterns: [ + // "/node_modules/", + // "\\.pnp\\.[^\\/]+$" + // ], + + // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them + // unmockedModulePathPatterns: undefined, + + // Indicates whether each individual test should be reported during the run + // verbose: undefined, + + // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode + // watchPathIgnorePatterns: [], + + // Whether to use watchman for file crawling + // watchman: true, +}; diff --git a/lib/app.ts b/lib/app.ts new file mode 100644 index 0000000..ceb7259 --- /dev/null +++ b/lib/app.ts @@ -0,0 +1,32 @@ +import Koa from 'koa'; +import bodyparser from 'koa-bodyparser'; +import cookie from 'koa-cookie'; +import session from 'koa-session'; + +import passport from './passport'; +import { performanceLogger, performanceTimer } from './middleware/performance'; +import { errorHandler } from './middleware/errorHandler'; +import { authRouter } from './controllers/auth'; +import { SESSION_KEY } from '../constants/env'; + +const app: Koa = new Koa(); + +app.use(errorHandler); +app.use(performanceTimer); +app.use(performanceLogger); +app.use(bodyparser()); +app.use(cookie()); + +app.keys = [SESSION_KEY]; +app.use(session({}, app)); + +app.use(passport.initialize()); +app.use(passport.session()); + +app.use(authRouter.routes()); +app.use(authRouter.allowedMethods()); + +// Application error logging. +app.on('error', console.error); + +export default app; 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/controllers/auth.ts b/lib/controllers/auth.ts new file mode 100644 index 0000000..3516584 --- /dev/null +++ b/lib/controllers/auth.ts @@ -0,0 +1,80 @@ +import { StatusCodes } from 'http-status-codes'; +import Koa from 'koa'; +import Router from 'koa-router'; +import { StringSchemaDefinition } from 'mongoose'; + +import { Auth } from '@mifi/services-common/lib/db'; +import { create } from '@mifi/services-common/lib/db/dao/create'; +import { resetPasswordPost } from '@mifi/services-common/lib/db/api/resetPasswordPost'; +import { resetPasswordGet } from '@mifi/services-common/lib/db/api/resetPasswordGet'; +import { deleteById } from '@mifi/services-common/lib/db/dao/deleteById'; +import { deleteStrategy } from '@mifi/services-common/lib/db/api/deleteStrategy'; +import { AuthDocument } from '@mifi/services-common/lib/db/schema/auth'; + +import { ROUTE_PREFIX as prefix, RESET_ROUTE } from '../constants/env'; +import passport from '../passport'; +import { ErrorCodes, getErrorBody } from '../constants/errors'; +import { authenticated } from '../middleware/authenication'; + +const routerOpts: Router.IRouterOptions = { prefix }; +const router: Router = new Router(routerOpts); + +router.get('/info', (ctx) => { + ctx.body = { + service: process.env.SERVICE_NAME, + }; +}); + +router.post('/', async (ctx) => { + console.log('POST: /auth [ctx]', ctx); + const data = await create(ctx.request.body).catch((err) => + console.error('POST: /auth [err]', err), + ); + console.log('POST: /auth [data]', data); + ctx.body = { success: !!data, data }; +}); + +router.delete('/strategy/:id', async (ctx) => { + ctx.body = { success: await deleteStrategy(ctx.params.id as StringSchemaDefinition) }; +}); + +router.delete('/:id', async (ctx) => { + ctx.body = { success: await deleteById(ctx.params.id as StringSchemaDefinition) }; +}); + +router.post('/login', async (ctx, next) => { + return passport.authenticate('local', (err, user) => { + ctx.body = user; + return user ? ctx.login(user) : ctx.throw(StatusCodes.UNAUTHORIZED); + })(ctx, next); +}); + +router.post(process.env.RESET_ROUTE || RESET_ROUTE, async (ctx) => { + const { password, token, username } = ctx.request.body as { token?: string; password?: string; username?: string }; + let response: false | { record: StringSchemaDefinition; token: string } = false; + + if (username) { + response = await resetPasswordGet(username); + } else if (token && password) { + response = await resetPasswordPost(token, password); + } + + ctx.body = { success: !!response, ...(response || getErrorBody(ErrorCodes.RESET_REQUEST_DATA)) }; + + if (!response) { + ctx.throw(StatusCodes.BAD_REQUEST); + } +}); + +router.patch('/:record', authenticated(), (ctx: Koa.Context) => { + if (ctx.user !== ctx.param.record) { + ctx.throw(StatusCodes.UNAUTHORIZED); + } + const data = Auth.findOneAndUpdate({ record: ctx.params.record }); + if (!data) { + ctx.throw(StatusCodes.NOT_FOUND); + } + ctx.body = { success: true, data }; +}); + +export { router as authRouter }; diff --git a/lib/index.ts b/lib/index.ts new file mode 100644 index 0000000..4db2be5 --- /dev/null +++ b/lib/index.ts @@ -0,0 +1,11 @@ +import app from './app'; +import { connection } from '../db'; +import { PORT } from '../constants/env'; + +connection.then( + () => { + app.listen(PORT); + console.debug('Server up and listening', { env: process.env }); + }, + (err) => console.error('Could not reach database', { err, env: process.env }), +); diff --git a/lib/middleware/authenication.ts b/lib/middleware/authenication.ts new file mode 100644 index 0000000..edce19b --- /dev/null +++ b/lib/middleware/authenication.ts @@ -0,0 +1,13 @@ +import { Middleware } from 'koa'; + +import { LOGIN_ROUTE } from '../constants/env'; + +export const authenticated = (): Middleware => { + return (ctx, next) => { + if (ctx.isAuthenticated()) { + return next(); + } else { + ctx.redirect(process.env.LOGIN_ROUTE || LOGIN_ROUTE); + } + }; +}; diff --git a/lib/middleware/errorHandler.ts b/lib/middleware/errorHandler.ts new file mode 100644 index 0000000..b91aa2f --- /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..669f867 --- /dev/null +++ b/lib/middleware/performance.ts @@ -0,0 +1,14 @@ +import { Context, Next } from 'koa'; + +export const performanceLogger = async (ctx: Context, next: Next) => { + await next(); + const rt = ctx.response.get('X-Response-Time'); + console.log(`${ctx.method} ${ctx.url} - ${rt}`); +}; + +export const performanceTimer = async (ctx: Context, next: Next) => { + const start = Date.now(); + await next(); + const ms = Date.now() - start; + ctx.set('X-Response-Time', `${ms}ms`); +}; diff --git a/lib/passport/index.ts b/lib/passport/index.ts new file mode 100644 index 0000000..6da5542 --- /dev/null +++ b/lib/passport/index.ts @@ -0,0 +1,23 @@ +import passport from 'koa-passport'; +import { Types } from 'mongoose'; + +import { AuthDocument } from '@mifi/services-common/lib/db/schema/auth'; +import { readOneByRecord } from '@mifi/services-common/lib/db/dao/readOneByRecord'; +import { readOneById } from '@mifi/services-common/lib/db/dao/readOneById'; + +import LocalStrategy from './strategies/local'; +import JwtStrategy from './strategies/jwt'; + +passport.use(LocalStrategy); +passport.use(JwtStrategy); + +passport.serializeUser((user, done) => { + done(null, (user as AuthDocument).record || (user as AuthDocument).id); +}); + +passport.deserializeUser(async (id, done) => { + const user = await readOneByRecord(id).catch(async () => await readOneById(id)); + done(user ? null : 'user not found', user); +}); + +export default passport; diff --git a/lib/passport/strategies/jwt.ts b/lib/passport/strategies/jwt.ts new file mode 100644 index 0000000..a20e7de --- /dev/null +++ b/lib/passport/strategies/jwt.ts @@ -0,0 +1,17 @@ +import { ExtractJwt, Strategy as JwtStrategy } from 'passport-jwt'; + +import { readOneByRecord } from '@mifi/services-common/lib/db/dao/readOneByRecord'; + +import { JWT_SECRET } from '../../constants/env'; + +const opts = { + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + secretOrKey: JWT_SECRET, + issuer: process.env.JWT_ISSUER, + audience: process.env.JWT_AUDIENCE, +}; + +export default new JwtStrategy(opts, async ({ sub }, done) => { + const auth = await readOneByRecord(sub); + return done(null, auth || false); +}); diff --git a/lib/passport/strategies/local.ts b/lib/passport/strategies/local.ts new file mode 100644 index 0000000..a921257 --- /dev/null +++ b/lib/passport/strategies/local.ts @@ -0,0 +1,9 @@ +// eslint-disable-next-line import/named +import { Strategy as LocalStrategy } from 'passport-local'; + +import { authenticate } from '@mifi/services-common/lib/db/api/authenticate'; + +export default new LocalStrategy(async (username: string, password: string, done: any) => { + const user = await authenticate(username, password); + done(null, user); +}); 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), + }); diff --git a/package.json b/package.json new file mode 100644 index 0000000..ca296ec --- /dev/null +++ b/package.json @@ -0,0 +1,84 @@ +{ + "name": "@mifi/auth-service", + "version": "1.0.0", + "author": "mifi (Mike Fitzpatrick)", + "license": "MIT", + "scripts": { + "build": "tsc", + "build:production": "tsc -p .", + "format": "prettier:fix && lint:fix", + "lint": "eslint --ext .ts,.tsx lib/", + "lint:fix": "eslint --fix --ext .ts,.tsx lib/", + "prettier": "prettier --check 'lib/**/*.ts'", + "prettier:fix": "prettier --write 'lib/**/*.ts'", + "serve": "node dist/lib/index.js", + "start": "nodemon", + "test": "jest --passWithNoTests" + }, + "devDependencies": { + "@babel/core": "^7.21.8", + "@babel/preset-env": "^7.21.5", + "@babel/preset-typescript": "^7.21.5", + "@tsconfig/node16": "^1.0.3", + "@types/jest": "^29.5.1", + "@types/jsonwebtoken": "^9.0.1", + "@types/koa": "^2.13.5", + "@types/koa-bodyparser": "^4.3.10", + "@types/koa-cookie": "^1.0.0", + "@types/koa-passport": "^4.0.3", + "@types/koa-router": "^7.4.4", + "@types/koa-session": "^5.10.6", + "@types/luxon": "^3.2.0", + "@types/node": "^18.14.0", + "@types/passport": "^1.0.12", + "@types/passport-facebook": "^2.1.11", + "@types/passport-fido2-webauthn": "^0.1.0", + "@types/passport-google-oauth": "^1.0.42", + "@types/passport-jwt": "^3.0.8", + "@types/passport-local": "^1.0.35", + "@typescript-eslint/eslint-plugin": "^5.59.2", + "@typescript-eslint/parser": "^5.59.2", + "babel-jest": "^29.5.0", + "eslint": "^8.39.0", + "eslint-config-prettier": "^8.8.0", + "eslint-import-resolver-typescript": "^3.5.5", + "eslint-plugin-import": "^2.27.5", + "eslint-plugin-n": "^15.0.0", + "eslint-plugin-prettier": "^4.2.1", + "eslint-plugin-promise": "^6.0.0", + "jest": "^29.5.0", + "nodemon": "^2.0.20", + "prettier": "^2.8.4", + "prettier-eslint": "^15.0.1", + "prettier-eslint-cli": "^7.1.0", + "reflect-metadata": "^0.1.13", + "ts-node": "^10.9.1", + "typescript": "^4.9.5" + }, + "dependencies": { + "@mifi/auth-db": "^1.0.0", + "@simplewebauthn/server": "^7.2.0", + "dotenv": "^16.0.3", + "http-status-codes": "^2.2.0", + "jsonwebtoken": "^9.0.0", + "koa": "^2.14.1", + "koa-bodyparser": "^4.3.0", + "koa-cookie": "^1.0.0", + "koa-passport": "^6.0.0", + "koa-router": "^12.0.0", + "koa-session": "^6.4.0", + "luxon": "^3.3.0", + "passport": "^0.6.0", + "passport-facebook": "^3.0.0", + "passport-fido2-webauthn": "^0.1.0", + "passport-google-oauth": "^2.0.0", + "passport-http-bearer": "^1.0.1", + "passport-jwt": "^4.0.1", + "passport-local": "^1.0.0" + }, + "description": "", + "repository": { + "type": "git", + "url": "https://git.mifi.dev/mifi/auth-api.git" + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..bdb6755 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@tsconfig/node16/tsconfig.json", + "compilerOptions": { + "allowSyntheticDefaultImports": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "noImplicitAny": true, + "outDir": "./dist/", + "rootDirs": ["lib"], + "sourceMap": true + } +}