mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-17 03:32:32 -06:00
Unit: the mfa_pending BITE (db-injected so removing the rejection goes red), lockout, replay, recovery-hash, decrypt-null graceful. Integration: enrollment, login->mfa_required, route-level bite, recovery single-use, API-token bypass, verify lockout. Key-rotation: enroll under key A, reboot under key B -> recovery still works, TOTP fails cleanly (no 500). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
86 lines
4.7 KiB
JavaScript
86 lines
4.7 KiB
JavaScript
'use strict';
|
|
|
|
// #100 unit tests: the security-critical assertions that don't need a full server.
|
|
// The bite-test (#1) injects an in-memory db with a real user row so that REMOVING the
|
|
// mfa_pending rejection in requireAuth makes it go red (the pending token would then
|
|
// find the user and call next()).
|
|
|
|
const { test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const crypto = require('node:crypto');
|
|
const Database = require('better-sqlite3');
|
|
const { authenticator } = require('otplib');
|
|
|
|
// Inject the db BEFORE requiring middleware/auth so requireAuth queries this one.
|
|
const mem = new Database(':memory:');
|
|
mem.exec(`CREATE TABLE users (id TEXT PRIMARY KEY, email TEXT, name TEXT, role TEXT,
|
|
auth_provider TEXT, avatar_url TEXT, plan_id TEXT, email_alerts INTEGER,
|
|
must_change_password INTEGER NOT NULL DEFAULT 0)`);
|
|
mem.prepare("INSERT INTO users (id,email,name,role,auth_provider) VALUES ('u1','u1@x','U1','user','local')").run();
|
|
require.cache[require.resolve('../db/database')] = {
|
|
id: require.resolve('../db/database'), loaded: true, exports: { db: mem },
|
|
};
|
|
|
|
const { requireAuth, generateMfaPendingToken, generateToken } = require('../middleware/auth');
|
|
const totp = require('../lib/totp');
|
|
const totpLockout = require('../lib/totp-lockout');
|
|
|
|
function runRequireAuth(token) {
|
|
const req = { headers: { authorization: 'Bearer ' + token }, originalUrl: '/api/devices' };
|
|
let status = 200, nexted = false;
|
|
const res = { status(s) { status = s; return this; }, json() { return this; } };
|
|
requireAuth(req, res, () => { nexted = true; });
|
|
return { status, nexted, req };
|
|
}
|
|
|
|
test('#100 BITE: requireAuth rejects an mfa_pending token (no password-only session)', () => {
|
|
const pending = runRequireAuth(generateMfaPendingToken({ id: 'u1' }));
|
|
assert.equal(pending.status, 401, 'mfa_pending token must be 401');
|
|
assert.equal(pending.nexted, false, 'must NOT call next() for an mfa_pending token');
|
|
// Contrast: a FULL token for the SAME user passes - so the user EXISTS in the db,
|
|
// which means removing the mfa_pending check would let the pending token through too
|
|
// (next() called). That's what makes this a real bite-test, not a vacuous 401.
|
|
const full = runRequireAuth(generateToken({ id: 'u1', email: 'u1@x', role: 'user' }, null));
|
|
assert.equal(full.nexted, true, 'a full token for the same user must pass requireAuth');
|
|
assert.equal(full.req.user.id, 'u1');
|
|
});
|
|
|
|
test('#100 lockout: locks after MAX_FAILS, lifts after the window, reset clears', () => {
|
|
const k = 'user-' + crypto.randomUUID();
|
|
for (let i = 0; i < totpLockout.MAX_FAILS - 1; i++) totpLockout.recordFailure(k, 1000);
|
|
assert.equal(totpLockout.isLocked(k, 1000), false);
|
|
totpLockout.recordFailure(k, 1000);
|
|
assert.equal(totpLockout.isLocked(k, 1000), true, 'locked at MAX_FAILS');
|
|
assert.equal(totpLockout.isLocked(k, 1000 + totpLockout.LOCKOUT_MS + 1), false, 'lifts after window');
|
|
totpLockout.reset(k);
|
|
assert.equal(totpLockout.isLocked(k, 1000), false, 'reset clears');
|
|
});
|
|
|
|
test('#100 replay: a TOTP code from an already-consumed step is rejected', () => {
|
|
const secret = totp.generateSecret();
|
|
const code = authenticator.generate(secret);
|
|
const step = totp.currentStep();
|
|
assert.equal(totp.verifyCode(code, secret, step - 1), step, 'fresh code accepted, returns the step');
|
|
assert.equal(totp.verifyCode(code, secret, step), null, 'same code at the consumed step is blocked');
|
|
});
|
|
|
|
test('#100 key-mismatch is graceful: decrypt failure -> null (no throw); verifyCode tolerates null', () => {
|
|
// If the secretbox key changes (rotated JWT_SECRET, non-persisted .jwt_secret), the
|
|
// stored TOTP secret becomes undecryptable. That must degrade, not 500.
|
|
assert.equal(totp.decryptSecret('!!!not-decryptable'), null, 'undecryptable -> null, not a throw');
|
|
assert.doesNotThrow(() => totp.verifyCode('123456', null, 0), 'null secret must not throw on the login path');
|
|
assert.equal(totp.verifyCode('123456', null, 0), null, 'null secret -> null (recovery path then handles it)');
|
|
});
|
|
|
|
test('#100 recovery codes: stored hashed, never plaintext; input normalized', () => {
|
|
const { plain, hashes } = totp.generateRecoveryCodes(10);
|
|
assert.equal(plain.length, 10);
|
|
assert.equal(hashes.length, 10);
|
|
assert.match(plain[0], /^[0-9A-F]{10}$/, 'plaintext is 10 hex chars (shown once)');
|
|
assert.match(hashes[0], /^[0-9a-f]{64}$/, 'stored value is a SHA-256 hash');
|
|
assert.notEqual(plain[0], hashes[0], 'the stored value is not the plaintext');
|
|
// typed with stray spaces/hyphens/lowercase still matches the stored hash
|
|
const messy = ' ' + plain[0].toLowerCase().slice(0, 5) + '-' + plain[0].toLowerCase().slice(5) + ' ';
|
|
assert.equal(totp.hashRecoveryCode(messy), hashes[0], 'normalized input matches');
|
|
});
|