Makes `users.email` unique, de-duplicates the addresses an upgrade will find, and builds the self-service change-and-verify flow that did not exist. The uniqueness index is on a generated `email_norm AS (LOWER(email)) STORED` column under `utf8mb4_bin`, NOT on `email` under a `_ci` collation as the plan specified. Every case-insensitive collation this server offers is also accent-insensitive: `josé@x.com` and `jose@x.com` compare equal, and those are two different mailboxes. The plan's index would have refused the second address forever and the de-duplication would have nulled a legitimate account's. A requested address is STAGED in `email_pending` and only a tokened link installs it, so a typo cannot silently redirect account-recovery mail. `isDuplicateUsername()` now distinguishes the two indexes. All five call sites branch on it; each answers differently on purpose, because a public form, an IdP callback, a half-completed invite and an admin screen do not owe the same person the same amount of truth. SSO reads the IdP's actual `email_verified`/`verified` claim instead of inferring verification from an address merely being present. Co-Authored-By: Claude <noreply@anthropic.com>
71 lines
4.6 KiB
JavaScript
71 lines
4.6 KiB
JavaScript
// Auth · Password — self-service password reset. Public but token-gated: request
|
|
// a link by email, then validate the link and set a new password.
|
|
//
|
|
// Mounted at /api/v1/auth/password by auth/index.js, so the routes below emit
|
|
// POST /auth/password/forgot and GET|POST /auth/password/reset/:token.
|
|
//
|
|
// Two anti-enumeration properties are load-bearing and must survive any edit
|
|
// here: the request step always returns the same generic 200 whether or not the
|
|
// email matches an account, and the lookup step never distinguishes expired from
|
|
// used from never-existed. Both live in passwordReset.controller; the per-IP
|
|
// limiters below are what stop the endpoints being used as an oracle by volume.
|
|
//
|
|
// Changing a password while signed in is a different route —
|
|
// PATCH /auth/me/account/password.
|
|
|
|
const express = require('express')
|
|
const { body, param } = require('express-validator')
|
|
|
|
const { requestReset, lookupReset, confirmReset } = require('./passwordReset.controller')
|
|
const {
|
|
passwordResetRequestLimiter,
|
|
passwordResetConfirmLimiter,
|
|
} = require('../../../middleware/rateLimit')
|
|
const validate = require('../../../middleware/validate')
|
|
|
|
const passwordRouter = express.Router()
|
|
|
|
passwordRouter.post(
|
|
'/forgot',
|
|
// #swagger.tags = ['Auth']
|
|
// #swagger.summary = 'Request a password-reset link by email'
|
|
// #swagger.description = 'Emails a single-use, ~1h reset link to the active account on the address. Always returns the same generic 200 whether or not the email matches (no account enumeration). Rate limited per IP.'
|
|
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["email"], properties: { email: { type: "string", format: "email" } } } } } } */
|
|
/* #swagger.responses[200] = { description: 'Generic acknowledgement (sent if the account exists)', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */
|
|
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
|
/* #swagger.responses[429] = { description: 'Too many requests', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
passwordResetRequestLimiter,
|
|
body('email').isString().trim().isEmail().isLength({ max: 255 }),
|
|
validate,
|
|
requestReset,
|
|
)
|
|
passwordRouter.get(
|
|
'/reset/:token',
|
|
// #swagger.tags = ['Auth']
|
|
// #swagger.summary = 'Validate a password-reset link'
|
|
// #swagger.description = 'Returns the target username for a valid, pending, unexpired reset link so the reset form can render. 404 for anything not currently usable (never distinguishes expired from used from never-existed).'
|
|
/* #swagger.responses[200] = { description: 'Reset link is valid', content: { "application/json": { schema: { type: "object", properties: { username: { type: "string" } } } } } } */
|
|
/* #swagger.responses[404] = { description: 'Invalid or expired reset link', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
param('token').isString().isLength({ min: 8, max: 128 }),
|
|
validate,
|
|
lookupReset,
|
|
)
|
|
passwordRouter.post(
|
|
'/reset/:token',
|
|
// #swagger.tags = ['Auth']
|
|
// #swagger.summary = 'Set a new password from a reset link'
|
|
// #swagger.description = 'Consumes the single-use link and sets the new password. Rotates the hash and revokes every existing session (web + mobile). Does NOT sign the user in — they log in fresh afterwards (so a 2FA account still passes TOTP). Rate limited per IP.'
|
|
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["password"], properties: { password: { type: "string", minLength: 8, maxLength: 64 } } } } } } */
|
|
/* #swagger.responses[200] = { description: 'Password changed', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */
|
|
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
|
/* #swagger.responses[404] = { description: 'Invalid, expired, or already-used reset link', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
/* #swagger.responses[429] = { description: 'Too many attempts', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
passwordResetConfirmLimiter,
|
|
param('token').isString().isLength({ min: 8, max: 128 }),
|
|
body('password').isString().isLength({ min: 8, max: 64 }),
|
|
validate,
|
|
confirmReset,
|
|
)
|
|
|
|
module.exports = passwordRouter
|