mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
feat(auth): bound password login per account, not only per IP
The only throttle on POST /api/auth/login was the per-IP limiter in server.js. That bounds one noisy source and nothing else: it does not bound a distributed attempt, and it is only as accurate as a deployment's proxy configuration. Nothing counted failures against the account actually being attacked, and nothing cleared such a count on success because no such count existed. lib/login-lockout.js mirrors lib/totp-lockout.js and lib/pair-lockout.js so there is one lockout idiom here rather than three. 10 failed passwords lock an account for 15 minutes. Keyed on user.id, never on the submitted email: the email is attacker-supplied and unbounded, so keying on it would let anyone grow the Map without limit — the same class of bug fixed elsewhere in this campaign. A user id only exists for a real account, so the key space is bounded by the user table and needs no eviction sweep, exactly like totp-lockout. A locked account returns the SAME 401 and body as a wrong password. A distinct 429 would tell an attacker "this account exists and is under attack", turning login into an account-existence oracle; the test asserts the locked response is byte-identical to both the wrong-password and unknown-account responses. The trade is that a locked-out legitimate user sees the generic message, so the trip is recorded in activity_log (auth:login_locked) for the operator instead. The counter is cleared as soon as the password verifies — before the TOTP and email-verification branches, which return early and never reach issueSession, so a reset placed there would never fire for those accounts. SSO paths do not share this code and are unaffected. Frontend needs no change: login.js renders any non-ok body's `error` string verbatim, and the body is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8a55798eaf
commit
9130aa5f7d
45
server/lib/login-lockout.js
Normal file
45
server/lib/login-lockout.js
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
'use strict';
|
||||
|
||||
// Per-ACCOUNT brute-force lockout for POST /api/auth/login. Same shape as
|
||||
// lib/totp-lockout.js (#100) and lib/pair-lockout.js (#87) — deliberately, so there is one
|
||||
// recognisable lockout idiom in this codebase rather than three.
|
||||
//
|
||||
// WHY per-account and not just per-IP: the existing login throttle in server.js is keyed on
|
||||
// the client IP, which is the right control for a single noisy source but bounds nothing
|
||||
// against a distributed one — and IP attribution is only as good as the deployment's proxy
|
||||
// configuration. A counter tied to the account being attacked is independent of where the
|
||||
// attempts come from.
|
||||
//
|
||||
// KEYED ON user.id, never on the submitted email. The email is attacker-supplied and
|
||||
// unbounded, so keying on it would let anyone grow this Map without limit — the same
|
||||
// mistake this campaign fixed elsewhere. A user id only exists for a real account, so the
|
||||
// key space is bounded by the user table and needs no eviction sweep (matching
|
||||
// totp-lockout, which is bounded the same way).
|
||||
//
|
||||
// In-memory; resets on restart. That is a deliberate trade, not an oversight: a restart
|
||||
// forgiving an in-progress lockout is preferable to persisting one, and the per-IP limiter
|
||||
// still applies across restarts.
|
||||
|
||||
const MAX_FAILS = 10; // failed passwords before the account is locked
|
||||
const LOCKOUT_MS = 15 * 60 * 1000; // how long it is then locked
|
||||
|
||||
const failures = new Map(); // user.id -> { count, lockedUntil }
|
||||
|
||||
function isLocked(key, now = Date.now()) {
|
||||
const rec = failures.get(key);
|
||||
return !!(rec && rec.lockedUntil > now);
|
||||
}
|
||||
|
||||
function recordFailure(key, now = Date.now()) {
|
||||
const rec = failures.get(key) || { count: 0, lockedUntil: 0 };
|
||||
rec.count += 1;
|
||||
if (rec.count >= MAX_FAILS) { rec.lockedUntil = now + LOCKOUT_MS; rec.count = 0; }
|
||||
failures.set(key, rec);
|
||||
return rec;
|
||||
}
|
||||
|
||||
// A correct password clears the key — including for accounts that then go on to a TOTP or
|
||||
// email-verification step, since the password itself has been proven at that point.
|
||||
function reset(key) { failures.delete(key); }
|
||||
|
||||
module.exports = { isLocked, recordFailure, reset, MAX_FAILS, LOCKOUT_MS };
|
||||
|
|
@ -10,6 +10,7 @@ const { resolveTenancy } = require('../lib/tenancy');
|
|||
const { logActivity, getClientIp } = require('../services/activity');
|
||||
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 emailVerify = require('../lib/emailVerify');
|
||||
|
|
@ -168,11 +169,30 @@ router.post('/login', (req, res) => {
|
|||
return res.status(401).json({ error: 'Invalid email or password' });
|
||||
}
|
||||
|
||||
// Per-ACCOUNT brute-force lockout (lib/login-lockout), on top of the per-IP limiter in
|
||||
// server.js. Checked BEFORE bcrypt so a locked account costs no hashing work.
|
||||
//
|
||||
// The response is deliberately IDENTICAL to a wrong password: a distinct 429 would tell
|
||||
// an attacker "this account exists and is under attack", turning the endpoint into an
|
||||
// account-existence oracle. The trade is that a locked-out legitimate user sees the
|
||||
// generic message, so the trip is written to activity_log for the operator instead.
|
||||
if (loginLockout.isLocked(user.id)) {
|
||||
logFailedLogin(email, getClientIp(req), 'Locked out (too many failed passwords)');
|
||||
return res.status(401).json({ error: 'Invalid email or password' });
|
||||
}
|
||||
|
||||
if (!bcrypt.compareSync(password, user.password_hash)) {
|
||||
const rec = loginLockout.recordFailure(user.id);
|
||||
if (rec.lockedUntil) logActivity(null, 'auth:login_locked', `${email} - locked after repeated failures`, null, getClientIp(req));
|
||||
logFailedLogin(email, getClientIp(req), 'Wrong password');
|
||||
return res.status(401).json({ error: 'Invalid email or password' });
|
||||
}
|
||||
|
||||
// Password proven. Clear the counter HERE rather than in issueSession: the TOTP and
|
||||
// email-verification branches below return before issueSession is ever reached, so a
|
||||
// reset placed there would never fire for those accounts.
|
||||
loginLockout.reset(user.id);
|
||||
|
||||
// Email verification gate. Unverified LOCAL accounts are asked to confirm on login — this
|
||||
// covers both new signups AND existing users who predate the feature (grandfathered locals are
|
||||
// email_verified=0). Gated ONLY where we can actually send the mail (isConfigured), so an
|
||||
|
|
|
|||
149
server/test/login-lockout.test.js
Normal file
149
server/test/login-lockout.test.js
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
'use strict';
|
||||
|
||||
// Password login must be bounded per ACCOUNT, not only per IP. The IP limiter in server.js
|
||||
// bounds one noisy source; it bounds nothing against a distributed one, and it is only as
|
||||
// accurate as the deployment's proxy configuration.
|
||||
//
|
||||
// Two properties matter and are easy to get wrong:
|
||||
// - a locked account must answer EXACTLY like a wrong password, or the endpoint becomes
|
||||
// an account-existence oracle (a distinct 429 says "this account is real");
|
||||
// - a correct password must clear the counter immediately — including for accounts that
|
||||
// then go on to a TOTP or email-verification step, which return before issueSession.
|
||||
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs');
|
||||
const crypto = require('node:crypto');
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'st-loginlock-'));
|
||||
process.env.DATA_DIR = TMP;
|
||||
process.env.SELF_HOSTED = 'true';
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const lockout = require('../lib/login-lockout');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unit: the counter itself (mirrors test/pair-lockout.test.js — unique key per
|
||||
// test, because the Map is module-level)
|
||||
// ---------------------------------------------------------------------------
|
||||
const k = () => 'user-' + crypto.randomBytes(6).toString('hex');
|
||||
|
||||
test('an account is not locked until it crosses the threshold', () => {
|
||||
const key = k();
|
||||
for (let i = 0; i < lockout.MAX_FAILS - 1; i++) lockout.recordFailure(key);
|
||||
assert.equal(lockout.isLocked(key), false, `${lockout.MAX_FAILS - 1} failures must not lock`);
|
||||
});
|
||||
|
||||
test('crossing the threshold locks the account for the full window', () => {
|
||||
const key = k();
|
||||
const t0 = 1_000_000;
|
||||
for (let i = 0; i < lockout.MAX_FAILS; i++) lockout.recordFailure(key, t0);
|
||||
assert.equal(lockout.isLocked(key, t0), true, 'locked at the threshold');
|
||||
assert.equal(lockout.isLocked(key, t0 + lockout.LOCKOUT_MS - 1), true, 'still locked inside the window');
|
||||
assert.equal(lockout.isLocked(key, t0 + lockout.LOCKOUT_MS + 1), false, 'released after the window');
|
||||
});
|
||||
|
||||
test('a correct password clears the counter', () => {
|
||||
const key = k();
|
||||
for (let i = 0; i < lockout.MAX_FAILS - 1; i++) lockout.recordFailure(key);
|
||||
lockout.reset(key);
|
||||
for (let i = 0; i < lockout.MAX_FAILS - 1; i++) lockout.recordFailure(key);
|
||||
assert.equal(lockout.isLocked(key), false, 'reset gave the account its full budget back');
|
||||
});
|
||||
|
||||
test('accounts are independent — one locked account does not lock another', () => {
|
||||
const a = k(), b = k();
|
||||
for (let i = 0; i < lockout.MAX_FAILS; i++) lockout.recordFailure(a);
|
||||
assert.equal(lockout.isLocked(a), true);
|
||||
assert.equal(lockout.isLocked(b), false, 'a different account is unaffected');
|
||||
});
|
||||
|
||||
test('an unknown key is never locked', () => {
|
||||
assert.equal(lockout.isLocked(k()), false);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Route: the lockout is wired into POST /api/auth/login, and is not an oracle
|
||||
// ---------------------------------------------------------------------------
|
||||
const http = require('node:http');
|
||||
const express = require('express');
|
||||
const { db } = require('../db/database');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
let server, base;
|
||||
const PW = 'Passw0rd123';
|
||||
|
||||
function post(body) {
|
||||
const data = JSON.stringify(body);
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request(base + '/login', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
|
||||
}, (res) => {
|
||||
let out = '';
|
||||
res.on('data', (c) => (out += c));
|
||||
res.on('end', () => resolve({ status: res.statusCode, body: out ? JSON.parse(out) : null }));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end(data);
|
||||
});
|
||||
}
|
||||
|
||||
test('route: a locked account is indistinguishable from a wrong password', async (t) => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/', require('../routes/auth'));
|
||||
server = http.createServer(app);
|
||||
await new Promise((r) => server.listen(0, r));
|
||||
base = `http://127.0.0.1:${server.address().port}`;
|
||||
t.after(() => new Promise((r) => server.close(r)));
|
||||
|
||||
const email = 'lock' + crypto.randomBytes(5).toString('hex') + '@x.local';
|
||||
const id = crypto.randomUUID();
|
||||
db.prepare("INSERT INTO users (id, email, password_hash, auth_provider, plan_id, email_verified) VALUES (?,?,?,'local','free',1)")
|
||||
.run(id, email, bcrypt.hashSync(PW, 10));
|
||||
|
||||
// Baseline: what a wrong password looks like.
|
||||
const wrong = await post({ email, password: 'nope' });
|
||||
assert.equal(wrong.status, 401);
|
||||
assert.equal(wrong.body.error, 'Invalid email or password');
|
||||
|
||||
// Drive it past the threshold.
|
||||
for (let i = 0; i < lockout.MAX_FAILS + 2; i++) await post({ email, password: 'nope' });
|
||||
assert.equal(lockout.isLocked(id), true, 'the account is locked after repeated failures');
|
||||
|
||||
// The CORRECT password is now refused — and the response is byte-identical to a wrong
|
||||
// one, so an attacker learns nothing about whether the account exists or is locked.
|
||||
const locked = await post({ email, password: PW });
|
||||
assert.equal(locked.status, wrong.status, 'locked status must match the wrong-password status');
|
||||
assert.deepEqual(locked.body, wrong.body, 'locked body must match the wrong-password body exactly');
|
||||
|
||||
// A non-existent account still answers the same way.
|
||||
const ghost = await post({ email: 'ghost' + crypto.randomBytes(4).toString('hex') + '@x.local', password: 'nope' });
|
||||
assert.equal(ghost.status, wrong.status);
|
||||
assert.deepEqual(ghost.body, wrong.body, 'unknown account is indistinguishable too');
|
||||
});
|
||||
|
||||
test('route: a correct password clears the counter before any TOTP/verify step', async (t) => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/', require('../routes/auth'));
|
||||
const srv = http.createServer(app);
|
||||
await new Promise((r) => srv.listen(0, r));
|
||||
base = `http://127.0.0.1:${srv.address().port}`;
|
||||
t.after(() => new Promise((r) => srv.close(r)));
|
||||
|
||||
const email = 'clear' + crypto.randomBytes(5).toString('hex') + '@x.local';
|
||||
const id = crypto.randomUUID();
|
||||
db.prepare("INSERT INTO users (id, email, password_hash, auth_provider, plan_id, email_verified) VALUES (?,?,?,'local','free',1)")
|
||||
.run(id, email, bcrypt.hashSync(PW, 10));
|
||||
|
||||
for (let i = 0; i < lockout.MAX_FAILS - 1; i++) await post({ email, password: 'nope' });
|
||||
const ok = await post({ email, password: PW });
|
||||
assert.equal(ok.status, 200, 'the correct password still logs in');
|
||||
assert.equal(lockout.isLocked(id), false);
|
||||
|
||||
// Full budget restored: another MAX_FAILS-1 failures must still not lock.
|
||||
for (let i = 0; i < lockout.MAX_FAILS - 1; i++) await post({ email, password: 'nope' });
|
||||
assert.equal(lockout.isLocked(id), false, 'the successful login reset the counter');
|
||||
});
|
||||
Loading…
Reference in a new issue