├── .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: Record = {}; 10 | 11 | const sendMail = mailer.sendMail.bind(mailer); 12 | mailer.sendMail = async (opts) => { 13 | const info = await sendMail(opts); 14 | info.message = JSON.parse(info.message); // String => JSON 15 | 16 | const key = String(opts.to); 17 | if (!fakeInbox[key]) { 18 | fakeInbox[key] = []; 19 | } 20 | fakeInbox[key].push(info); 21 | 22 | return info; 23 | }; 24 | 25 | export const app = createApp(mailer); 26 | -------------------------------------------------------------------------------- /api/test/utils.test.ts: -------------------------------------------------------------------------------- 1 | import t from "tap"; 2 | import { safeEqual, compress } from "../src/utils"; 3 | 4 | t.test("safeEqual() - happy path", async (t) => { 5 | t.ok(safeEqual("some_string", "some_string")); 6 | }); 7 | 8 | t.test("safeEqual() - unequal strings, same length", async (t) => { 9 | t.notOk(safeEqual("some_string", "some_abcxyz")); 10 | }); 11 | 12 | t.test("safeEqual() - unequal strings, variable length", async (t) => { 13 | t.notOk(safeEqual("some_string", "some_other_string")); 14 | }); 15 | 16 | // 17 | 18 | t.test("compress() - happy path", async (t) => { 19 | const unminified = ` 20 |
21 |

Title

22 |

Lorem ipsum dolor

23 |
24 | `; 25 | const minified = `

Title

Lorem ipsum dolor

`; 26 | 27 | t.equal(compress(unminified), minified); 28 | }); 29 | -------------------------------------------------------------------------------- /api/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "./src" 5 | }, 6 | "exclude": ["test"] 7 | } 8 | -------------------------------------------------------------------------------- /api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Basic Options */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | "target": "ESNEXT" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */, 8 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, 9 | // "lib": [], /* Specify library files to be included in the compilation. */ 10 | // "allowJs": true, /* Allow javascript files to be compiled. */ 11 | // "checkJs": true, /* Report errors in .js files. */ 12 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */ 13 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 14 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 15 | "sourceMap": true /* Generates corresponding '.map' file. */, 16 | // "outFile": "./" /* Concatenate and emit output to single file. */, 17 | "outDir": "./dist" /* Redirect output structure to the directory. */, 18 | // "rootDir": "./" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */, 19 | // "composite": true, /* Enable project compilation */ 20 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 21 | "removeComments": true /* Do not emit comments to output. */, 22 | // "noEmit": true, /* Do not emit outputs. */ 23 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 24 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 25 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 26 | 27 | /* Strict Type-Checking Options */ 28 | "strict": true /* Enable all strict type-checking options. */, 29 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 30 | // "strictNullChecks": true, /* Enable strict null checks. */ 31 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 32 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 33 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 34 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 35 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 36 | 37 | /* Additional Checks */ 38 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 39 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 40 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 41 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 42 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 43 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an 'override' modifier. */ 44 | // "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */ 45 | 46 | /* Module Resolution Options */ 47 | "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */, 48 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 49 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 50 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 51 | // "typeRoots": [], /* List of folders to include type definitions from. */ 52 | // "types": [], /* Type declaration files to be included in compilation. */ 53 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 54 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 55 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 56 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 57 | 58 | /* Source Map Options */ 59 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 60 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 61 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 62 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 63 | 64 | /* Experimental Options */ 65 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 66 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 67 | 68 | /* Advanced Options */ 69 | "skipLibCheck": true /* Skip type checking of declaration files. */, 70 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 71 | }, 72 | // ts-node doesn't seem to load .d.ts files https://stackoverflow.com/q/51610583 73 | "ts-node": { 74 | "files": true 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # node-auth 2 | 3 | Authentication boilerplate for Node.js. 4 | 5 | ## Background 6 | 7 | Originally inspired by [Your Node.js authentication tutorial is (probably) wrong](https://medium.com/hackernoon/your-node-js-authentication-tutorial-is-wrong-f1a3bf831a46). Although its critique is on point, the article sadly doesn't offer any concrete solutions. This repo is my attempt to address those remarks in code. You can also find some of my research [here](https://github.com/alex996/presentations/blob/master/node-auth.md). 8 | 9 | ## Features 10 | 11 | - login, logout, and registration 12 | - email verification (`"Confirm your email"`) 13 | - password reset (`"Forgot password"`) 14 | - password confirmation (`"Re-enter your password"`) 15 | - persistent login (`"Remember me"`) 16 | - account lockout (`"Too many failed login attempts"`) 17 | - rate limiting (`"Too many requests"`) 18 | 19 | ## Development 20 | 21 | First, create a `.env` file: 22 | 23 | ```sh 24 | cd api 25 | cp .env.example .env 26 | ``` 27 | 28 | Next, populate `APP_SECRET`, which you can generate like so: 29 | 30 | ```js 31 | $ node 32 | > require('crypto').randomBytes(32).toString('base64') 33 | ``` 34 | 35 | Make sure to also populate `MAIL_*` variables. For example, you can sign up for a free service like [Mailtrap](https://mailtrap.io/) or [Ethereal](https://ethereal.email/). Using Ethereal you can even create an account right from your terminal: 36 | 37 | ```js 38 | $ node 39 | > require('nodemailer').createTestAccount().then(console.log) 40 | ``` 41 | 42 | Finally, boot the server: 43 | 44 | ```sh 45 | npm run dev 46 | ``` 47 | 48 | ## Integration tests 49 | 50 | > Tip: just read the first test in each file to follow the happy path. Other tests cover non-standard scenarios. 51 | 52 | ```sh 53 | npm test 54 | ``` 55 | 56 | ## API 57 | 58 | | Method | URI | Middleware | 59 | | ------ | ----------------- | ---------- | 60 | | POST | /register | guest | 61 | | POST | /login | | 62 | | POST | /logout | auth | 63 | | POST | /email/verify | | 64 | | POST | /email/resend | | 65 | | POST | /password/email | | 66 | | POST | /password/reset | | 67 | | POST | /password/confirm | auth | 68 | 69 | ## curl 70 | 71 | > Tip: run `echo '-w "\n"' >> ~/.curlrc` to [auto-add a newline](https://stackoverflow.com/a/14614203) to response body. 72 | 73 | ```sh 74 | # Auth 75 | 76 | curl -d '{"email":"test@gmail.com","password":"test"}' -H 'Content-Type: application/json' \ 77 | localhost:3000/login 78 | 79 | curl -X POST \ 80 | -b 'sid=s%3AT_Pkrw6AvSQ3LfOYC9q0EnE1uqWQhJbp.hTs%2BqXXHbFMn2dxgSKBWd%2F%2FEQ8xwnV3KKsA9IwVJ7nU' \ 81 | localhost:3000/logout 82 | 83 | curl -d '{"email":"alex@gmail.com","password":"test","name":"Alex"}' \ 84 | -H 'Content-Type: application/json' localhost:3000/register 85 | 86 | # Email verification 87 | 88 | curl -X POST \ 89 | 'localhost:3000/email/verify?id=2&expires=1626766452957&signature=ddd8e0451ef93172b5345e0f7d1e8a5e85b69bca2b2aeed80a14848d0d2fb2df' 90 | 91 | curl -d '{"email":"alex@gmail.com"}' -H 'Content-Type: application/json' localhost:3000/email/resend 92 | 93 | # Password recovery 94 | 95 | curl -d '{"email":"alex@gmail.com"}' -H 'Content-Type: application/json' localhost:3000/password/email 96 | 97 | curl -d '{"password":"123456"}' -H 'Content-Type: application/json' \ 98 | 'localhost:3000/password/reset?id=2&token=78f3065ed0b350b1ee1ea80162c2e2f4908fb5bc9d42c22e08202d2e79a0d180a59a86bf9885a002' 99 | 100 | # Password confirmation 101 | 102 | curl -d '{"password":"test"}' \ 103 | -b 'sid=s%3AT_Pkrw6AvSQ3LfOYC9q0EnE1uqWQhJbp.hTs%2BqXXHbFMn2dxgSKBWd%2F%2FEQ8xwnV3KKsA9IwVJ7nU' \ 104 | localhost:3000/password/confirm 105 | ``` 106 | 107 | ## Disclaimer 108 | 109 | I am not a security expert. There is only so much I know, so there are likely things I missed. If you see something that doesn't make sense, poses a vulnerability, or otherwise needs improvement, please feel free to open an issue or submit a PR. All contributions are welcome! 110 | --------------------------------------------------------------------------------