Merge branch 'feat/self-service-password-reset'

This commit is contained in:
ScreenTinker 2026-07-27 11:21:36 -05:00
commit d4cf1d4123
9 changed files with 450 additions and 6 deletions

View file

@ -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');

View file

@ -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.',

View file

@ -89,6 +89,11 @@ export async function render(container) {
<button class="btn btn-primary" id="loginBtn" style="width:100%;justify-content:center;padding:10px">
${isSetup ? t('auth.create_admin_account') : t('auth.sign_in')}
</button>
${!isSetup ? `
<p style="text-align:center;margin-top:10px">
<a href="#" id="forgotLink" style="color:var(--text-secondary);font-size:12px;text-decoration:none">${t('auth.forgot_password')}</a>
</p>
` : ''}
${!isSetup && canRegister ? `
<button class="btn btn-secondary" id="showRegisterBtn" style="width:100%;justify-content:center;padding:10px;margin-top:8px">
${t('auth.create_account')}
@ -188,6 +193,23 @@ export async function render(container) {
</div>
</details>
<div id="forgotForm" style="display:none">
<div class="form-group">
<label>${t('auth.email')}</label>
<input type="email" id="forgotEmail" class="input" placeholder="${t('auth.placeholder_email')}" autocomplete="email">
</div>
<button class="btn btn-primary" id="forgotSendBtn" style="width:100%;justify-content:center;padding:10px">${t('auth.forgot_send')}</button>
<button class="btn btn-secondary" id="forgotBackBtn" style="width:100%;justify-content:center;padding:10px;margin-top:8px">${t('auth.back_to_signin')}</button>
<p id="forgotNotice" style="color:var(--text-secondary);font-size:12px;text-align:center;margin-top:12px;display:none">${t('auth.forgot_sent')}</p>
</div>
<div id="resetForm" style="display:none">
<div class="form-group">
<label>${t('auth.new_password')}</label>
<input type="password" id="resetPassword" class="input" placeholder="${t('auth.placeholder_register_password')}" autocomplete="new-password">
</div>
<button class="btn btn-primary" id="resetSubmitBtn" style="width:100%;justify-content:center;padding:10px">${t('auth.reset_submit')}</button>
<button class="btn btn-secondary" id="resetBackBtn" style="width:100%;justify-content:center;padding:10px;margin-top:8px">${t('auth.back_to_signin')}</button>
</div>
<p id="loginError" style="color:var(--danger);font-size:12px;text-align:center;margin-top:12px;display:none"></p>
<p style="text-align:center;margin-top:16px;font-size:11px;color:var(--text-muted)">
<a href="/legal/terms.html" target="_blank" style="color:var(--text-muted);text-decoration:underline">${t('auth.terms')}</a>
@ -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.

View file

@ -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

View file

@ -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 };

View file

@ -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

View file

@ -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.

View file

@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
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 = `<p>Hi ${escapeHtml(who)},</p>
<p>Someone asked to reset the password for your ScreenTinker account.</p>
<p><a href="${escapeHtml(url)}">Choose a new password</a> &mdash; the link is valid for 1 hour and can be used once.</p>
<p style="color:#666">If this wasn't you, you can ignore this email &mdash; your password has not changed.</p>`;
return sendEmail({ to: user.email, subject: 'Reset your ScreenTinker password', text, html });
}
module.exports = { sendSignupEmails, sendVerificationEmail, sendPasswordResetEmail };

View file

@ -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');
});