Title
22 |Lorem ipsum dolor
23 |├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── api ├── .env.example ├── .env.test ├── package-lock.json ├── package.json ├── src │ ├── app.ts │ ├── config.ts │ ├── db.ts │ ├── express-session.d.ts │ ├── index.ts │ ├── middleware.ts │ ├── routes │ │ ├── auth.ts │ │ ├── demo.ts │ │ ├── email.ts │ │ ├── index.ts │ │ └── password.ts │ ├── utils.ts │ └── validation.ts ├── test │ ├── auth │ │ ├── login.test.ts │ │ ├── logout.test.ts │ │ └── register.test.ts │ ├── email │ │ ├── resend.test.ts │ │ └── verify.test.ts │ ├── middleware.test.ts │ ├── password │ │ ├── confirm.test.ts │ │ ├── email.test.ts │ │ └── reset.test.ts │ ├── setup.ts │ └── utils.test.ts ├── tsconfig.build.json └── tsconfig.json └── readme.md /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | 3 | node_modules 4 | 5 | .env 6 | 7 | *.log 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "esbenp.prettier-vscode", 3 | "editor.formatOnSave": true 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Alex N 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /api/.env.example: -------------------------------------------------------------------------------- 1 | # DO NOT USE THIS FILE IN PRODUCTION. IT'S FOR DEVELOPMENT ONLY. 2 | 3 | APP_SECRET=rG1b/tpaQ5yMzkW8em79XFcV/LcDHRc2XjvMWlWp9e8= 4 | 5 | MAIL_HOST= 6 | MAIL_PORT= 7 | MAIL_USERNAME= 8 | MAIL_PASSWORD= 9 | -------------------------------------------------------------------------------- /api/.env.test: -------------------------------------------------------------------------------- 1 | # DO NOT USE THIS FILE IN PRODUCTION. IT'S FOR TESTING ONLY. 2 | 3 | NODE_ENV=test 4 | 5 | APP_SECRET=rG1b/tpaQ5yMzkW8em79XFcV/LcDHRc2XjvMWlWp9e8= 6 | 7 | -------------------------------------------------------------------------------- /api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "@node-auth/api", 4 | "version": "1.0.0", 5 | "main": "dist/index.js", 6 | "license": "MIT", 7 | "scripts": { 8 | "dev": "env $(grep -v '^#' .env) tsnd --transpile-only src", 9 | "prebuild": "rm -rf dist", 10 | "build": "tsc -p tsconfig.build.json", 11 | "start": "NODE_ENV=production node -r source-map-support/register dist/index.js", 12 | "test": "env $(grep -v '^#' .env.test) tap --ts --no-coverage test/{*.test.ts,**/*.test.ts}", 13 | "test:watch": "npm run test -- -w" 14 | }, 15 | "dependencies": { 16 | "bcrypt": "^5.0.1", 17 | "celebrate": "^15.0.0", 18 | "dayjs": "^1.10.6", 19 | "express": "^4.17.1", 20 | "express-async-errors": "^3.1.1", 21 | "express-session": "^1.17.2", 22 | "helmet": "^4.6.0", 23 | "nodemailer": "^6.6.3", 24 | "source-map-support": "^0.5.19" 25 | }, 26 | "devDependencies": { 27 | "@types/bcrypt": "^5.0.0", 28 | "@types/express": "^4.17.13", 29 | "@types/express-session": "^1.17.4", 30 | "@types/node": "^16.3.3", 31 | "@types/nodemailer": "^6.4.4", 32 | "@types/supertest": "^2.0.11", 33 | "@types/tap": "^15.0.5", 34 | "supertest": "^6.1.3", 35 | "tap": "^15.0.9", 36 | "ts-node-dev": "^1.1.8", 37 | "typescript": "^4.3.5" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /api/src/app.ts: -------------------------------------------------------------------------------- 1 | import "express-async-errors"; 2 | import { Transporter } from "nodemailer"; 3 | import express from "express"; 4 | import helmet from "helmet"; 5 | import session from "express-session"; 6 | import { errors } from "celebrate"; 7 | import { SESSION_OPTS } from "./config"; 8 | import { demo, auth, email, password } from "./routes"; 9 | import { notFound, serverError } from "./middleware"; 10 | 11 | export const createApp = (mailer: Transporter) => { 12 | const app = express(); 13 | 14 | app.locals.mailer = mailer; 15 | 16 | app.use(helmet()); 17 | 18 | app.use(session(SESSION_OPTS)); 19 | 20 | app.use(express.json()); 21 | 22 | app.use( 23 | demo, // sample routes to test middleware 24 | auth, // login, logout, register 25 | email, // email verification, resend 26 | password // password recovery and confirmation 27 | ); 28 | 29 | app.use(notFound); 30 | 31 | app.use(errors()); 32 | 33 | app.use(serverError); 34 | 35 | return app; 36 | }; 37 | -------------------------------------------------------------------------------- /api/src/config.ts: -------------------------------------------------------------------------------- 1 | import { SessionOptions } from "express-session"; 2 | import SMTPTransport from "nodemailer/lib/smtp-transport"; 3 | 4 | export const { 5 | NODE_ENV = "development", 6 | 7 | APP_PORT = 3000, 8 | APP_HOSTNAME = "localhost", 9 | // NOTE APP_SECRET is used to sign the session ID cookie, 10 | // the email verification URL, and the password reset token. 11 | // It may be prudent to use different secrets for each. 12 | APP_SECRET = "", // crypto.randomBytes(32).toString('base64') 13 | 14 | SESSION_COOKIE = "sid", 15 | 16 | MAIL_HOST = "", 17 | MAIL_PORT = "", 18 | MAIL_USERNAME = "", 19 | MAIL_PASSWORD = "", 20 | } = process.env; 21 | 22 | export const IN_PROD = NODE_ENV === "production"; 23 | const IN_DEV = NODE_ENV === "development"; 24 | const IN_TEST = NODE_ENV === "test"; 25 | 26 | // Assert required variables are passed 27 | [ 28 | "APP_SECRET", 29 | IN_PROD && "APP_HOSTNAME", 30 | !IN_TEST && "MAIL_HOST", 31 | !IN_TEST && "MAIL_PORT", 32 | !IN_TEST && "MAIL_USERNAME", 33 | !IN_TEST && "MAIL_PASSWORD", 34 | ].forEach((secret) => { 35 | if (secret && !process.env[secret]) { 36 | throw new Error(`${secret} is missing from process.env`); 37 | } 38 | }); 39 | 40 | // App 41 | 42 | const APP_PROTOCOL = IN_PROD ? "https" : "http"; 43 | const APP_HOST = `${APP_HOSTNAME}${IN_DEV ? `:${APP_PORT}` : ""}`; 44 | export const APP_ORIGIN = `${APP_PROTOCOL}://${APP_HOST}`; 45 | 46 | // Session 47 | 48 | const ONE_HOUR_IN_MS = 1_000 * 60 * 60; 49 | const ONE_WEEK_IN_MS = 7 * 24 * ONE_HOUR_IN_MS; 50 | 51 | export const SESSION_OPTS: SessionOptions = { 52 | cookie: { 53 | // domain, // current domain (Same-Origin, no CORS) 54 | httpOnly: true, 55 | maxAge: ONE_WEEK_IN_MS, 56 | sameSite: "strict", 57 | secure: IN_PROD, 58 | }, 59 | name: SESSION_COOKIE, 60 | resave: false, // whether to save the session if it wasn't modified during the request 61 | rolling: true, // whether to (re-)set cookie on every response 62 | saveUninitialized: false, // whether to save empty sessions to the store 63 | secret: APP_SECRET, 64 | }; 65 | 66 | // Bcrypt 67 | 68 | export const BCRYPT_SALT_ROUNDS = 12; 69 | 70 | // Mail 71 | 72 | export const MAIL_OPTS: SMTPTransport.Options = { 73 | host: MAIL_HOST, 74 | port: +MAIL_PORT, 75 | secure: IN_PROD, 76 | auth: { 77 | user: MAIL_USERNAME, 78 | pass: MAIL_PASSWORD, 79 | }, 80 | }; 81 | 82 | export const MAIL_EXPIRES_IN_DAYS = 1; 83 | export const MAIL_FROM = `noreply@${APP_HOSTNAME}`; 84 | 85 | // Passwords 86 | 87 | export const PWD_RESET_TOKEN_BYTES = 40; 88 | export const PWD_RESET_EXPIRES_IN_HOURS = 12; 89 | export const PWD_CONFIRM_EXPIRES_IN_MS = 2 * ONE_HOUR_IN_MS; 90 | -------------------------------------------------------------------------------- /api/src/db.ts: -------------------------------------------------------------------------------- 1 | export const db: Db = { 2 | users: [ 3 | { 4 | id: 1, 5 | email: "test@gmail.com", 6 | password: "$2b$12$j5V3RyLko4Gpx.IStt8ux.WN95F3n3fULUyhBINe4zbME.L7C1h7C", // 'test' 7 | name: "Test", 8 | verifiedAt: null, 9 | }, 10 | ], 11 | passwordResets: [], 12 | }; 13 | 14 | interface Db { 15 | users: User[]; 16 | passwordResets: PasswordReset[]; 17 | } 18 | 19 | export interface User { 20 | id: number; 21 | email: string; 22 | password: string; 23 | name: string; 24 | verifiedAt: string | null; 25 | } 26 | 27 | export interface PasswordReset { 28 | id: number; 29 | userId: number; 30 | token: string; 31 | expiresAt: string; 32 | } 33 | -------------------------------------------------------------------------------- /api/src/express-session.d.ts: -------------------------------------------------------------------------------- 1 | import session from "express-session"; 2 | 3 | declare module "express-session" { 4 | export interface SessionData { 5 | userId?: number; // ID int 6 | confirmedAt: number; // timestamp in ms 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /api/src/index.ts: -------------------------------------------------------------------------------- 1 | import nodemailer from "nodemailer"; 2 | import { MAIL_OPTS } from "./config"; 3 | import { createApp } from "./app"; 4 | import { APP_PORT, APP_ORIGIN } from "./config"; 5 | 6 | const mailer = nodemailer.createTransport(MAIL_OPTS); 7 | 8 | const app = createApp(mailer); 9 | 10 | app.listen(APP_PORT, () => console.log(APP_ORIGIN)); 11 | -------------------------------------------------------------------------------- /api/src/middleware.ts: -------------------------------------------------------------------------------- 1 | import { RequestHandler, ErrorRequestHandler } from "express"; 2 | import { db } from "./db"; 3 | import { PWD_CONFIRM_EXPIRES_IN_MS } from "./config"; 4 | 5 | // https://expressjs.com/en/starter/faq.html#how-do-i-handle-404-responses 6 | 7 | export const notFound: RequestHandler = (req, res, next) => { 8 | res.status(404).json({ message: "Not Found" }); 9 | }; 10 | 11 | export const serverError: ErrorRequestHandler = (err, req, res, next) => { 12 | // Handle "SyntaxError: Unexpected end of JSON input" 13 | if (err instanceof SyntaxError) { 14 | return res.status(400).json({ message: "Bad Request" }); 15 | } 16 | 17 | console.error(err); 18 | res.status(500).json({ message: "Server Error" }); 19 | }; 20 | 21 | // Laravel's naming https://laravel.com/docs/8.x/middleware#assigning-middleware-to-routes 22 | 23 | export const auth: RequestHandler = (req, res, next) => { 24 | if (req.session.userId) { 25 | return next(); 26 | } 27 | 28 | res.status(401).json({ message: "Unauthorized" }); 29 | }; 30 | 31 | export const guest: RequestHandler = (req, res, next) => { 32 | if (req.session.userId) { 33 | return res.status(403).json({ message: "Forbidden" }); 34 | } 35 | 36 | next(); 37 | }; 38 | 39 | export const verified: RequestHandler = (req, res, next) => { 40 | const { userId } = req.session; 41 | const { verifiedAt } = db.users.find((user) => user.id === userId) || {}; 42 | 43 | if (!verifiedAt) { 44 | return res.status(403).json({ message: "Forbidden" }); 45 | } 46 | 47 | next(); 48 | }; 49 | 50 | export const pwdConfirmed: RequestHandler = (req, res, next) => { 51 | const { confirmedAt } = req.session; 52 | 53 | if (!confirmedAt || confirmedAt + PWD_CONFIRM_EXPIRES_IN_MS <= Date.now()) { 54 | return res.status(403).json({ message: "Forbidden" }); 55 | } 56 | 57 | next(); 58 | }; 59 | -------------------------------------------------------------------------------- /api/src/routes/auth.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { compare, hash } from "bcrypt"; 3 | import { createHash } from "crypto"; 4 | import { validate, loginSchema, registerSchema } from "../validation"; 5 | import { db, User } from "../db"; 6 | import { auth, guest } from "../middleware"; 7 | import { SESSION_COOKIE, BCRYPT_SALT_ROUNDS } from "../config"; 8 | import { confirmationEmail } from "./email"; 9 | 10 | const router = Router(); 11 | 12 | // Login 13 | 14 | // NOTE login is idempotent, so we don't apply `guest` middleware 15 | // https://stackoverflow.com/a/18263884 16 | router.post("/login", validate(loginSchema), async (req, res) => { 17 | const { email, password } = req.body; 18 | 19 | // TODO this lookup isn't constant time, so it can leak information 20 | // (ex: when the email doesn't exist). When using a DB like Postgres, 21 | // index the `email` field so that your query is timing-safe. 22 | const user = db.users.find((user) => user.email === email); 23 | 24 | // NOTE even if the user doesn't exist, we still hash the plaintext 25 | // password. Although inefficient, this helps mitigate a timing attack. 26 | const fakeHash = 27 | "$2b$12$tLn0rFkPBoE1WCpdM6MjR.t/h6Wzql1kAd27FecEDtjRYsTFlYlWa"; // 'test' 28 | const pwdHash = user?.password || fakeHash; 29 | const pwdMatches = await comparePassword(password, pwdHash); 30 | 31 | // NOTE bcrypt's compare() is *not* timing-safe 32 | // https://github.com/kelektiv/node.bcrypt.js/issues/720 33 | // This is fine because the generated hash can't be predicted, so the 34 | // attacker can't learn anything based on the time of this comparison 35 | // https://github.com/bcrypt-ruby/bcrypt-ruby/pull/43 36 | if (!user || !pwdMatches) { 37 | // Return 401 for invalid creds https://stackoverflow.com/a/32752617 38 | return res.status(401).json({ 39 | message: "Email or password is incorrect", 40 | }); 41 | } 42 | 43 | req.session.userId = user.id; 44 | 45 | res.json({ message: "OK" }); 46 | }); 47 | 48 | // Logout 49 | 50 | router.post("/logout", auth, (req, res) => { 51 | req.session.destroy((err) => { 52 | if (err) throw err; 53 | 54 | res.clearCookie(SESSION_COOKIE); 55 | 56 | res.json({ message: "OK" }); 57 | }); 58 | }); 59 | 60 | // Register 61 | 62 | router.post("/register", guest, validate(registerSchema), async (req, res) => { 63 | const { email, password, name } = req.body; 64 | 65 | const userExists = db.users.some((user) => user.email === email); 66 | 67 | if (userExists) { 68 | // TODO throw Joi error if possible, assuming the above check is async 69 | return res.status(400).json({ 70 | message: "Email is already taken", 71 | }); 72 | } 73 | 74 | // Create the user 75 | const user: User = { 76 | id: db.users.length + 1, 77 | email, 78 | password: await hashPassword(password), 79 | name, 80 | verifiedAt: null, 81 | }; 82 | db.users.push(user); 83 | 84 | // Authenticate 85 | req.session.userId = user.id; 86 | 87 | // Send the email 88 | const { mailer } = req.app.locals; 89 | await mailer.sendMail(confirmationEmail(email, user.id)); 90 | 91 | res.status(201).json({ message: "OK" }); 92 | }); 93 | 94 | // Utils 95 | 96 | // NOTE bcrypt truncates the input string after 72 bytes, meaning 97 | // you can still log in with just the first 72 bytes of your password. 98 | // To prevent this, we prehash plaintext passwords before running them 99 | // through bcrypt. https://security.stackexchange.com/q/6623 100 | export const comparePassword = (plaintextPassword: string, hash: string) => 101 | compare(sha256(plaintextPassword), hash); 102 | 103 | export const hashPassword = (plaintextPassword: string) => 104 | hash(sha256(plaintextPassword), BCRYPT_SALT_ROUNDS); 105 | 106 | // NOTE SHA256 always produces a string that's 256 bits (or 32 bytes) long. 107 | // In base64, that's ceil(32 / 3) * 4 = 44 bytes which meets the 72 byte limit. 108 | const sha256 = (plaintext: string) => 109 | createHash("sha256").update(plaintext).digest("base64"); 110 | 111 | export { router as auth }; 112 | -------------------------------------------------------------------------------- /api/src/routes/demo.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { db } from "../db"; 3 | import { auth, verified, pwdConfirmed } from "../middleware"; 4 | 5 | const router = Router(); 6 | 7 | router.get("/", (req, res) => res.json({ message: "OK" })); // health 8 | 9 | router.get("/me", auth, (req, res) => { 10 | return res.json(db.users.find((user) => user.id === req.session.userId)); 11 | }); 12 | 13 | // NOTE how both auth *and* verified are applied in that order. 14 | // This ensures that unauthorized reqs return 401, while unverified 403. 15 | router.get("/me/verified", auth, verified, (req, res) => 16 | res.json({ message: "OK" }) 17 | ); 18 | 19 | router.get("/me/confirmed", auth, pwdConfirmed, (req, res) => 20 | res.json({ message: "OK" }) 21 | ); 22 | 23 | export { router as demo }; 24 | -------------------------------------------------------------------------------- /api/src/routes/email.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import dayjs from "dayjs"; 3 | import { SendMailOptions } from "nodemailer"; 4 | import { validate, verifyEmailSchema, resendEmailSchema } from "../validation"; 5 | import { db } from "../db"; 6 | import { safeEqual, hmacSha256, compress } from "../utils"; 7 | import { 8 | APP_ORIGIN, 9 | APP_SECRET, 10 | MAIL_EXPIRES_IN_DAYS, 11 | MAIL_FROM, 12 | } from "../config"; 13 | 14 | const router = Router(); 15 | 16 | // Email verification 17 | 18 | // NOTE both routes could be behind `auth` middleware in which case 19 | // we wouldn't need to ask for the user ID or email. 20 | router.post("/email/verify", validate(verifyEmailSchema), (req, res) => { 21 | const { id, expires } = req.query; 22 | 23 | const expectedUrl = confirmationUrl(Number(id), Number(expires)); 24 | const actualUrl = `${APP_ORIGIN}${req.originalUrl}`; 25 | 26 | if (!safeEqual(expectedUrl, actualUrl)) { 27 | return res.status(400).json({ message: "URL is invalid" }); 28 | } 29 | 30 | if (Number(expires) <= Date.now()) { 31 | return res.status(400).json({ message: "URL has expired" }); 32 | } 33 | 34 | const user = db.users.find((user) => user.id === Number(id)); 35 | 36 | if (!user || user.verifiedAt) { 37 | return res 38 | .status(400) 39 | .json({ message: "Email is incorrect or already verified" }); 40 | } 41 | 42 | user.verifiedAt = new Date().toISOString(); 43 | 44 | res.json({ message: "OK" }); 45 | }); 46 | 47 | // Email resend 48 | 49 | router.post("/email/resend", validate(resendEmailSchema), async (req, res) => { 50 | const { email } = req.body; 51 | const user = db.users.find((user) => user.email === email); 52 | 53 | if (!user || user.verifiedAt) { 54 | return res 55 | .status(400) 56 | .json({ message: "Email is incorrect or already verified" }); 57 | } 58 | 59 | const { mailer } = req.app.locals; 60 | await mailer.sendMail(confirmationEmail(email, user.id)); 61 | 62 | res.json({ message: "OK" }); 63 | }); 64 | 65 | // Utils 66 | 67 | export function confirmationUrl(userId: number, expiresInMs?: number) { 68 | expiresInMs = 69 | expiresInMs || dayjs().add(MAIL_EXPIRES_IN_DAYS, "day").valueOf(); 70 | 71 | const url = `${APP_ORIGIN}/email/verify?id=${userId}&expires=${expiresInMs}`; 72 | const signature = hmacSha256(url, APP_SECRET); // 256 bits = 32 bytes, 32 * 2 = 64 chars 73 | 74 | return `${url}&signature=${signature}`; 75 | } 76 | 77 | export function confirmationEmail(to: string, userId: number): SendMailOptions { 78 | const url = confirmationUrl(userId); 79 | 80 | return { 81 | from: MAIL_FROM, 82 | to, 83 | subject: "Confirm your email", 84 | html: compress(` 85 |
To verify your email, POST to the link below.
86 | ${url} 87 | `), // TODO should be a link to the front-end 88 | }; 89 | } 90 | 91 | export { router as email }; 92 | -------------------------------------------------------------------------------- /api/src/routes/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./auth"; 2 | 3 | export * from "./demo"; 4 | 5 | export * from "./email"; 6 | 7 | export * from "./password"; 8 | -------------------------------------------------------------------------------- /api/src/routes/password.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { randomBytes } from "crypto"; 3 | import dayjs from "dayjs"; 4 | import { SendMailOptions } from "nodemailer"; 5 | import { guest, auth } from "../middleware"; 6 | import { 7 | validate, 8 | sendResetSchema, 9 | resetPasswordSchema, 10 | confirmPasswordSchema, 11 | } from "../validation"; 12 | import { db } from "../db"; 13 | import { hmacSha256, safeEqual, compress } from "../utils"; 14 | import { hashPassword, comparePassword } from "./auth"; 15 | import { 16 | APP_SECRET, 17 | PWD_RESET_TOKEN_BYTES, 18 | PWD_RESET_EXPIRES_IN_HOURS, 19 | APP_ORIGIN, 20 | MAIL_FROM, 21 | } from "../config"; 22 | 23 | const router = Router(); 24 | 25 | // Password reset request 26 | 27 | router.post( 28 | "/password/email", 29 | guest, 30 | validate(sendResetSchema), 31 | async (req, res) => { 32 | const { email } = req.body; 33 | 34 | const user = db.users.find((user) => user.email === email); 35 | 36 | if (!user) { 37 | // TODO throw Joi error if possible, assuming the above check is async 38 | return res.status(400).json({ 39 | message: "Email does not exist", 40 | }); 41 | } 42 | 43 | const token = randomBytes(PWD_RESET_TOKEN_BYTES).toString("hex"); 44 | const expiresAt = dayjs() 45 | .add(PWD_RESET_EXPIRES_IN_HOURS, "hour") 46 | .toISOString(); 47 | 48 | // NOTE we treat reset tokens like passwords, so we don't store 49 | // them in plaintext. Instead, we hash and sign them with a secret. 50 | db.passwordResets.push({ 51 | id: db.passwordResets.length + 1, 52 | userId: user.id, 53 | token: hmacSha256(token, APP_SECRET), 54 | expiresAt, 55 | }); 56 | 57 | const { mailer } = req.app.locals; 58 | await mailer.sendMail(passwordResetEmail(email, token, user.id)); 59 | 60 | res.json({ message: "OK" }); 61 | } 62 | ); 63 | 64 | // Password reset submission 65 | 66 | router.post( 67 | "/password/reset", 68 | guest, 69 | validate(resetPasswordSchema), 70 | async (req, res) => { 71 | const { token, id } = req.query; 72 | const { password } = req.body; 73 | 74 | const hashedToken = hmacSha256(String(token), APP_SECRET); 75 | const resetToken = db.passwordResets.find( 76 | (reset) => 77 | reset.userId === Number(id) && safeEqual(reset.token, hashedToken) 78 | ); 79 | 80 | if (!resetToken) { 81 | return res.status(401).json({ message: "Token or ID is invalid" }); 82 | } 83 | 84 | const user = db.users.find((user) => user.id === Number(id)); 85 | if (!user) throw new Error(`User id = ${id} not found`); // unreachable 86 | user.password = await hashPassword(password); 87 | 88 | // Invalidate all user reset tokens 89 | db.passwordResets = db.passwordResets.filter( 90 | (reset) => reset.userId !== Number(id) 91 | ); 92 | 93 | res.json({ message: "OK" }); 94 | } 95 | ); 96 | 97 | // Password confirmation 98 | 99 | router.post( 100 | "/password/confirm", 101 | auth, 102 | validate(confirmPasswordSchema), 103 | async (req, res) => { 104 | const { password } = req.body; 105 | const { userId } = req.session; 106 | 107 | const user = db.users.find((user) => user.id === userId); 108 | if (!user) throw new Error(`User id = ${userId} not found`); // unreachable 109 | 110 | const pwdMatches = await comparePassword(password, user.password); 111 | 112 | if (!pwdMatches) { 113 | return res.status(401).json({ message: "Password is incorrect" }); 114 | } 115 | 116 | req.session.confirmedAt = Date.now(); 117 | 118 | res.json({ message: "OK" }); 119 | } 120 | ); 121 | 122 | // Utils 123 | 124 | function passwordResetEmail( 125 | to: string, 126 | token: string, 127 | userId: number 128 | ): SendMailOptions { 129 | const url = `${APP_ORIGIN}/password/reset?id=${userId}&token=${token}`; 130 | return { 131 | from: MAIL_FROM, 132 | to, 133 | subject: "Reset your password", 134 | html: compress(` 135 |To reset your password, POST to the link below.
136 | ${url} 137 | `), // TODO should be a link to the front-end 138 | }; 139 | } 140 | 141 | export { router as password }; 142 | -------------------------------------------------------------------------------- /api/src/utils.ts: -------------------------------------------------------------------------------- 1 | import { timingSafeEqual, createHash, createHmac } from "crypto"; 2 | 3 | // Crypto 4 | 5 | // Perform a constant-time comparison when the strings have the same length. 6 | export const safeEqual = (a: string, b: string) => { 7 | const aBuff = Buffer.from(a); 8 | const bBuff = Buffer.from(b); 9 | 10 | // NOTE crypto.timingSafeEqual() requires buffers of the same length. 11 | // We short-circuit when they have different lengths - this doesn't aid 12 | // in a timing attack https://github.com/nodejs/node/issues/17178 13 | return aBuff.length === bBuff.length && timingSafeEqual(aBuff, bBuff); 14 | }; 15 | 16 | export const hmacSha256 = (plaintext: string, key: string) => 17 | createHmac("sha256", key).update(plaintext).digest("hex"); 18 | 19 | // String 20 | 21 | export const compress = (str: string) => str.replace(/\s{2,}/g, ""); 22 | -------------------------------------------------------------------------------- /api/src/validation.ts: -------------------------------------------------------------------------------- 1 | import { celebrate, SchemaOptions, Modes, Segments, Joi } from "celebrate"; 2 | import { PWD_RESET_TOKEN_BYTES } from "./config"; 3 | 4 | export const validate = (schema: SchemaOptions) => 5 | celebrate( 6 | schema, 7 | { 8 | abortEarly: false, // validate all fields in the segment 9 | }, 10 | { 11 | mode: Modes.FULL, // validate all segments (body, query, etc.) 12 | } 13 | ); 14 | 15 | const email = Joi.string().email().required(); 16 | 17 | // NOTE instead of prehashing passwords with SHA256, we could limit 18 | // them to 72 bytes (important: not characters) like so: .max(72, 'utf8') 19 | // However, this would likely leak our password algorithm (i.e. bcrypt). 20 | const password = Joi.string().max(256).required(); // TODO password strength 21 | 22 | export const loginSchema = { 23 | [Segments.BODY]: Joi.object().keys({ 24 | email, 25 | password, 26 | }), 27 | }; 28 | 29 | export const registerSchema = { 30 | [Segments.BODY]: Joi.object().keys({ 31 | email, 32 | password, 33 | name: Joi.string().max(256).required(), 34 | }), 35 | }; 36 | 37 | // Based on Postgres `serial` type (4 bytes, roughly 2.1B) 38 | // https://www.postgresql.org/docs/9.1/datatype-numeric.html 39 | const id = Joi.number() 40 | .positive() // can't be zero or negative 41 | .max(2 ** 31 - 1) 42 | .required(); 43 | 44 | export const verifyEmailSchema = { 45 | [Segments.QUERY]: { 46 | id, 47 | expires: Joi.date().timestamp().raw().required(), // `raw` means it's not casted to a Date 48 | signature: Joi.string().length(64).required(), // 256 / 8 * 2 (hex) 49 | }, 50 | }; 51 | 52 | export const resendEmailSchema = { 53 | [Segments.BODY]: Joi.object().keys({ 54 | email, 55 | }), 56 | }; 57 | 58 | export const sendResetSchema = { 59 | [Segments.BODY]: Joi.object().keys({ 60 | email, 61 | }), 62 | }; 63 | 64 | export const resetPasswordSchema = { 65 | [Segments.QUERY]: { 66 | id, 67 | token: Joi.string() 68 | .length(PWD_RESET_TOKEN_BYTES * 2) // hex 69 | .required(), 70 | }, 71 | [Segments.BODY]: Joi.object().keys({ 72 | password, 73 | }), 74 | }; 75 | 76 | export const confirmPasswordSchema = { 77 | [Segments.BODY]: Joi.object().keys({ 78 | password, 79 | }), 80 | }; 81 | -------------------------------------------------------------------------------- /api/test/auth/login.test.ts: -------------------------------------------------------------------------------- 1 | import t from "tap"; 2 | import request from "supertest"; 3 | import { app } from "../setup"; 4 | 5 | t.test("/login - happy path", async (t) => { 6 | const res = await request(app) 7 | .post("/login") 8 | .send({ email: "test@gmail.com", password: "test" }) 9 | .expect(200) 10 | .expect("Set-Cookie", /sid=.+; Expires=.+; HttpOnly; SameSite=Strict/); 11 | 12 | const cookie = res.headers["set-cookie"][0].split(/;/, 1)[0]; 13 | 14 | await request(app).get("/me").set("Cookie", [cookie]).expect(200); 15 | }); 16 | 17 | t.test("/login - missing credentials", async (t) => { 18 | const res = await request(app).post("/login").expect(400); 19 | 20 | t.equal( 21 | res.body.validation.body.message, 22 | '"email" is required. "password" is required' 23 | ); 24 | }); 25 | 26 | t.test("/login - invalid email (user doesn't exist)", async (t) => { 27 | const res = await request(app) 28 | .post("/login") 29 | .send({ email: "bogus@gmail.com", password: "test" }) 30 | .expect(401); 31 | 32 | t.equal(res.body.message, "Email or password is incorrect"); 33 | }); 34 | 35 | t.test("/login - invalid password (user does exist)", async (t) => { 36 | const res = await request(app) 37 | .post("/login") 38 | .send({ email: "test@gmail.com", password: "wrong" }) 39 | .expect(401); 40 | 41 | t.equal(res.body.message, "Email or password is incorrect"); 42 | }); 43 | 44 | t.test("/login - already logged in", async (t) => { 45 | const payload = { email: "test@gmail.com", password: "test" }; 46 | 47 | const req = await request(app).post("/login").send(payload).expect(200); 48 | 49 | await request(app) 50 | .post("/login") 51 | .set("Cookie", [req.headers.sid]) 52 | .send(payload) 53 | .expect(200) 54 | .expect("Set-Cookie", /sid=.+; Expires=.+; HttpOnly; SameSite=Strict/); 55 | }); 56 | -------------------------------------------------------------------------------- /api/test/auth/logout.test.ts: -------------------------------------------------------------------------------- 1 | import t from "tap"; 2 | import request from "supertest"; 3 | import { app } from "../setup"; 4 | 5 | t.test("/logout - happy path", async (t) => { 6 | const req = await request(app) 7 | .post("/login") 8 | .send({ email: "test@gmail.com", password: "test" }) 9 | .expect(200); 10 | const cookie = req.headers["set-cookie"][0].split(/;/, 1)[0]; 11 | 12 | await request(app) 13 | .post("/logout") 14 | .set("Cookie", [cookie]) 15 | .expect(200) 16 | .expect("Set-Cookie", /sid=;/); 17 | 18 | await request(app).get("/me").set("Cookie", [cookie]).expect(401); 19 | }); 20 | 21 | t.test("/logout - not logged in", async (t) => { 22 | const res = await request(app).post("/logout").expect(401); 23 | 24 | t.equal(res.body.message, "Unauthorized"); 25 | }); 26 | 27 | t.test("/logout - invalid or expired cookie", async (t) => { 28 | const res = await request(app) 29 | .post("/logout") 30 | .set("Cookie", [ 31 | "sid=s%3AT_Pkrw6AvSQ3LfOYC9q0EnE1uqWQhJbp.hTs%2BqXXHbFMn2dxgSKBWd%2F%2FEQ8xwnV3KKsA9IwVJ7nU", 32 | ]) 33 | // .expect("Set-Cookie", /sid=;/) // TODO should unset invalid cookie 34 | .expect(401); 35 | 36 | t.equal(res.body.message, "Unauthorized"); 37 | }); 38 | -------------------------------------------------------------------------------- /api/test/auth/register.test.ts: -------------------------------------------------------------------------------- 1 | import t from "tap"; 2 | import request from "supertest"; 3 | import { app, fakeInbox } from "../setup"; 4 | 5 | t.test("/register - happy path", async (t) => { 6 | const email = "alex@gmail.com"; 7 | 8 | const res = await request(app) 9 | .post("/register") 10 | .send({ email, password: "123456", name: "Alex" }) 11 | .expect(201) 12 | .expect("Set-Cookie", /sid=.+; Expires=.+; HttpOnly; SameSite=Strict/); 13 | 14 | t.match( 15 | fakeInbox[email][0].message.html, 16 | /\/email\/verify\?id=\d{1,}&expires=\d{13,}&signature=[a-z\d]{64}/ 17 | ); 18 | 19 | const cookie = res.headers["set-cookie"][0].split(/;/, 1)[0]; 20 | 21 | await request(app).get("/me").set("Cookie", [cookie]).expect(200); 22 | }); 23 | 24 | t.test("/register - missing body", async (t) => { 25 | const res = await request(app).post("/register").expect(400); 26 | 27 | t.equal( 28 | res.body.validation.body.message, 29 | '"email" is required. "password" is required. "name" is required' 30 | ); 31 | }); 32 | 33 | t.test("/register - email already taken", async (t) => { 34 | const res = await request(app) 35 | .post("/register") 36 | .send({ email: "test@gmail.com", password: "123456", name: "Test" }) 37 | .expect(400); 38 | 39 | t.equal(res.body.message, "Email is already taken"); 40 | }); 41 | 42 | t.test("/register - already logged in", async (t) => { 43 | const login = await request(app) 44 | .post("/login") 45 | .send({ email: "test@gmail.com", password: "test" }) 46 | .expect(200); 47 | const cookie = login.headers["set-cookie"][0].split(/;/, 1)[0]; 48 | 49 | const res = await request(app) 50 | .post("/register") 51 | .set("Cookie", [cookie]) 52 | // .send({}) // doesn't matter what the body is 53 | .expect(403); 54 | 55 | t.equal(res.body.message, "Forbidden"); 56 | }); 57 | 58 | t.test("/register - invalid/expired cookie", async (t) => { 59 | await request(app) 60 | .post("/register") 61 | .set("Cookie", [ 62 | "sid=s%3AT_Pkrw6AvSQ3LfOYC9q0EnE1uqWQhJbp.hTs%2BqXXHbFMn2dxgSKBWd%2F%2FEQ8xwnV3KKsA9IwVJ7nU", 63 | ]) 64 | .send({ email: "max@gmail.com", password: "123456", name: "Max" }) 65 | .expect(201) 66 | .expect("Set-Cookie", /sid=.+; Expires=.+; HttpOnly; SameSite=Strict/); 67 | }); 68 | -------------------------------------------------------------------------------- /api/test/email/resend.test.ts: -------------------------------------------------------------------------------- 1 | import t from "tap"; 2 | import request from "supertest"; 3 | import { app, fakeInbox } from "../setup"; 4 | 5 | t.test("/email/resend - happy path", async (t) => { 6 | const email = "homer@gmail.com"; 7 | 8 | await request(app) 9 | .post("/register") 10 | .send({ email, password: "123456", name: "Homer" }) 11 | .expect(201); 12 | 13 | await request(app).post("/email/resend").send({ email }).expect(200); 14 | 15 | t.match( 16 | fakeInbox[email][1].message.html, 17 | /\/email\/verify\?id=\d{1,}&expires=\d{13,}&signature=[a-z\d]{64}/ 18 | ); 19 | }); 20 | 21 | t.test("/email/resend - missing body", async (t) => { 22 | const res = await request(app).post("/email/resend").expect(400); 23 | 24 | t.equal(res.body.validation.body.message, '"email" is required'); 25 | }); 26 | 27 | t.test("/email/resend - non-existing email", async (t) => { 28 | const res = await request(app) 29 | .post("/email/resend") 30 | .send({ email: "bogus@gmail.com" }) 31 | .expect(400); 32 | 33 | t.equal(res.body.message, "Email is incorrect or already verified"); 34 | }); 35 | 36 | t.test("/email/resend - already verified", async (t) => { 37 | const email = "josh@gmail.com"; 38 | 39 | await request(app) 40 | .post("/register") 41 | .send({ email, password: "123456", name: "Josh" }) 42 | .expect(201); 43 | 44 | const [, link] = fakeInbox[email][0].message.html.match( 45 | // 46 | ); 47 | 48 | await request(app).post(link).expect(200); 49 | 50 | const res = await request(app) 51 | .post("/email/resend") 52 | .send({ email }) 53 | .expect(400); 54 | 55 | t.equal(res.body.message, "Email is incorrect or already verified"); 56 | }); 57 | -------------------------------------------------------------------------------- /api/test/email/verify.test.ts: -------------------------------------------------------------------------------- 1 | import t from "tap"; 2 | import request from "supertest"; 3 | import { app, fakeInbox } from "../setup"; 4 | import { confirmationUrl } from "../../src/routes"; 5 | 6 | t.test("/email/verify - happy path", async (t) => { 7 | const email = "jake@gmail.com"; 8 | 9 | const register = await request(app) 10 | .post("/register") 11 | .send({ email, password: "123456", name: "Jake" }) 12 | .expect(201); 13 | 14 | const [, link] = fakeInbox[email][0].message.html.match( 15 | // 16 | ); 17 | 18 | await request(app).post(link).expect(200); 19 | 20 | const cookie = register.headers["set-cookie"][0].split(/;/, 1)[0]; 21 | 22 | await request(app).get("/me/verified").set("Cookie", [cookie]).expect(200); 23 | }); 24 | 25 | t.test("/email/verify - missing params", async (t) => { 26 | const res = await request(app).post("/email/verify").expect(400); 27 | 28 | t.equal( 29 | res.body.validation.query.message, 30 | '"id" is required. "expires" is required. "signature" is required' 31 | ); 32 | }); 33 | 34 | t.test("/email/verify - tampered URL", async (t) => { 35 | const url = confirmationUrl(1).replace( 36 | /http:\/\/localhost(.+expires)=\d*(.+)/, 37 | "$1=1626737260105$2" // custom expiration timestamp 38 | ); 39 | 40 | const res = await request(app).post(url).expect(400); 41 | 42 | t.equal(res.body.message, "URL is invalid"); 43 | }); 44 | 45 | t.test("/email/verify - expired URL", async (t) => { 46 | const [, url] = confirmationUrl(1, 1626737676114).match( 47 | /http:\/\/localhost(.+)/ 48 | )!; 49 | 50 | const res = await request(app).post(url).expect(400); 51 | 52 | t.equal(res.body.message, "URL has expired"); 53 | }); 54 | 55 | t.test("/email/verify - invalid user ID (user doesn't exist)", async (t) => { 56 | const [, url] = confirmationUrl(999).match(/http:\/\/localhost(.+)/)!; 57 | 58 | const res = await request(app).post(url).expect(400); 59 | 60 | t.equal(res.body.message, "Email is incorrect or already verified"); 61 | }); 62 | 63 | t.test("/email/verify - already verified", async (t) => { 64 | const email = "gary@gmail.com"; 65 | 66 | await request(app) 67 | .post("/register") 68 | .send({ email, password: "123456", name: "Gary" }) 69 | .expect(201); 70 | 71 | const [, link] = fakeInbox[email][0].message.html.match( 72 | // 73 | ); 74 | 75 | await request(app).post(link).expect(200); 76 | 77 | const res = await request(app).post(link).expect(400); 78 | 79 | t.equal(res.body.message, "Email is incorrect or already verified"); 80 | }); 81 | -------------------------------------------------------------------------------- /api/test/middleware.test.ts: -------------------------------------------------------------------------------- 1 | import t from "tap"; 2 | import request from "supertest"; 3 | import { app } from "./setup"; 4 | 5 | t.test("/ - headers", async (t) => { 6 | const res = await request(app).get("/").expect(200); 7 | 8 | t.notOk(res.headers["x-powered-by"]); 9 | }); 10 | 11 | t.test("/bogus - not found", async (t) => { 12 | await request(app).get("/bogus").expect(404); 13 | }); 14 | 15 | t.test("/register - malformed JSON", async (t) => { 16 | await request(app) 17 | .post("/register") 18 | .set("Content-Type", "application/json") 19 | .send('{"email":"test@gmail.com}') 20 | .expect(400); 21 | }); 22 | 23 | t.test("/me - unauthenticated", async (t) => { 24 | await request(app).get("/me").expect(401); 25 | }); 26 | 27 | t.test("/me/verified - email unverified", async (t) => { 28 | await request(app).get("/me/verified").expect(401); 29 | 30 | const login = await request(app) 31 | .post("/login") 32 | .send({ email: "test@gmail.com", password: "test" }) 33 | .expect(200); 34 | 35 | const cookie = login.headers["set-cookie"][0].split(/;/, 1)[0]; 36 | 37 | await request(app).get("/me/verified").set("Cookie", [cookie]).expect(403); 38 | }); 39 | 40 | t.test("/me/verified - password unconfirmed", async (t) => { 41 | await request(app).get("/me/confirmed").expect(401); 42 | 43 | const login = await request(app) 44 | .post("/login") 45 | .send({ email: "test@gmail.com", password: "test" }) 46 | .expect(200); 47 | 48 | const cookie = login.headers["set-cookie"][0].split(/;/, 1)[0]; 49 | 50 | await request(app).get("/me/confirmed").set("Cookie", [cookie]).expect(403); 51 | }); 52 | -------------------------------------------------------------------------------- /api/test/password/confirm.test.ts: -------------------------------------------------------------------------------- 1 | import t from "tap"; 2 | import request from "supertest"; 3 | import { app } from "../setup"; 4 | 5 | t.test("/password/confirm - happy path", async (t) => { 6 | const password = "test"; 7 | 8 | const login = await request(app) 9 | .post("/login") 10 | .send({ email: "test@gmail.com", password }) 11 | .expect(200); 12 | 13 | const cookie = login.headers["set-cookie"][0].split(/;/, 1)[0]; 14 | 15 | await request(app) 16 | .post("/password/confirm") 17 | .set("Cookie", [cookie]) 18 | .send({ password }) 19 | .expect(200); 20 | 21 | await request(app).get("/me/confirmed").set("Cookie", [cookie]).expect(200); 22 | }); 23 | 24 | t.test("/password/confirm - guest (unauthorized)", async (t) => { 25 | const res = await request(app).post("/password/confirm").expect(401); 26 | 27 | t.equal(res.body.message, "Unauthorized"); 28 | }); 29 | 30 | t.test("/password/confirm - missing body", async (t) => { 31 | const login = await request(app) 32 | .post("/login") 33 | .send({ email: "test@gmail.com", password: "test" }) 34 | .expect(200); 35 | 36 | const cookie = login.headers["set-cookie"][0].split(/;/, 1)[0]; 37 | 38 | const res = await request(app) 39 | .post("/password/confirm") 40 | .set("Cookie", [cookie]) 41 | .expect(400); 42 | 43 | t.equal(res.body.validation.body.message, '"password" is required'); 44 | }); 45 | 46 | t.test("/password/confirm - incorrect password", async (t) => { 47 | const login = await request(app) 48 | .post("/login") 49 | .send({ email: "test@gmail.com", password: "test" }) 50 | .expect(200); 51 | 52 | const cookie = login.headers["set-cookie"][0].split(/;/, 1)[0]; 53 | 54 | const res = await request(app) 55 | .post("/password/confirm") 56 | .set("Cookie", [cookie]) 57 | .send({ password: "bogus" }) 58 | .expect(401); 59 | 60 | t.equal(res.body.message, "Password is incorrect"); 61 | }); 62 | -------------------------------------------------------------------------------- /api/test/password/email.test.ts: -------------------------------------------------------------------------------- 1 | import t from "tap"; 2 | import request from "supertest"; 3 | import { app, fakeInbox } from "../setup"; 4 | 5 | t.test("/password/email - happy path", async (t) => { 6 | const email = "mark@gmail.com"; 7 | 8 | await request(app) 9 | .post("/register") 10 | .send({ email, password: "123456", name: "Mark" }) 11 | .expect(201); 12 | 13 | await request(app).post("/password/email").send({ email }).expect(200); 14 | 15 | t.match( 16 | fakeInbox[email][1].message.html, 17 | /\/password\/reset\?id=\d{1,}&token=[a-z\d]{80}/ 18 | ); 19 | }); 20 | 21 | t.test("/password/email - already authenticated", async (t) => { 22 | const email = "test@gmail.com"; 23 | 24 | const login = await request(app) 25 | .post("/login") 26 | .send({ email, password: "test" }) 27 | .expect(200); 28 | const cookie = login.headers["set-cookie"][0].split(/;/, 1)[0]; 29 | 30 | const res = await request(app) 31 | .post("/password/email") 32 | .set("Cookie", [cookie]) 33 | .send({ email }) 34 | .expect(403); 35 | 36 | t.equal(res.body.message, "Forbidden"); 37 | }); 38 | 39 | t.test("/password/email - missing body", async (t) => { 40 | const res = await request(app).post("/password/email").expect(400); 41 | 42 | t.equal(res.body.validation.body.message, '"email" is required'); 43 | }); 44 | 45 | t.test("/password/email - invalid email", async (t) => { 46 | const res = await request(app) 47 | .post("/password/email") 48 | .send({ email: "bogus@gmail.com" }) 49 | .expect(400); 50 | 51 | t.equal(res.body.message, "Email does not exist"); 52 | }); 53 | 54 | t.test("/password/email - multiple tokens", async (t) => { 55 | const email = "rob@gmail.com"; 56 | 57 | await request(app) 58 | .post("/register") 59 | .send({ email, password: "123456", name: "Rob" }) 60 | .expect(201); 61 | 62 | await request(app).post("/password/email").send({ email }).expect(200); 63 | 64 | await request(app).post("/password/email").send({ email }).expect(200); 65 | 66 | t.match( 67 | fakeInbox[email][2].message.html, 68 | /\/password\/reset\?id=\d{1,}&token=[a-z\d]{80}/ 69 | ); 70 | }); 71 | -------------------------------------------------------------------------------- /api/test/password/reset.test.ts: -------------------------------------------------------------------------------- 1 | import t from "tap"; 2 | import request from "supertest"; 3 | import { randomBytes } from "crypto"; 4 | import { app, fakeInbox } from "../setup"; 5 | 6 | t.test("/password/reset - happy path", async (t) => { 7 | const email = "samuel@gmail.com"; 8 | 9 | await request(app) 10 | .post("/register") 11 | .send({ email, password: "123456", name: "Samuel" }) 12 | .expect(201); 13 | 14 | await request(app).post("/password/email").send({ email }).expect(200); 15 | 16 | const [, link] = fakeInbox[email][1].message.html.match( 17 | // 18 | ); 19 | 20 | const password = "789012"; 21 | 22 | await request(app).post(link).send({ password }).expect(200); 23 | 24 | await request(app).post("/login").send({ email, password }).expect(200); 25 | }); 26 | 27 | t.test("/password/reset - already authenticated", async (t) => { 28 | const email = "test@gmail.com"; 29 | 30 | const login = await request(app) 31 | .post("/login") 32 | .send({ email, password: "test" }) 33 | .expect(200); 34 | const cookie = login.headers["set-cookie"][0].split(/;/, 1)[0]; 35 | 36 | const res = await request(app) 37 | .post("/password/reset") 38 | .set("Cookie", [cookie]) 39 | .send({ email }) 40 | .expect(403); 41 | 42 | t.equal(res.body.message, "Forbidden"); 43 | }); 44 | 45 | t.test("/password/reset - missing body and params", async (t) => { 46 | const res = await request(app).post("/password/reset").expect(400); 47 | 48 | t.equal(res.body.validation.body.message, '"password" is required'); 49 | t.equal( 50 | res.body.validation.query.message, 51 | '"id" is required. "token" is required' 52 | ); 53 | }); 54 | 55 | t.test("/password/reset - invalid token", async (t) => { 56 | const res = await request(app) 57 | .post(`/password/reset?id=1&token=${randomBytes(40).toString("hex")}`) 58 | .send({ password: "test" }) 59 | .expect(401); 60 | 61 | t.equal(res.body.message, "Token or ID is invalid"); 62 | }); 63 | 64 | t.test("/password/reset - invalid user ID", async (t) => { 65 | const email = "francis@gmail.com"; 66 | 67 | await request(app) 68 | .post("/register") 69 | .send({ email, password: "123456", name: "Francis" }) 70 | .expect(201); 71 | 72 | await request(app).post("/password/email").send({ email }).expect(200); 73 | 74 | const [, link] = fakeInbox[email][1].message.html.match( 75 | // 76 | ); 77 | 78 | const res = await request(app) 79 | .post(link.replace(/id=\d{1,}/, "id=1")) 80 | .send({ password: "test" }) 81 | .expect(401); 82 | 83 | t.equal(res.body.message, "Token or ID is invalid"); 84 | }); 85 | 86 | t.test("/password/verify - token invalidation", async (t) => { 87 | const email = "tim@gmail.com"; 88 | 89 | await request(app) 90 | .post("/register") 91 | .send({ email, password: "123456", name: "Tim" }) 92 | .expect(201); 93 | 94 | await request(app).post("/password/email").send({ email }).expect(200); 95 | 96 | await request(app).post("/password/email").send({ email }).expect(200); 97 | 98 | const regex = //; 99 | const [, link1] = fakeInbox[email][1].message.html.match(regex); 100 | const [, link2] = fakeInbox[email][2].message.html.match(regex); 101 | 102 | const password = "789012"; 103 | 104 | await request(app).post(link1).send({ password }).expect(200); // older token still works 105 | 106 | const res = await request(app).post(link2).send({ password }).expect(401); 107 | 108 | t.equal(res.body.message, "Token or ID is invalid"); 109 | }); 110 | -------------------------------------------------------------------------------- /api/test/setup.ts: -------------------------------------------------------------------------------- 1 | import nodemailer from "nodemailer"; 2 | import { createApp } from "../src/app"; 3 | 4 | // https://nodemailer.com/transports/stream/ 5 | const mailer = nodemailer.createTransport({ 6 | jsonTransport: true, 7 | }); 8 | 9 | export const fakeInbox: RecordLorem ipsum dolor
23 |Lorem ipsum dolor