feat(auth): add email-based password reset with MFA + session invalidation

Adds /auth/forgot-password and /auth/reset-password endpoints plus two new
client pages. When SMTP is configured the user receives a branded, i18n-aware
reset email; when it isn't the reset link is logged to the server console in
a clearly-fenced block so self-hosters can relay it manually.

Security properties:
- 256-bit cryptographically-random tokens, only SHA-256 hashes stored in DB
- 60 min expiry, single-use, prior unconsumed tokens auto-invalidated
- Enumeration-safe: /forgot-password always responds {ok:true} with a minimum
  latency pad so timing doesn't leak account existence
- Per-IP rate limit (3/15min on forgot, 5/15min on reset) + per-email throttle
- If the user has MFA enabled, a valid TOTP or backup code is required at
  reset-complete time — a compromised mailbox alone cannot take over a
  2FA-protected account
- New users.password_version column + JWT "pv" claim: bumping it on reset
  invalidates every live session immediately
- Full audit-log coverage (user.password_reset_request/_success/_fail)
- Forgot-page shows a visible hint when SMTP is unconfigured

Migration 115 adds users.password_version and password_reset_tokens
(user_id, token_hash UNIQUE, expires_at, consumed_at, created_ip).
This commit is contained in:
Maurice
2026-04-20 14:06:42 +02:00
parent 2ab8b401fb
commit 51387b0af1
26 changed files with 1140 additions and 17 deletions
+16 -14
View File
@@ -15,11 +15,21 @@ export function extractToken(req: Request): string | null {
function verifyJwtAndLoadUser(token: string): User | null {
try {
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number };
const user = db.prepare(
'SELECT id, username, email, role FROM users WHERE id = ?'
).get(decoded.id) as User | undefined;
return user ?? null;
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number; pv?: number };
const row = db.prepare(
'SELECT id, username, email, role, password_version FROM users WHERE id = ?'
).get(decoded.id) as (User & { password_version?: number }) | undefined;
if (!row) return null;
// Session invalidation: any token whose embedded password_version
// predates the user's current one is rejected. Tokens issued before
// the `pv` claim existed (decoded.pv === undefined) are treated as
// version 0 so legacy sessions keep working until the user resets.
const tokenPv = typeof decoded.pv === 'number' ? decoded.pv : 0;
const currentPv = typeof row.password_version === 'number' ? row.password_version : 0;
if (tokenPv !== currentPv) return null;
// Don't leak password_version beyond the middleware.
const { password_version: _pv, ...user } = row;
return user as User;
} catch {
return null;
}
@@ -68,15 +78,7 @@ const optionalAuth = (req: Request, res: Response, next: NextFunction): void =>
return next();
}
try {
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number };
const user = db.prepare(
'SELECT id, username, email, role FROM users WHERE id = ?'
).get(decoded.id) as User | undefined;
(req as OptionalAuthRequest).user = user || null;
} catch (err: unknown) {
(req as OptionalAuthRequest).user = null;
}
(req as OptionalAuthRequest).user = verifyJwtAndLoadUser(token) || null;
next();
};