From b7d55595af76a7066de6071ee32652cdeb701f04 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Mon, 27 Jul 2026 11:19:39 -0500 Subject: [PATCH] feat(auth): self-service password reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Until now the only ways back into an account were an admin setting your password for you or shell access to run scripts/reset-admin.js. A self-hosted operator who forgot their password had no path at all, and the admin-reset route explicitly refuses to reset a platform admin's password — so a single-admin instance was unrecoverable without a shell. The per-account login lockout added recently makes that sharper: a user who forgets their password will hit the lockout and see the same generic error, with no way out. Two unauthenticated endpoints (they must be — the user cannot log in): POST /api/auth/forgot-password { email } -> always the same 200 POST /api/auth/reset-password { token, password } -> 200 / 400 The properties that matter, each covered by a test: - NO ENUMERATION. The request endpoint answers identically — same status, same body — for a real address, an unknown one, an SSO identity with no local password, and a malformed string. The frontend shows the same confirmation even on a network error, so the client cannot leak what the server refused to. - NO MFA BYPASS. Completing a reset does NOT issue a session; the user signs in afterwards, so a TOTP-enabled account still clears its second factor. Returning a token here would turn "read one email" into a full session without the second factor. - SINGLE USE, SHORT LIVED. 32 random bytes, stored only as a SHA-256 hash (same discipline as email verification, recovery codes and API tokens), 1h TTL, and the redeeming UPDATE is conditioned on the hash still being present so concurrent redemptions cannot both win. - LOCAL ACCOUNTS ONLY. SSO identities have no local password; no token is minted. - IT ACTUALLY UNBLOCKS YOU. A completed reset clears the per-account login lockout and must_change_password, otherwise someone who locked themselves out would reset and still be locked out. Rate limited: 5/min on the request (it sends mail to a caller-supplied address), 10/min on the redeem. If no email transport is configured the response is unchanged — no oracle — but the server logs loudly, because the user will otherwise wait for mail that cannot arrive and the generic response cannot tell them. Frontend: a "Forgot your password?" link on the sign-in card, a request card, and a new-password card. app.js had to learn #/reset-password explicitly — the auth guard rewrites any unauthenticated hash to #/login, which would have discarded the one-time token in the emailed link and made it silently do nothing. Migration adds users.password_reset_hash / password_reset_expires: additive, nullable, idempotent; a code-only rollback leaves two dead columns. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/js/app.js | 14 ++- frontend/js/i18n/en.js | 8 ++ frontend/js/views/login.js | 96 ++++++++++++++ server/db/database.js | 6 + server/lib/passwordReset.js | 48 +++++++ server/routes/auth.js | 57 ++++++++- server/server.js | 5 + server/services/signupEmails.js | 27 +++- server/test/password-reset.test.js | 195 +++++++++++++++++++++++++++++ 9 files changed, 450 insertions(+), 6 deletions(-) create mode 100644 server/lib/passwordReset.js create mode 100644 server/test/password-reset.test.js diff --git a/frontend/js/app.js b/frontend/js/app.js index 4593fc7..966386b 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -275,14 +275,20 @@ function route() { } } + // Password-reset links arrive from email on a browser that is by definition NOT logged + // in, and carry a one-time token in the hash. This must be handled BEFORE the redirect + // below: rewriting the hash would discard the token and the emailed link would silently + // do nothing. The login view reads the token off the hash and shows the new-password form. + const isResetRoute = hash.startsWith('#/reset-password'); + // Auth check - redirect to login if not authenticated - if (!isAuthenticated() && hash !== '#/login') { + if (!isAuthenticated() && hash !== '#/login' && !isResetRoute) { window.location.hash = '#/login'; return; } // If authenticated and on login page, redirect to dashboard or onboarding - if (isAuthenticated() && hash === '#/login') { + if (isAuthenticated() && (hash === '#/login' || isResetRoute)) { window.location.hash = localStorage.getItem('rd_onboarded') ? '#/' : '#/onboarding'; return; } @@ -359,8 +365,8 @@ function route() { return; } - // Login page - hide sidebar - if (hash === '#/login') { + // Login page (and password-reset links from email) - hide sidebar + if (hash === '#/login' || isResetRoute) { sidebar.style.display = 'none'; app.style.marginLeft = '0'; const mb = document.getElementById('mobileMenuBtn'); diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index 39f03b2..61115b0 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -78,6 +78,14 @@ export default { 'auth.verify_title': 'Confirm your email', 'auth.verify_body': "We've sent a verification link to", 'auth.verify_resend': 'Resend the email', + 'auth.forgot_password': 'Forgot your password?', + 'auth.forgot_send': 'Send reset link', + 'auth.forgot_sent': 'If that address has an account, a reset link is on its way. Check your inbox.', + 'auth.back_to_signin': 'Back to sign in', + 'auth.new_password': 'New password', + 'auth.reset_submit': 'Set new password', + 'auth.reset_done': 'Password updated — sign in with your new password.', + 'auth.reset_failed': 'That reset link is invalid or has expired. Request a new one.', 'auth.verify_resent': "If that address needs confirming, we've sent a new link.", 'auth.verify_resend_failed': "Couldn't resend right now — try again in a moment.", 'auth.verify_ok': 'Email confirmed. You can sign in now.', diff --git a/frontend/js/views/login.js b/frontend/js/views/login.js index 88fc3c5..cdaf376 100644 --- a/frontend/js/views/login.js +++ b/frontend/js/views/login.js @@ -89,6 +89,11 @@ export async function render(container) { + ${!isSetup ? ` +

