mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-16 23:33:10 -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
|
// Auth check - redirect to login if not authenticated
|
||||||
if (!isAuthenticated() && hash !== '#/login') {
|
if (!isAuthenticated() && hash !== '#/login' && !isResetRoute) {
|
||||||
window.location.hash = '#/login';
|
window.location.hash = '#/login';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If authenticated and on login page, redirect to dashboard or onboarding
|
// 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';
|
window.location.hash = localStorage.getItem('rd_onboarded') ? '#/' : '#/onboarding';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -359,8 +365,8 @@ function route() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Login page - hide sidebar
|
// Login page (and password-reset links from email) - hide sidebar
|
||||||
if (hash === '#/login') {
|
if (hash === '#/login' || isResetRoute) {
|
||||||
sidebar.style.display = 'none';
|
sidebar.style.display = 'none';
|
||||||
app.style.marginLeft = '0';
|
app.style.marginLeft = '0';
|
||||||
const mb = document.getElementById('mobileMenuBtn');
|
const mb = document.getElementById('mobileMenuBtn');
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,14 @@ export default {
|
||||||
'auth.verify_title': 'Confirm your email',
|
'auth.verify_title': 'Confirm your email',
|
||||||
'auth.verify_body': "We've sent a verification link to",
|
'auth.verify_body': "We've sent a verification link to",
|
||||||
'auth.verify_resend': 'Resend the email',
|
'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_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_resend_failed': "Couldn't resend right now — try again in a moment.",
|
||||||
'auth.verify_ok': 'Email confirmed. You can sign in now.',
|
'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">
|
<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')}
|
${isSetup ? t('auth.create_admin_account') : t('auth.sign_in')}
|
||||||
</button>
|
</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 ? `
|
${!isSetup && canRegister ? `
|
||||||
<button class="btn btn-secondary" id="showRegisterBtn" style="width:100%;justify-content:center;padding:10px;margin-top:8px">
|
<button class="btn btn-secondary" id="showRegisterBtn" style="width:100%;justify-content:center;padding:10px;margin-top:8px">
|
||||||
${t('auth.create_account')}
|
${t('auth.create_account')}
|
||||||
|
|
@ -188,6 +193,23 @@ export async function render(container) {
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</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 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)">
|
<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>
|
<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).
|
// "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) {
|
function showVerifyNotice(email) {
|
||||||
// The server refused a session — make sure no stale token from a prior login lingers,
|
// 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.
|
// 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 captions_lang TEXT",
|
||||||
"ALTER TABLE content ADD COLUMN subtitle_url TEXT",
|
"ALTER TABLE content ADD COLUMN subtitle_url TEXT",
|
||||||
"ALTER TABLE content ADD COLUMN subtitle_lang 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.
|
// 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
|
// 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 totpLockout = require('../lib/totp-lockout');
|
||||||
const loginLockout = require('../lib/login-lockout');
|
const loginLockout = require('../lib/login-lockout');
|
||||||
const QRCode = require('qrcode');
|
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 emailVerify = require('../lib/emailVerify');
|
||||||
const emailSvc = require('../services/email');
|
const emailSvc = require('../services/email');
|
||||||
const { deleteUserCascade, OrgHasOtherMembersError } = require('../lib/user-deletion');
|
const { deleteUserCascade, OrgHasOtherMembersError } = require('../lib/user-deletion');
|
||||||
|
|
@ -263,6 +264,60 @@ router.post('/resend-verification', (req, res) => {
|
||||||
res.json({ ok: true });
|
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) ====================
|
// ==================== TOTP MFA (#100) ====================
|
||||||
// Opt-in per-user, LOCAL accounts only (SSO IdPs own MFA). Enrollment is a two-step
|
// 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
|
// 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));
|
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.
|
// 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));
|
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,
|
// 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
|
// 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.
|
// 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 });
|
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