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 26ce7ac..a050fa4 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'); +});