+ ${t('auth.forgot_password')} +

+ ` : ''} ${!isSetup && canRegister ? ` + + + +

${t('auth.terms')} @@ -274,6 +296,80 @@ function setupHandlers(config, isSetup) { } // "Check your email" panel shown when signup/login returns verification_required (hosted). + // ---- Self-service password reset ------------------------------------------------- + // Two cards swapped into the same login shell. The request step ALWAYS shows the same + // confirmation regardless of the server's answer, matching the server's deliberate + // refusal to reveal whether an address exists. + function showCard(id) { + ['localAuthForm', 'registerForm', 'mfaForm', 'ssoBlock', 'forgotForm', 'resetForm'].forEach((x) => { + const el = document.getElementById(x); if (el) el.style.display = (x === id ? 'block' : 'none'); + }); + const errEl = document.getElementById('loginError'); if (errEl) errEl.style.display = 'none'; + } + + const forgotLink = document.getElementById('forgotLink'); + if (forgotLink) forgotLink.addEventListener('click', (e) => { + e.preventDefault(); + showCard('forgotForm'); + const src = document.getElementById('loginEmail'); + const dst = document.getElementById('forgotEmail'); + if (src && dst) dst.value = src.value; // carry over whatever they already typed + }); + + const forgotBackBtn = document.getElementById('forgotBackBtn'); + if (forgotBackBtn) forgotBackBtn.addEventListener('click', () => showCard('localAuthForm')); + + const forgotSendBtn = document.getElementById('forgotSendBtn'); + if (forgotSendBtn) forgotSendBtn.addEventListener('click', async () => { + const email = (document.getElementById('forgotEmail').value || '').trim(); + forgotSendBtn.disabled = true; + try { + await fetch('/api/auth/forgot-password', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }), + }); + } catch (e) { /* deliberately ignored — see below */ } + // Same confirmation either way. Surfacing a network/server error here would leak + // whether the address matched, undoing the server-side enumeration resistance. + document.getElementById('forgotNotice').style.display = 'block'; + forgotSendBtn.disabled = false; + }); + + // A link from the reset email: #/reset-password?token=... + function resetTokenFromHash() { + const h = window.location.hash || ''; + const q = h.indexOf('?'); + if (!h.startsWith('#/reset-password') || q < 0) return null; + return new URLSearchParams(h.slice(q + 1)).get('token'); + } + + const pendingResetToken = resetTokenFromHash(); + if (pendingResetToken) showCard('resetForm'); + + const resetBackBtn = document.getElementById('resetBackBtn'); + if (resetBackBtn) resetBackBtn.addEventListener('click', () => { window.location.hash = '#/login'; window.location.reload(); }); + + const resetSubmitBtn = document.getElementById('resetSubmitBtn'); + if (resetSubmitBtn) resetSubmitBtn.addEventListener('click', async () => { + const password = document.getElementById('resetPassword').value || ''; + resetSubmitBtn.disabled = true; + try { + const res = await fetch('/api/auth/reset-password', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: pendingResetToken, password }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { showError(data.error || t('auth.reset_failed')); resetSubmitBtn.disabled = false; return; } + // No session is issued by design, so send them through a normal sign-in — which is + // what keeps TOTP in the loop for accounts that have it. + showToast(t('auth.reset_done'), 'success'); + window.location.hash = '#/login'; + window.location.reload(); + } catch (e) { + showError(t('auth.reset_failed')); + resetSubmitBtn.disabled = false; + } + }); + function showVerifyNotice(email) { // The server refused a session — make sure no stale token from a prior login lingers, // else the router would treat this browser as authenticated and bounce it into the app. diff --git a/server/db/database.js b/server/db/database.js index f5a28f1..d09abc5 100644 --- a/server/db/database.js +++ b/server/db/database.js @@ -389,6 +389,12 @@ const migrations = [ "ALTER TABLE content ADD COLUMN captions_lang TEXT", "ALTER TABLE content ADD COLUMN subtitle_url TEXT", "ALTER TABLE content ADD COLUMN subtitle_lang TEXT", + // Self-service password reset. Mirrors the email-verification columns: the emailed token + // is stored ONLY as a SHA-256 hash (single-use), with its own expiry, and one pending + // token per user so a re-request simply overwrites the previous one. Nullable and + // additive — existing rows are unaffected and a code-only rollback leaves dead columns. + "ALTER TABLE users ADD COLUMN password_reset_hash TEXT", + "ALTER TABLE users ADD COLUMN password_reset_expires INTEGER", // AUTH-05: make break-glass recovery revocable, single-use and auditable. // // scripts/reset-admin.js mints a JWT carrying `recovery: true`, which middleware/auth.js diff --git a/server/lib/passwordReset.js b/server/lib/passwordReset.js new file mode 100644 index 0000000..6b8e11f --- /dev/null +++ b/server/lib/passwordReset.js @@ -0,0 +1,48 @@ +'use strict'; + +// Self-service password-reset tokens. Deliberately the same shape as lib/emailVerify.js: +// the emailed token is random, stored ONLY as a SHA-256 hash (single-use, same discipline +// as recovery codes and api tokens), with the plaintext living just in the email link. One +// pending token per user on the users row, so re-requesting overwrites the previous one. +// +// TTL is much shorter than email verification's 24h: this token changes a credential, so +// the window in which a leaked link is useful should be small. + +const crypto = require('crypto'); +const bcrypt = require('bcryptjs'); +const { db } = require('../db/database'); +const { hashToken } = require('../middleware/apiToken'); + +const TTL_SEC = 60 * 60; // 1 hour +const MIN_PASSWORD_LENGTH = 8; // same minimum as registration and PUT /api/auth/me + +// Mint a token for a user, store its hash + expiry, return the PLAINTEXT (emailed once). +function issue(userId) { + const token = crypto.randomBytes(32).toString('hex'); + const expires = Math.floor(Date.now() / 1000) + TTL_SEC; + db.prepare('UPDATE users SET password_reset_hash = ?, password_reset_expires = ? WHERE id = ?') + .run(hashToken(token), expires, userId); + return token; +} + +// Consume a token and set the new password. Returns the user id on success, else null +// (unknown / expired / already used). Single-use: the hash is cleared in the same statement +// that sets the password, and that UPDATE is conditioned on the hash still being present, +// so two concurrent redemptions cannot both win. +// +// Clears must_change_password too — the user has just chosen a password, which is exactly +// what that flag was demanding. +function consume(token, newPassword) { + if (!token || typeof token !== 'string') return null; + if (!newPassword || newPassword.length < MIN_PASSWORD_LENGTH) return null; + const hash = hashToken(token); + const row = db.prepare('SELECT id, password_reset_expires FROM users WHERE password_reset_hash = ?').get(hash); + if (!row) return null; + if (!row.password_reset_expires || row.password_reset_expires < Math.floor(Date.now() / 1000)) return null; + const res = db.prepare(`UPDATE users SET password_hash = ?, password_reset_hash = NULL, + password_reset_expires = NULL, must_change_password = 0, updated_at = strftime('%s','now') + WHERE id = ? AND password_reset_hash = ?`).run(bcrypt.hashSync(newPassword, 10), row.id, hash); + return res.changes === 1 ? row.id : null; +} + +module.exports = { issue, consume, TTL_SEC, MIN_PASSWORD_LENGTH }; diff --git a/server/routes/auth.js b/server/routes/auth.js index 8d028fb..49b1b45 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -12,7 +12,8 @@ const totp = require('../lib/totp'); const totpLockout = require('../lib/totp-lockout'); const loginLockout = require('../lib/login-lockout'); const QRCode = require('qrcode'); -const { sendSignupEmails, sendVerificationEmail } = require('../services/signupEmails'); +const { sendSignupEmails, sendVerificationEmail, sendPasswordResetEmail } = require('../services/signupEmails'); +const passwordReset = require('../lib/passwordReset'); const emailVerify = require('../lib/emailVerify'); const emailSvc = require('../services/email'); const { deleteUserCascade, OrgHasOtherMembersError } = require('../lib/user-deletion'); @@ -263,6 +264,60 @@ router.post('/resend-verification', (req, res) => { res.json({ ok: true }); }); +// ==================== Self-service password reset ==================== +// Two endpoints, both unauthenticated by necessity (the user cannot log in). +// +// The request endpoint ALWAYS answers the same way — same status, same body — whether the +// address exists, is an SSO identity with no local password, or is malformed. Anything +// else turns it into an account-existence oracle, which is the classic mistake here. +// +// Completing a reset deliberately does NOT return a session. The user logs in afterwards, +// so a TOTP-enabled account still has to clear its second factor; issuing a token here +// would turn "read one email" into a full session and quietly bypass MFA. +const RESET_GENERIC_OK = { ok: true, message: 'If that address has an account, a reset link is on its way.' }; + +router.post('/forgot-password', (req, res) => { + const email = String(req.body?.email || '').toLowerCase().trim(); + // Respond identically no matter what happens below. + try { + if (email) { + const user = db.prepare("SELECT * FROM users WHERE email = ? AND auth_provider = 'local'").get(email); + if (user) { + if (!emailSvc.isConfigured()) { + // Loud, because the user will wait for an email that can never arrive and the + // generic response cannot tell them. + console.error(`[password-reset] NO EMAIL TRANSPORT CONFIGURED — reset requested for ${email} cannot be delivered.`); + } else { + const token = passwordReset.issue(user.id); + sendPasswordResetEmail(user, token, req).catch(e => + console.error('[password-reset] send failed:', e && e.message)); + logActivity(user.id, 'auth:password_reset_requested', null, null, getClientIp(req)); + } + } + } + } catch (e) { + console.error('[password-reset] request error:', e && e.message); + } + return res.json(RESET_GENERIC_OK); +}); + +router.post('/reset-password', (req, res) => { + const { token, password } = req.body || {}; + if (!password || String(password).length < passwordReset.MIN_PASSWORD_LENGTH) { + return res.status(400).json({ error: `Password must be at least ${passwordReset.MIN_PASSWORD_LENGTH} characters` }); + } + const userId = passwordReset.consume(token, String(password)); + if (!userId) return res.status(400).json({ error: 'This reset link is invalid or has expired. Request a new one.' }); + // Someone who locked themselves out guessing must not stay locked out after proving + // control of the mailbox and choosing a new password. + loginLockout.reset(userId); + const u = db.prepare('SELECT email FROM users WHERE id = ?').get(userId); + logActivity(userId, 'auth:password_reset_completed', null, null, getClientIp(req)); + console.log(`[password-reset] password changed for ${u ? u.email : userId}`); + // No session on purpose — see above. + return res.json({ ok: true, message: 'Password updated. You can now sign in.' }); +}); + // ==================== TOTP MFA (#100) ==================== // Opt-in per-user, LOCAL accounts only (SSO IdPs own MFA). Enrollment is a two-step // confirm (setup -> enable) so a mistyped secret can't lock anyone out. Recovery diff --git a/server/server.js b/server/server.js index da91f26..c57eeba 100644 --- a/server/server.js +++ b/server/server.js @@ -362,6 +362,11 @@ app.use('/api/auth/register', rateLimit(60000, 5)); // 5 registrations per minut app.use('/api/auth/totp/verify', rateLimit(60000, 10)); // Email-verification resend: cap so it can't be used to spray mail at an address. app.use('/api/auth/resend-verification', rateLimit(60000, 5)); +// Self-service password reset. The request endpoint is the spray surface (it sends mail to +// an address the caller supplies), so it gets the tighter cap; the redeem endpoint is a +// 32-byte-token guess, capped mostly to keep the bcrypt work bounded. +app.use('/api/auth/forgot-password', rateLimit(60000, 5)); +app.use('/api/auth/reset-password', rateLimit(60000, 10)); // Admin password-reset endpoint: even if an admin's session is compromised, // cap the blast radius to 20 resets/min/IP. Express matches the longest // path prefix first, so this fires before /api/auth catches the request. diff --git a/server/services/signupEmails.js b/server/services/signupEmails.js index a3eea96..00ed519 100644 --- a/server/services/signupEmails.js +++ b/server/services/signupEmails.js @@ -212,4 +212,29 @@ async function sendVerificationEmail(user, token, req) { return sendEmail({ to: user.email, subject: 'Verify your email for ScreenTinker', text, html }); } -module.exports = { sendSignupEmails, sendVerificationEmail }; +function escapeHtml(s) { return String(s == null ? '' : s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } + +async function sendPasswordResetEmail(user, token, req) { + // Same public-origin resolution as verification/invites. The link lands on the SPA, + // which posts the token back to /api/auth/reset-password with the new password — the + // token is never redeemed by a bare GET, so a link-prefetching mail client cannot + // consume it. + const base = process.env.APP_URL || `${req.protocol}://${req.get('host')}`; + const url = `${base}/app#/reset-password?token=${encodeURIComponent(token)}`; + const who = user.name || user.email; + const text = `Hi ${who}, + +Someone asked to reset the password for your ScreenTinker account. + +Open this link to choose a new password (valid for 1 hour, and usable once): +${url} + +If this wasn't you, you can ignore this email — your password has not changed.`; + const html = `

