mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
Merge branch 'feat/self-service-password-reset'
This commit is contained in:
commit
d4cf1d4123
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
48
server/lib/passwordReset.js
Normal file
48
server/lib/passwordReset.js
Normal 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 };
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,'>').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 = `<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> — 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 — your password has not changed.</p>`;
|
||||
return sendEmail({ to: user.email, subject: 'Reset your ScreenTinker password', text, html });
|
||||
}
|
||||
|
||||
module.exports = { sendSignupEmails, sendVerificationEmail, sendPasswordResetEmail };
|
||||
|
|
|
|||
195
server/test/password-reset.test.js
Normal file
195
server/test/password-reset.test.js
Normal 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');
|
||||
});
|
||||
Loading…
Reference in a new issue