feat: sign in with a code emailed to you, offered beside the password
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 46s
Forgejo Docker Build / Root app tests (push) Successful in 56s
Forgejo Android APK / Build signed APK (push) Successful in 2m6s
Forgejo Docker Build / Build Docker image (push) Successful in 15s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 46s
Forgejo Docker Build / Root app tests (push) Successful in 56s
Forgejo Android APK / Build signed APK (push) Successful in 2m6s
Forgejo Docker Build / Build Docker image (push) Successful in 15s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The sign-in screen asks for an email first, then offers both ways in together: a six-digit code sent to that address, or the password. Beside rather than instead — a code depends on mail being delivered and a password does not, so neither may be the only route. "Use a different email" goes back a step, and creating an account stays where it was. What keeps it from being a second, weaker front door: - Only a bcrypt hash is stored, so a code read out of the database is not a working credential. - Ten minutes, single use, marked used before the session is issued so a replay cannot race it, and requesting a new one deletes the old. - Five wrong guesses burn it. Six digits is a million possibilities, which is plenty against a person and nothing against a script with unlimited tries. - Requesting a code answers identically whether or not the address exists, and every verify failure returns one message. A sign-in screen that says "no such account" is a way of finding out who has one. - Two-factor still applies: a code proves you can read the mailbox, which is one factor, and an account that asked for a second still wants it. - Its own rate limits, tighter for requesting than for attempting, because requesting sends mail to someone else's address. These had to be separate limiters: Express matches app.use paths on segment boundaries, so /api/auth/login does not cover /api/auth/login-code — checked against a real router rather than assumed. Two bugs found while building it, both mine: authFetch keeps an allowlist of endpoints callable with no verified owner and rejects everything else before it is sent. The new endpoints were not on it, so the request never left the browser and surfaced as "Connection error". reveal() hid elements by appending 'hidden' to className and showed them with a non-global replace, so hiding twice left two copies and showing stripped one. The "use a different email" link never reappeared. It uses classList now, which is idempotent. Verified against the running server: correct code signs in, the same code again is refused, a superseded code is refused, five wrong guesses burn it, an expired one is refused, and the stored value is a hash. In the browser: requesting a code advances the screen, a wrong code is refused without losing the screen, and the password route still signs in. Not yet demonstrated: a correct code typed into the browser. The harness keeps racing the one-live-code rule — the page's own request supersedes whatever code the test holds, and with SMTP off the delivered one cannot be read. The same request reaches the server on the wrong-code path, and the endpoint itself is verified, but that last step is untested end to end. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
parent
087f717f55
commit
22683f3584
9 changed files with 521 additions and 7 deletions
|
|
@ -227,6 +227,9 @@ DB_PASSWORD=pedscribe_secret_change_me
|
|||
# SITE_NAME=Pediatric AI Scribe
|
||||
# API_RATE_LIMIT_MAX=200 # requests per window across /api
|
||||
# LOGIN_RATE_LIMIT_MAX=10 # login attempts per 15 minutes
|
||||
# Codes emailed for sign-in, per IP per hour. Lower than the login limit
|
||||
# because each request sends mail to somebody else's address (default 5).
|
||||
#LOGIN_CODE_RATE_LIMIT_MAX=5
|
||||
# NODE_ENV=production # with APP_URL, puts the app in production mode:
|
||||
# refuses to start without JWT_SECRET or a CORS origin
|
||||
# CORS_ORIGINS= # extra allowed origins, comma-separated, beyond APP_URL
|
||||
|
|
|
|||
29
migrations/1780700000000_login-codes.js
Normal file
29
migrations/1780700000000_login-codes.js
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
// Signing in with a code emailed to you, instead of a password.
|
||||
//
|
||||
// Its own table rather than columns on users, because a code is a short-lived
|
||||
// event with its own attempt count and it should be possible to delete every
|
||||
// outstanding one without touching an account row.
|
||||
//
|
||||
// Only the hash is stored. A code read out of the database would otherwise be a
|
||||
// working credential, which is the whole thing a login code must not become.
|
||||
|
||||
exports.up = pgm => pgm.sql(`
|
||||
CREATE TABLE IF NOT EXISTS login_codes (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
code_hash TEXT NOT NULL,
|
||||
-- Guessing is bounded per code as well as per IP: six digits is a million
|
||||
-- possibilities, which is plenty against a human and nothing against a
|
||||
-- script that gets unlimited tries at one code.
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
used_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_login_codes_user ON login_codes (user_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_login_codes_expiry ON login_codes (expires_at);
|
||||
`);
|
||||
|
||||
exports.down = pgm => pgm.sql(`
|
||||
DROP TABLE IF EXISTS login_codes;
|
||||
`);
|
||||
|
|
@ -70,15 +70,37 @@
|
|||
<label>Email</label>
|
||||
<input type="email" id="login-email" required placeholder="your@email.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<!-- Email first. Both ways in are then offered together, because a
|
||||
code depends on mail arriving and a password does not, so neither
|
||||
can be the only route. -->
|
||||
<button type="button" class="btn-auth" id="btn-login-continue">Continue</button>
|
||||
|
||||
<div id="login-choice" class="hidden" style="display:none;gap:8px;flex-direction:column;margin-top:2px;">
|
||||
<button type="button" class="btn-auth" id="btn-login-send-code">
|
||||
<i class="fas fa-envelope"></i> Email me a sign-in code
|
||||
</button>
|
||||
<button type="button" class="btn-auth" id="btn-login-use-password"
|
||||
style="background:none;color:#2563eb;border:1px solid #e5e7eb;">
|
||||
Use my password instead
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="login-code-group" class="form-group hidden">
|
||||
<label>Sign-in code</label>
|
||||
<input type="text" id="login-code" placeholder="6-digit code" maxlength="6"
|
||||
inputmode="numeric" autocomplete="one-time-code">
|
||||
<p id="login-code-hint" style="margin:6px 0 0;font-size:12px;color:#6b7280;"></p>
|
||||
</div>
|
||||
|
||||
<div id="login-password-group" class="form-group hidden">
|
||||
<label>Password</label>
|
||||
<input type="password" id="login-password" required placeholder="••••••••">
|
||||
<input type="password" id="login-password" placeholder="••••••••">
|
||||
</div>
|
||||
<div id="totp-group" class="form-group hidden">
|
||||
<label>2FA Code</label>
|
||||
<input type="text" id="login-totp" placeholder="6-digit code" maxlength="6">
|
||||
</div>
|
||||
<button type="submit" class="btn-auth" id="btn-local-login">Sign In</button>
|
||||
<button type="submit" class="btn-auth hidden" id="btn-local-login" style="display:none;">Sign In</button>
|
||||
<div id="sso-divider" class="hidden" style="display:none;text-align:center;margin:16px 0 12px;position:relative;">
|
||||
<span style="background:white;padding:0 12px;color:#9ca3af;font-size:12px;position:relative;z-index:1;">or</span>
|
||||
<hr style="border:none;border-top:1px solid #e5e7eb;position:absolute;top:50%;left:0;right:0;margin:0;">
|
||||
|
|
@ -93,6 +115,7 @@
|
|||
<div class="auth-links">
|
||||
<a href="#" id="show-register" style="display:none">Create account</a>
|
||||
<a href="#" id="show-forgot">Forgot password?</a>
|
||||
<a href="#" id="login-change-email" class="hidden" style="display:none;">Use a different email</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
|
|
|||
|
|
@ -258,7 +258,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
if (ssoLabel && data.buttonLabel) ssoLabel.textContent = data.buttonLabel;
|
||||
if (data.disableLocalAuth) {
|
||||
// Hide local login form fields, only show SSO
|
||||
var localFields = document.querySelectorAll('#login-form .form-group, #btn-local-login, #show-register, #show-forgot');
|
||||
var localFields = document.querySelectorAll('#login-form .form-group, #btn-local-login, ' +
|
||||
'#btn-login-continue, #login-choice, #login-change-email, #show-register, #show-forgot');
|
||||
localFields.forEach(function(el) { el.style.display = 'none'; });
|
||||
if (ssoDivider) ssoDivider.style.display = 'none';
|
||||
}
|
||||
|
|
@ -716,7 +717,137 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
});
|
||||
}
|
||||
|
||||
// ---- EMAIL-FIRST SIGN IN ----
|
||||
// Email, then both ways in offered together. A code needs mail to arrive and
|
||||
// a password does not, so neither is allowed to be the only route: the
|
||||
// password option sits next to the code option rather than behind it.
|
||||
function reveal(id, on) {
|
||||
var el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
// classList, not a string replace: hiding twice used to append 'hidden'
|
||||
// twice, and a non-global replace then stripped only one of them, so the
|
||||
// element stayed hidden forever. Measured on the "use a different email"
|
||||
// link, which never reappeared.
|
||||
if (on) el.classList.remove('hidden'); else el.classList.add('hidden');
|
||||
el.style.display = on ? (id === 'login-choice' ? 'flex' : '') : 'none';
|
||||
}
|
||||
|
||||
function loginStep(step) {
|
||||
reveal('btn-login-continue', step === 'email');
|
||||
reveal('login-choice', step === 'choice');
|
||||
reveal('login-code-group', step === 'code');
|
||||
reveal('login-password-group', step === 'password');
|
||||
reveal('btn-local-login', step === 'code' || step === 'password');
|
||||
reveal('login-change-email', step !== 'email');
|
||||
var email = document.getElementById('login-email');
|
||||
if (email) email.readOnly = step !== 'email';
|
||||
var submit = document.getElementById('btn-local-login');
|
||||
if (submit) submit.textContent = step === 'code' ? 'Sign in with code' : 'Sign In';
|
||||
loginMode = step;
|
||||
}
|
||||
var loginMode = 'email';
|
||||
|
||||
var continueBtn = document.getElementById('btn-login-continue');
|
||||
if (continueBtn) continueBtn.addEventListener('click', function () {
|
||||
var email = (document.getElementById('login-email') || {}).value || '';
|
||||
if (!email.trim() || email.indexOf('@') === -1) { showToast('Enter your email', 'error'); return; }
|
||||
loginStep('choice');
|
||||
});
|
||||
|
||||
var changeEmail = document.getElementById('login-change-email');
|
||||
if (changeEmail) changeEmail.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
['login-code', 'login-password', 'login-totp'].forEach(function (id) {
|
||||
var el = document.getElementById(id); if (el) el.value = '';
|
||||
});
|
||||
reveal('totp-group', false);
|
||||
loginStep('email');
|
||||
var email = document.getElementById('login-email');
|
||||
if (email) email.focus();
|
||||
});
|
||||
|
||||
var usePassword = document.getElementById('btn-login-use-password');
|
||||
if (usePassword) usePassword.addEventListener('click', function () {
|
||||
loginStep('password');
|
||||
var pw = document.getElementById('login-password');
|
||||
if (pw) pw.focus();
|
||||
});
|
||||
|
||||
var sendCode = document.getElementById('btn-login-send-code');
|
||||
if (sendCode) sendCode.addEventListener('click', function () {
|
||||
var email = ((document.getElementById('login-email') || {}).value || '').trim();
|
||||
if (!email) { showToast('Enter your email', 'error'); return; }
|
||||
sendCode.disabled = true;
|
||||
fetch('/api/auth/login-code/request', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: email })
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
// The server answers the same way whether or not the account exists, so
|
||||
// this says what was done, not what was found.
|
||||
loginStep('code');
|
||||
var hint = document.getElementById('login-code-hint');
|
||||
if (hint) {
|
||||
hint.textContent = data && data.error
|
||||
? data.error
|
||||
: 'If ' + email + ' has an account, a code is on its way. It expires in 10 minutes. ' +
|
||||
'No code? Use your password instead.';
|
||||
}
|
||||
var box = document.getElementById('login-code');
|
||||
if (box) box.focus();
|
||||
})
|
||||
.catch(function () { showToast('Connection error', 'error'); })
|
||||
.finally(function () { sendCode.disabled = false; });
|
||||
});
|
||||
|
||||
// ---- LOGIN FORM SUBMIT ----
|
||||
function submitCode() {
|
||||
var email = document.getElementById('login-email').value.trim();
|
||||
var code = (document.getElementById('login-code') || {}).value || '';
|
||||
var totpEl = document.getElementById('login-totp');
|
||||
var totpCode = totpEl ? totpEl.value.trim() : '';
|
||||
if (!code.trim()) { showToast('Enter the code from your email', 'error'); return false; }
|
||||
|
||||
var body = { email: email, code: code };
|
||||
if (totpCode) body.totpCode = totpCode;
|
||||
var attempt = authState();
|
||||
return fetch('/api/auth/login-code/verify', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) { return handleSignIn(data, attempt); })
|
||||
.catch(function (err) {
|
||||
if (boundary.blocked() || err.name === 'AbortError') return;
|
||||
showToast('Connection error', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// Shared by both routes in, so a code sign-in gets the same 2FA prompt, the
|
||||
// same verification notice and the same welcome as a password sign-in.
|
||||
function handleSignIn(data, attempt) {
|
||||
if (boundary.blocked() && !(data.success && data.token && data.user)) return;
|
||||
if (data.requires2FA) {
|
||||
var grp = document.getElementById('totp-group');
|
||||
if (grp) { grp.style.display = 'block'; grp.className = grp.className.replace('hidden', '').trim(); }
|
||||
showToast('Enter your 2FA code', 'info');
|
||||
return;
|
||||
}
|
||||
if (data.needsVerification) {
|
||||
var resendBox = document.getElementById('resend-verify-box');
|
||||
if (resendBox) resendBox.className = resendBox.className.replace('hidden', '').trim();
|
||||
showToast('Verify your email first. Check inbox.', 'error');
|
||||
return;
|
||||
}
|
||||
if (data.success && data.token && data.user) {
|
||||
return enterApp(data.user, data.token, true, data.sessionId, attempt).then(function (entered) {
|
||||
if (entered) showToast('Welcome, ' + data.user.name + '!', 'success');
|
||||
});
|
||||
}
|
||||
showToast(data.error || 'Sign-in failed', 'error');
|
||||
}
|
||||
|
||||
function submitLogin() {
|
||||
|
||||
var email = document.getElementById('login-email').value.trim();
|
||||
|
|
@ -725,7 +856,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
var totpCode = totpEl ? totpEl.value.trim() : '';
|
||||
|
||||
if (!email || !password) {
|
||||
showToast('Enter email and password', 'error');
|
||||
showToast('Enter your password', 'error');
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -792,8 +923,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
if (loginForm) loginForm.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
authenticate(submitLogin, 'Signing in...');
|
||||
authenticate(loginMode === 'code' ? submitCode : submitLogin, 'Signing in...');
|
||||
});
|
||||
loginStep('email');
|
||||
|
||||
// ---- RESEND VERIFICATION LINK ----
|
||||
var resendLink = document.getElementById('resend-verify-link');
|
||||
|
|
|
|||
|
|
@ -3,8 +3,13 @@ if (!window.__fetchAuthIntercepted) {
|
|||
window.__fetchAuthIntercepted = true;
|
||||
var rawFetch = window.fetch.bind(window);
|
||||
var boundary = window.AccountBoundary;
|
||||
// Callable with no verified owner, because they are how one is obtained.
|
||||
// Anything not on this list is rejected before it is sent, which is right —
|
||||
// but it means a new sign-in endpoint has to be added here or it fails as a
|
||||
// "Connection error" with no request ever leaving the browser.
|
||||
var authPaths = new Set([
|
||||
'/api/auth/login', '/api/auth/register', '/api/auth/logout',
|
||||
'/api/auth/login-code/request', '/api/auth/login-code/verify',
|
||||
'/api/auth/forgot-password', '/api/auth/reset-password',
|
||||
'/api/auth/verify-email', '/api/auth/resend-verification',
|
||||
'/api/auth/oidc', '/api/auth/oidc-status', '/api/auth/registration-status'
|
||||
|
|
@ -39,7 +44,10 @@ if (!window.__fetchAuthIntercepted) {
|
|||
if (!ticket && !authPaths.has(url.pathname) && url.pathname !== '/api/auth/me' && url.pathname !== '/api/models') {
|
||||
return Promise.reject(boundary.error());
|
||||
}
|
||||
var login = (url.pathname === '/api/auth/login' || url.pathname === '/api/auth/register')
|
||||
// Signing in with a code establishes a session exactly as a password does,
|
||||
// so the boundary has to be prepared for a new owner the same way.
|
||||
var login = (url.pathname === '/api/auth/login' || url.pathname === '/api/auth/register'
|
||||
|| url.pathname === '/api/auth/login-code/verify')
|
||||
&& String((init && init.method) || (input && input.method) || 'GET').toUpperCase() === 'POST';
|
||||
try { if (login) boundary.startLogin(); } catch (e) { return Promise.reject(e); }
|
||||
var revision = boundary.revision();
|
||||
|
|
|
|||
17
server.js
17
server.js
|
|
@ -103,6 +103,23 @@ app.use('/api/auth/login', rateLimit({
|
|||
message: { error: 'Too many login attempts. Try again in 15 minutes.' },
|
||||
standardHeaders: true, legacyHeaders: false
|
||||
}));
|
||||
// Asking for a code sends mail to someone else's address, so it is limited more
|
||||
// tightly than a login attempt: the cost of abuse lands on the mailbox owner.
|
||||
// Note these are separate limiters rather than covered by the one above —
|
||||
// Express matches app.use paths on segment boundaries, so '/api/auth/login'
|
||||
// does not match '/api/auth/login-code'.
|
||||
app.use('/api/auth/login-code/request', rateLimit({
|
||||
windowMs: 60 * 60 * 1000,
|
||||
max: parseInt(process.env.LOGIN_CODE_RATE_LIMIT_MAX || '5', 10),
|
||||
message: { error: 'Too many code requests. Try again later, or sign in with your password.' },
|
||||
standardHeaders: true, legacyHeaders: false
|
||||
}));
|
||||
app.use('/api/auth/login-code/verify', rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: parseInt(process.env.LOGIN_RATE_LIMIT_MAX || '10', 10),
|
||||
message: { error: 'Too many attempts. Try again in 15 minutes.' },
|
||||
standardHeaders: true, legacyHeaders: false
|
||||
}));
|
||||
app.use('/api/auth/register', rateLimit({
|
||||
windowMs: 60 * 60 * 1000, max: 5,
|
||||
message: { error: 'Too many registration attempts.' },
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ const crypto = require('crypto');
|
|||
const db = require('../db/database');
|
||||
const { JWT_SECRET, authMiddleware } = require('../middleware/auth');
|
||||
const invites = require('../utils/registrationInvites');
|
||||
const loginCodes = require('../utils/loginCodes');
|
||||
const { hashToken, parseUserAgent, generateSessionId } = require('../utils/sessions');
|
||||
const { notifyNewLogin, notifyPasswordChanged, notifyNewRegistration } = require('../utils/notify');
|
||||
var logger = require('../utils/logger');
|
||||
|
|
@ -314,6 +315,101 @@ router.post('/resend-verification', async (req, res) => {
|
|||
} catch (err) { console.error('[Auth] Resend error:', err.message); res.status(500).json({ error: 'Failed to send email' }); }
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// SIGN-IN CODE
|
||||
// ============================================================
|
||||
// Signing in with a code emailed to you, offered alongside the password rather
|
||||
// than instead of it: the code depends on mail being delivered and the password
|
||||
// does not, so neither can be the only way in.
|
||||
//
|
||||
// Requesting one answers identically whether or not the address exists. A
|
||||
// sign-in screen that says "no such account" is a way of finding out who has
|
||||
// one, and that is worth more to an attacker than the code is.
|
||||
router.post('/login-code/request', requireLocalAuth, async (req, res) => {
|
||||
var email = String((req.body && req.body.email) || '').toLowerCase().trim();
|
||||
// Said the same way on every path below, including the ones that do nothing.
|
||||
var GENERIC = { success: true, message: 'If that account exists, a code is on its way.' };
|
||||
try {
|
||||
if (!email || email.length > 320) return res.json(GENERIC);
|
||||
|
||||
var user = await db.get('SELECT id, email, name, disabled FROM users WHERE email = ?', [email]);
|
||||
if (!user || user.disabled) {
|
||||
console.warn('[Auth] login-code: no deliverable account (ip=' + req.ip + ')');
|
||||
return res.json(GENERIC);
|
||||
}
|
||||
|
||||
var code = await loginCodes.issue(db, user.id);
|
||||
loginCodes.sweep(db);
|
||||
// Fire and forget: whether the mail went is not something the response may
|
||||
// reveal, and the password is still there if it did not.
|
||||
sendEmail(user.email, 'Your sign-in code', loginCodes.emailBody(code, 'PedAI'))
|
||||
.catch(function (err) { console.warn('[Auth] login-code send failed:', err.message); });
|
||||
|
||||
await db.run('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)',
|
||||
[user.id, 'login_code_requested', req.ip]).catch(function () {});
|
||||
res.json(GENERIC);
|
||||
} catch (err) {
|
||||
console.error('[Auth] login-code request error:', err.message);
|
||||
res.json(GENERIC);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/login-code/verify', requireLocalAuth, async (req, res) => {
|
||||
try {
|
||||
var email = String((req.body && req.body.email) || '').toLowerCase().trim();
|
||||
var code = String((req.body && req.body.code) || '');
|
||||
var totpCode = (req.body && req.body.totpCode) || '';
|
||||
// One message for every failure. Which of them it was is not the caller's
|
||||
// business, and saying would turn this into an account oracle.
|
||||
var REFUSED = { error: 'That code is not valid. Request a new one, or sign in with your password.' };
|
||||
if (!email || !code) return res.status(400).json(REFUSED);
|
||||
|
||||
var user = await db.get('SELECT * FROM users WHERE email = ?', [email]);
|
||||
if (!user || user.disabled) {
|
||||
console.warn('[Auth] login-code verify: no deliverable account (ip=' + req.ip + ')');
|
||||
return res.status(401).json(REFUSED);
|
||||
}
|
||||
|
||||
var ok = await loginCodes.consume(db, user.id, code);
|
||||
if (!ok) {
|
||||
logger.access(user.id, 'login_code', req, false);
|
||||
return res.status(401).json(REFUSED);
|
||||
}
|
||||
|
||||
// A code proves you can read the mailbox, which is one factor. An account
|
||||
// that asked for a second still wants it.
|
||||
if (user.totp_enabled) {
|
||||
if (!totpCode) return res.json({ requires2FA: true });
|
||||
var totpInput = String(totpCode).trim();
|
||||
var verified = speakeasy.totp.verify({ secret: user.totp_secret, encoding: 'base32', token: totpInput, window: 1 });
|
||||
if (!verified) {
|
||||
var consumed = await tryConsumeBackupCode(user.id, totpInput);
|
||||
if (!consumed) return res.status(401).json({ error: 'Invalid 2FA code' });
|
||||
}
|
||||
}
|
||||
|
||||
var token = signAuthToken(user.id, req);
|
||||
await db.run('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)',
|
||||
[user.id, 'login_code', req.ip]);
|
||||
logger.access(user.id, 'login_code', req, true);
|
||||
|
||||
var sessionId = generateSessionId();
|
||||
await db.run('INSERT INTO user_sessions (id, user_id, token_hash, ip_address, user_agent, device_label) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[sessionId, user.id, hashToken(token), req.ip, req.headers['user-agent'] || '', parseUserAgent(req.headers['user-agent'])]);
|
||||
notifyNewLogin(user.id, parseUserAgent(req.headers['user-agent']), req.ip);
|
||||
|
||||
setAuthCookie(res, token);
|
||||
res.json({
|
||||
success: true, token: token, sessionId: sessionId,
|
||||
user: { id: user.id, email: user.email, name: user.name, role: user.role,
|
||||
totp_enabled: user.totp_enabled, email_verified: user.email_verified }
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Auth] login-code verify error:', err.message);
|
||||
res.status(500).json({ error: 'Sign-in failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// LOGIN (checks disabled status)
|
||||
// ============================================================
|
||||
|
|
|
|||
110
src/utils/loginCodes.js
Normal file
110
src/utils/loginCodes.js
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
// ============================================================
|
||||
// SIGN-IN CODES
|
||||
// ============================================================
|
||||
// A six-digit code emailed to you, as an alternative to typing a password —
|
||||
// not a replacement for one. Both are always offered, because the code depends
|
||||
// on mail being delivered and the password does not.
|
||||
//
|
||||
// The rules that make this safe rather than a second, weaker front door:
|
||||
//
|
||||
// - Only a hash is stored, so a code read out of the database is not a
|
||||
// working credential.
|
||||
// - Ten minutes, single use, and requesting a new one invalidates the old.
|
||||
// - Five wrong guesses burns the code. Six digits is a million possibilities,
|
||||
// which is plenty against a person and nothing against a script with
|
||||
// unlimited tries at one code.
|
||||
// - Requesting a code says the same thing whether or not the address exists.
|
||||
// A sign-in screen that answers "no such account" is an oracle for finding
|
||||
// out who has one.
|
||||
// - Two-factor still applies. A code proves you can read the mailbox, which
|
||||
// is one factor, and an account that asked for a second still wants it.
|
||||
|
||||
var crypto = require('crypto');
|
||||
var bcrypt = require('bcryptjs');
|
||||
|
||||
var TTL_MINUTES = 10;
|
||||
var MAX_ATTEMPTS = 5;
|
||||
var LENGTH = 6;
|
||||
|
||||
// Uniform across the full range, unlike % on a random byte, which would make
|
||||
// low digits slightly likelier.
|
||||
function generate() {
|
||||
var max = Math.pow(10, LENGTH);
|
||||
var value;
|
||||
do {
|
||||
value = crypto.randomBytes(4).readUInt32BE(0);
|
||||
} while (value >= Math.floor(0xFFFFFFFF / max) * max);
|
||||
return String(value % max).padStart(LENGTH, '0');
|
||||
}
|
||||
|
||||
function normalise(input) {
|
||||
return String(input || '').replace(/\D/g, '').slice(0, LENGTH);
|
||||
}
|
||||
|
||||
async function issue(db, userId) {
|
||||
var code = generate();
|
||||
// One live code per account: asking for a new one must not leave the old one
|
||||
// working, or a code forwarded to the wrong place stays usable.
|
||||
await db.run('DELETE FROM login_codes WHERE user_id = ?', [userId]);
|
||||
await db.run(
|
||||
'INSERT INTO login_codes (user_id, code_hash, expires_at) VALUES (?, ?, NOW() + INTERVAL \'' +
|
||||
TTL_MINUTES + ' minutes\')',
|
||||
[userId, await bcrypt.hash(code, 10)]
|
||||
);
|
||||
return code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true only for a live, unused, correctly-guessed code.
|
||||
*
|
||||
* Every failure path looks the same to the caller. Telling someone that a code
|
||||
* was right but expired, or that there was no code at all, is information they
|
||||
* did not have.
|
||||
*/
|
||||
async function consume(db, userId, input) {
|
||||
var code = normalise(input);
|
||||
if (code.length !== LENGTH) return false;
|
||||
|
||||
var row = await db.get(
|
||||
'SELECT id, code_hash, attempts FROM login_codes ' +
|
||||
'WHERE user_id = ? AND used_at IS NULL AND expires_at > NOW() ' +
|
||||
'ORDER BY created_at DESC LIMIT 1',
|
||||
[userId]
|
||||
);
|
||||
if (!row) return false;
|
||||
if (row.attempts >= MAX_ATTEMPTS) {
|
||||
await db.run('DELETE FROM login_codes WHERE id = ?', [row.id]);
|
||||
return false;
|
||||
}
|
||||
|
||||
var ok = await bcrypt.compare(code, row.code_hash);
|
||||
if (!ok) {
|
||||
await db.run('UPDATE login_codes SET attempts = attempts + 1 WHERE id = ?', [row.id]);
|
||||
return false;
|
||||
}
|
||||
// Marked used before the caller issues a session, so a replay cannot race it.
|
||||
var claimed = await db.get(
|
||||
'UPDATE login_codes SET used_at = NOW() WHERE id = ? AND used_at IS NULL RETURNING id',
|
||||
[row.id]
|
||||
);
|
||||
return Boolean(claimed);
|
||||
}
|
||||
|
||||
// Expired and spent codes are of no use to anyone; this keeps the table from
|
||||
// growing without bound. Safe to call and ignore.
|
||||
async function sweep(db) {
|
||||
try {
|
||||
await db.run("DELETE FROM login_codes WHERE expires_at < NOW() - INTERVAL '1 day'");
|
||||
} catch (e) { /* housekeeping never fails a request */ }
|
||||
}
|
||||
|
||||
function emailBody(code, appName) {
|
||||
return '<p>Your sign-in code for ' + (appName || 'PedAI') + ' is:</p>' +
|
||||
'<p style="font-size:28px;font-weight:700;letter-spacing:4px;margin:16px 0;">' + code + '</p>' +
|
||||
'<p>It expires in ' + TTL_MINUTES + ' minutes and can be used once.</p>' +
|
||||
'<p style="color:#6b7280;font-size:13px;">If you did not ask to sign in, ignore this message ' +
|
||||
'and nothing will happen. Your password still works as usual.</p>';
|
||||
}
|
||||
|
||||
module.exports = { issue, consume, sweep, generate, normalise, emailBody,
|
||||
TTL_MINUTES, MAX_ATTEMPTS, LENGTH };
|
||||
96
test/login-codes.test.js
Normal file
96
test/login-codes.test.js
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
// ============================================================
|
||||
// SIGN-IN CODES
|
||||
// ============================================================
|
||||
// A code emailed to you, offered beside the password rather than instead of it.
|
||||
// The rules here are what keep it from being a second, weaker front door.
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const lc = require('../src/utils/loginCodes');
|
||||
const read = p => fs.readFileSync(path.join(__dirname, '..', p), 'utf8');
|
||||
|
||||
test('codes are six digits and uniformly drawn', () => {
|
||||
const seen = new Set();
|
||||
for (let i = 0; i < 3000; i++) {
|
||||
const code = lc.generate();
|
||||
assert.match(code, /^\d{6}$/);
|
||||
seen.add(code);
|
||||
}
|
||||
// Rejection sampling, not modulo on a random byte, which would make low
|
||||
// digits likelier.
|
||||
assert.match(read('src/utils/loginCodes.js'), /while \(value >= Math\.floor\(0xFFFFFFFF \/ max\) \* max\)/);
|
||||
assert.ok(seen.size > 2800, 'not repeating itself');
|
||||
assert.equal(lc.normalise(' 12 34-56 '), '123456');
|
||||
});
|
||||
|
||||
test('a code is stored as a hash, used once, and superseded by the next', () => {
|
||||
const src = read('src/utils/loginCodes.js');
|
||||
// A code read out of the database must not be a working credential.
|
||||
assert.match(src, /bcrypt\.hash\(code, 10\)/);
|
||||
// The value bound to code_hash is the hash, never the code itself.
|
||||
assert.match(src, /\[userId, await bcrypt\.hash\(code, 10\)\]/);
|
||||
assert.doesNotMatch(src, /\[userId, code\]/, 'the plaintext is never inserted');
|
||||
// Asking for a new one must not leave the old one working.
|
||||
assert.match(src, /DELETE FROM login_codes WHERE user_id = \?/);
|
||||
// Marked used before a session is issued, so a replay cannot race it.
|
||||
assert.match(src, /UPDATE login_codes SET used_at = NOW\(\) WHERE id = \? AND used_at IS NULL RETURNING id/);
|
||||
assert.match(src, /expires_at > NOW\(\)/);
|
||||
assert.match(src, /row\.attempts >= MAX_ATTEMPTS/);
|
||||
assert.equal(lc.MAX_ATTEMPTS, 5);
|
||||
assert.equal(lc.TTL_MINUTES, 10);
|
||||
});
|
||||
|
||||
test('neither endpoint says whether an account exists', () => {
|
||||
const route = read('src/routes/auth.js');
|
||||
// A sign-in screen that answers "no such account" is a way of finding out who
|
||||
// has one, which is worth more to an attacker than the code is.
|
||||
assert.match(route, /var GENERIC = \{ success: true, message: 'If that account exists, a code is on its way\.' \};/);
|
||||
assert.match(route, /if \(!user \|\| user\.disabled\) \{\s*\n\s*console\.warn\('\[Auth\] login-code: no deliverable account/);
|
||||
// One refusal for every failure on verify, too.
|
||||
assert.match(route, /var REFUSED = \{ error: 'That code is not valid\./);
|
||||
// A code proves you can read the mailbox. An account that asked for a second
|
||||
// factor still wants it.
|
||||
assert.match(route, /if \(user\.totp_enabled\) \{\s*\n\s*if \(!totpCode\) return res\.json\(\{ requires2FA: true \}\);/);
|
||||
});
|
||||
|
||||
test('the new endpoints are rate limited, and reachable while signed out', () => {
|
||||
const server = read('server.js');
|
||||
// Express matches app.use paths on segment boundaries, so the /api/auth/login
|
||||
// limiter does not cover /api/auth/login-code — verified against a real
|
||||
// router, not assumed.
|
||||
assert.match(server, /app\.use\('\/api\/auth\/login-code\/request', rateLimit\(\{/);
|
||||
assert.match(server, /app\.use\('\/api\/auth\/login-code\/verify', rateLimit\(\{/);
|
||||
// Asking for a code sends mail to someone else's address, so it is limited
|
||||
// more tightly than an attempt to sign in.
|
||||
assert.match(server, /LOGIN_CODE_RATE_LIMIT_MAX \|\| '5'/);
|
||||
|
||||
// authFetch rejects any /api path not on its allowlist before it is sent,
|
||||
// which surfaced as "Connection error" with no request leaving the browser.
|
||||
const wrapper = read('public/js/authFetch.js');
|
||||
assert.match(wrapper, /'\/api\/auth\/login-code\/request', '\/api\/auth\/login-code\/verify'/);
|
||||
assert.match(wrapper, /url\.pathname === '\/api\/auth\/login-code\/verify'/, 'and it counts as a login');
|
||||
});
|
||||
|
||||
test('the screen asks for the email first and always offers both ways in', () => {
|
||||
const html = read('public/index.html');
|
||||
assert.match(html, /id="btn-login-continue"/);
|
||||
assert.match(html, /id="btn-login-send-code"/);
|
||||
assert.match(html, /id="btn-login-use-password"/);
|
||||
assert.match(html, /id="login-change-email"/);
|
||||
// Creating an account stays on the first step.
|
||||
assert.match(html, /id="show-register"/);
|
||||
|
||||
const js = read('public/js/auth.js');
|
||||
// A code needs mail to arrive and a password does not, so the password option
|
||||
// sits beside the code option rather than behind it.
|
||||
assert.match(js, /reveal\('login-choice', step === 'choice'\)/);
|
||||
assert.match(js, /loginMode === 'code' \? submitCode : submitLogin/);
|
||||
// classList, not a string replace: hiding twice appended 'hidden' twice and a
|
||||
// non-global replace stripped only one, so the element stayed hidden forever.
|
||||
assert.match(js, /if \(on\) el\.classList\.remove\('hidden'\); else el\.classList\.add\('hidden'\);/);
|
||||
// One response handler, so a code sign-in gets the same 2FA prompt and the
|
||||
// same welcome as a password one.
|
||||
assert.match(js, /function handleSignIn\(data, attempt\)/);
|
||||
});
|
||||
Loading…
Reference in a new issue