Hi ${escapeHtml(who)},

+

Someone asked to reset the password for your ScreenTinker account.

+

Choose a new password — the link is valid for 1 hour and can be used once.

+

If this wasn't you, you can ignore this email — your password has not changed.

`; + return sendEmail({ to: user.email, subject: 'Reset your ScreenTinker password', text, html }); +} + +module.exports = { sendSignupEmails, sendVerificationEmail, sendPasswordResetEmail }; diff --git a/server/test/password-reset.test.js b/server/test/password-reset.test.js new file mode 100644 index 0000000..9d6c429 --- /dev/null +++ b/server/test/password-reset.test.js @@ -0,0 +1,195 @@ +'use strict'; + +// Self-service password reset. Until now the only ways back into an account were an admin +// setting your password for you, or shell access to run scripts/reset-admin.js — so a +// self-hosted operator who forgot their password had no path at all. +// +// The security-relevant properties, each pinned below: +// +// - NO ENUMERATION. The request endpoint answers identically whether or not the address +// exists, and whether or not it is an SSO account with no password to reset. +// - NO MFA BYPASS. Completing a reset does NOT issue a session. The user logs in +// afterwards, so a TOTP-enabled account still has to pass its second factor. A reset +// that returned a token would be a way to turn "I read one email" into a full session +// without the second factor. +// - SINGLE USE, SHORT LIVED. The token is stored only as a hash, works once, and expires. +// - LOCAL ACCOUNTS ONLY. SSO identities have no local password. +// - IT ACTUALLY UNBLOCKS YOU. A reset clears the per-account login lockout, otherwise +// someone who locked themselves out by guessing would reset and still be locked out. + +const { test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const { spawn } = require('node:child_process'); +const path = require('node:path'); +const os = require('node:os'); +const fs = require('node:fs'); +const crypto = require('node:crypto'); +const Database = require('better-sqlite3'); + +const { freePort } = require('./helpers/free-port'); +let PORT, BASE, proc, db; +const DATA_DIR = path.join(os.tmpdir(), 'st-pwreset-' + crypto.randomBytes(4).toString('hex')); +const LOG = path.join(os.tmpdir(), 'st-pwreset-' + crypto.randomBytes(4).toString('hex') + '.log'); +const PW = 'Passw0rd123'; +const NEW_PW = 'BrandNewPw456'; +const S = {}; + +const jfetch = async (p, opts = {}) => { + const res = await fetch(BASE + p, opts); + let body = null; try { body = await res.json(); } catch { /* */ } + return { status: res.status, body }; +}; +const post = (obj) => ({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(obj) }); + +const forgot = (email) => jfetch('/api/auth/forgot-password', post({ email })); +const reset = (token, password) => jfetch('/api/auth/reset-password', post({ token, password })); +const login = (email, password) => jfetch('/api/auth/login', post({ email, password })); + +// The emailed token is only ever stored as a hash, so a test reads the plaintext the way +// the user would: it cannot. Instead we mint through the same lib the route uses. +const issueTokenFor = (userId) => require('../lib/passwordReset').issue(userId); + +async function register(email) { + const r = await jfetch('/api/auth/register', post({ email, password: PW })); + return r.body; +} + +before(async () => { + PORT = await freePort(); + BASE = `http://127.0.0.1:${PORT}`; + const logFd = fs.openSync(LOG, 'w'); + proc = spawn('node', ['server.js'], { + cwd: path.join(__dirname, '..'), + env: { ...process.env, DATA_DIR, SELF_HOSTED: 'true', PORT: String(PORT), NODE_ENV: 'test' }, + stdio: ['ignore', logFd, logFd], + }); + let up = false; + for (let i = 0; i < 80; i++) { + try { const r = await fetch(BASE + '/api/status'); if (r.ok) { up = true; break; } } catch { /* */ } + await new Promise(r => setTimeout(r, 250)); + } + if (!up) throw new Error('server did not boot:\n' + fs.readFileSync(LOG, 'utf8').slice(-2000)); + process.env.DATA_DIR = DATA_DIR; // so the lib below opens the same DB the server uses + db = new Database(path.join(DATA_DIR, 'db', 'remote_display.db')); + + S.admin = await register('admin' + crypto.randomBytes(4).toString('hex') + '@x.local'); + S.email = 'u' + crypto.randomBytes(5).toString('hex') + '@x.local'; + S.user = await register(S.email); +}); +after(() => { try { db && db.close(); } catch { /* */ } try { proc.kill('SIGKILL'); } catch { /* */ } }); + +// --------------------------------------------------------------------------- +// Enumeration resistance +// --------------------------------------------------------------------------- +test('the request endpoint cannot be used to discover which addresses exist', async () => { + const real = await forgot(S.email); + const fake = await forgot('definitely-not-a-user-' + crypto.randomBytes(4).toString('hex') + '@x.local'); + assert.equal(real.status, fake.status, 'status must match for real and unknown addresses'); + assert.deepEqual(real.body, fake.body, 'body must match for real and unknown addresses'); + assert.equal(real.status, 200); +}); + +test('a malformed address is answered the same way, not validated into an oracle', async () => { + const bad = await forgot('not-an-email'); + const real = await forgot(S.email); + assert.equal(bad.status, real.status); + assert.deepEqual(bad.body, real.body); +}); + +// --------------------------------------------------------------------------- +// The reset itself +// --------------------------------------------------------------------------- +test('a valid token sets a new password, and the old one stops working', async () => { + const token = issueTokenFor(S.user.user.id); + const r = await reset(token, NEW_PW); + assert.equal(r.status, 200, `reset should succeed, got ${JSON.stringify(r.body)}`); + + assert.equal((await login(S.email, PW)).status, 401, 'the OLD password must stop working'); + const ok = await login(S.email, NEW_PW); + assert.equal(ok.status, 200, 'the NEW password works'); + assert.ok(ok.body.token, 'and yields a session on normal login'); +}); + +test('a token works exactly once', async () => { + const token = issueTokenFor(S.user.user.id); + assert.equal((await reset(token, 'FirstUse12345')).status, 200); + assert.equal((await reset(token, 'SecondUse12345')).status, 400, 'replay must fail'); + assert.equal((await login(S.email, 'SecondUse12345')).status, 401, 'and must not have changed the password'); +}); + +test('an unknown or expired token is refused', async () => { + assert.equal((await reset(crypto.randomBytes(32).toString('hex'), NEW_PW)).status, 400, 'unknown token'); + assert.equal((await reset('', NEW_PW)).status, 400, 'empty token'); + + const token = issueTokenFor(S.user.user.id); + db.prepare('UPDATE users SET password_reset_expires = ? WHERE id = ?') + .run(Math.floor(Date.now() / 1000) - 60, S.user.user.id); + assert.equal((await reset(token, NEW_PW)).status, 400, 'expired token'); +}); + +test('the new password must meet the same minimum as registration', async () => { + const token = issueTokenFor(S.user.user.id); + const r = await reset(token, 'short'); + assert.equal(r.status, 400, 'a too-short password is refused'); + assert.match(r.body.error, /8/, 'and says why'); +}); + +// --------------------------------------------------------------------------- +// The properties that keep this from becoming a bypass +// --------------------------------------------------------------------------- +test('completing a reset does NOT hand out a session (so TOTP is still enforced)', async () => { + const token = issueTokenFor(S.user.user.id); + const r = await reset(token, 'AnotherPw7890'); + assert.equal(r.status, 200); + assert.equal(r.body.token, undefined, 'a reset must never return a session token'); + assert.equal(r.body.user, undefined, 'nor a user object'); +}); + +test('a reset clears the per-account login lockout', async () => { + // Drive this over HTTP, not against the lib: the lockout Map lives in the SERVER + // process, so touching it in the test process would prove nothing. + // + // Each attempt carries a different X-Forwarded-For so the per-IP limiter (10/min) gives + // a fresh bucket every time while the per-ACCOUNT counter still accumulates — which is + // precisely the distributed case the account lockout exists for. + const lockout = require('../lib/login-lockout'); + const email = 'lock' + crypto.randomBytes(5).toString('hex') + '@x.local'; + const u = await register(email); + + const failFrom = (i) => jfetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Forwarded-For': `203.0.113.${i}` }, + body: JSON.stringify({ email, password: 'wrong-password' }), + }); + for (let i = 1; i <= lockout.MAX_FAILS; i++) { + const r = await failFrom(i); + assert.equal(r.status, 401, `failure ${i} must reach the handler, not the IP limiter`); + } + + // Locked: even the CORRECT password is refused, with the same generic body. + const blocked = await jfetch('/api/auth/login', { + method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Forwarded-For': '203.0.113.200' }, + body: JSON.stringify({ email, password: PW }), + }); + assert.equal(blocked.status, 401, 'the account is locked before the reset'); + + assert.equal((await reset(issueTokenFor(u.user.id), 'UnlockedPw123')).status, 200); + + const after = await jfetch('/api/auth/login', { + method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Forwarded-For': '203.0.113.201' }, + body: JSON.stringify({ email, password: 'UnlockedPw123' }), + }); + assert.equal(after.status, 200, 'resetting a password must let you back in'); + assert.ok(after.body.token); +}); + +test('an SSO account has no local password to reset', async () => { + const ssoEmail = 'sso' + crypto.randomBytes(4).toString('hex') + '@x.local'; + const id = crypto.randomUUID(); + db.prepare("INSERT INTO users (id, email, password_hash, auth_provider, plan_id) VALUES (?,?,NULL,'google','free')") + .run(id, ssoEmail); + const r = await forgot(ssoEmail); + assert.equal(r.status, 200, 'still answered identically — no oracle'); + const row = db.prepare('SELECT password_reset_hash FROM users WHERE id = ?').get(id); + assert.equal(row.password_reset_hash, null, 'but no reset token is minted for an SSO identity'); +});