mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-19 23:06:49 +00:00
fix(mfa): enforce single-use backup codes under concurrent verification (#1262)
* fix(mfa): enforce single-use backup codes under concurrent verification Backup-code consumption read the stored hash set, awaited bcrypt.compare, then wrote the shrunk set back. Two concurrent /login/mfa requests carrying the same code could both read the same set, both match, and both persist, so a single backup code yielded two authenticated sessions. Move consumption into a synchronous transaction: verifyBackupCode now returns the matched hash without mutating state, and a new consumeBackupCodeHash re-reads and shrinks the set atomically, returning whether the hash was still present. Login gates success on that result, so exactly one concurrent request wins. Reshape the verify result into a discriminated union so a match always carries its hash. Add coverage: a deterministic consume test (same hash twice, distinct hashes, absent hash), a concurrent same-code login race, backup-code exhaustion, and a disable-via-backup-code path. * test(mfa): make the concurrent backup-code test deterministic The race test relied on incidental scheduling to interleave the two requests, so it could false-green if they happened to serialize. Add a barrier that holds both requests just after verification (once both have read the same stored set) until both arrive, then releases them into the atomic consume. This forces the race every run, so the test fails against a non-atomic consume and passes only when exactly one request wins. A timeout releases the barrier if only one request arrives, so a setup fault fails loudly instead of hanging.
This commit is contained in:
@@ -106,21 +106,19 @@ describe('MfaService', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('verifyBackupCode matches and returns remaining set with the matched hash removed', async () => {
|
||||
it('verifyBackupCode matches and returns the matched hash for the caller to consume', async () => {
|
||||
const codes = MfaService.generateBackupCodes();
|
||||
const hashes = await MfaService.hashBackupCodes(codes);
|
||||
const result = await MfaService.verifyBackupCode(hashes, codes[3]);
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.remainingHashes).toHaveLength(hashes.length - 1);
|
||||
expect(result.remainingHashes).not.toContain(hashes[3]);
|
||||
if (result.matched) expect(result.matchedHash).toBe(hashes[3]);
|
||||
});
|
||||
|
||||
it('verifyBackupCode on non-match returns original hashes', async () => {
|
||||
it('verifyBackupCode on non-match reports no match', async () => {
|
||||
const codes = MfaService.generateBackupCodes();
|
||||
const hashes = await MfaService.hashBackupCodes(codes);
|
||||
const result = await MfaService.verifyBackupCode(hashes, 'NOTACODE99');
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.remainingHashes).toBe(hashes);
|
||||
});
|
||||
|
||||
it('normalizeBackupCode strips spaces/dashes and uppercases', () => {
|
||||
@@ -129,6 +127,44 @@ describe('MfaService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── consumeBackupCodeHash: the atomic single-use enforcement point ────────────
|
||||
|
||||
describe('DatabaseService.consumeBackupCodeHash', () => {
|
||||
it('consumes a present hash once: a second consume of the same hash returns false', async () => {
|
||||
const { userId, backupCodes } = await seedMfaUser('consume-same', 'mfapassword123');
|
||||
const db = DatabaseService.getInstance();
|
||||
const hashes = JSON.parse(db.getUserMfa(userId)!.backup_codes_json!) as string[];
|
||||
const target = hashes[2];
|
||||
|
||||
// First consume wins, second loses: this is what guarantees single-use
|
||||
// when two concurrent logins race on the same code.
|
||||
expect(db.consumeBackupCodeHash(userId, target)).toBe(true);
|
||||
expect(db.consumeBackupCodeHash(userId, target)).toBe(false);
|
||||
|
||||
const remaining = JSON.parse(db.getUserMfa(userId)!.backup_codes_json!) as string[];
|
||||
expect(remaining).toHaveLength(backupCodes.length - 1);
|
||||
expect(remaining).not.toContain(target);
|
||||
});
|
||||
|
||||
it('consumes two distinct hashes independently, dropping the set by two', async () => {
|
||||
const { userId, backupCodes } = await seedMfaUser('consume-distinct', 'mfapassword123');
|
||||
const db = DatabaseService.getInstance();
|
||||
const hashes = JSON.parse(db.getUserMfa(userId)!.backup_codes_json!) as string[];
|
||||
|
||||
expect(db.consumeBackupCodeHash(userId, hashes[0])).toBe(true);
|
||||
expect(db.consumeBackupCodeHash(userId, hashes[1])).toBe(true);
|
||||
|
||||
const remaining = JSON.parse(db.getUserMfa(userId)!.backup_codes_json!) as string[];
|
||||
expect(remaining).toHaveLength(backupCodes.length - 2);
|
||||
});
|
||||
|
||||
it('returns false for a hash that is not in the stored set', async () => {
|
||||
const { userId } = await seedMfaUser('consume-absent', 'mfapassword123');
|
||||
const db = DatabaseService.getInstance();
|
||||
expect(db.consumeBackupCodeHash(userId, 'not-a-stored-hash')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Login flow ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('POST /api/auth/login with MFA-enabled user', () => {
|
||||
@@ -263,6 +299,87 @@ describe('POST /api/auth/login/mfa', () => {
|
||||
expect(hashes.length).toBe(remaining - 1);
|
||||
});
|
||||
|
||||
it('enforces single-use when the same backup code is submitted concurrently', async () => {
|
||||
const u = 'mfauser-backup-race';
|
||||
const p = 'mfapassword123';
|
||||
const { backupCodes: codes } = await seedMfaUser(u, p);
|
||||
const chosen = codes[0];
|
||||
|
||||
// Force the race deterministically rather than relying on scheduling: hold
|
||||
// both requests just after verifyBackupCode (so both have already read the
|
||||
// same stored set and matched) until both have arrived, then let them race
|
||||
// into the atomic consume. The pre-fix non-atomic path would let both win
|
||||
// here; the fix must still let exactly one through. A timeout releases the
|
||||
// barrier if only one request ever arrives, so a setup fault fails loudly
|
||||
// instead of hanging.
|
||||
let arrived = 0;
|
||||
let releaseBarrier!: () => void;
|
||||
const bothVerified = new Promise<void>((resolve) => { releaseBarrier = resolve; });
|
||||
const realVerify = MfaService.verifyBackupCode.bind(MfaService);
|
||||
const spy = vi.spyOn(MfaService, 'verifyBackupCode').mockImplementation(async (hashes, code) => {
|
||||
const result = await realVerify(hashes, code);
|
||||
arrived += 1;
|
||||
if (arrived >= 2) releaseBarrier();
|
||||
await Promise.race([bothVerified, new Promise<void>((r) => setTimeout(r, 3000))]);
|
||||
return result;
|
||||
});
|
||||
|
||||
try {
|
||||
const [login1, login2] = await Promise.all([
|
||||
request(app).post('/api/auth/login').send({ username: u, password: p }),
|
||||
request(app).post('/api/auth/login').send({ username: u, password: p }),
|
||||
]);
|
||||
const pending1 = findCookie(login1.headers, 'sencho_mfa_pending')!;
|
||||
const pending2 = findCookie(login2.headers, 'sencho_mfa_pending')!;
|
||||
|
||||
const [r1, r2] = await Promise.all([
|
||||
request(app).post('/api/auth/login/mfa').set('Cookie', pending1).send({ code: chosen, isBackupCode: true }),
|
||||
request(app).post('/api/auth/login/mfa').set('Cookie', pending2).send({ code: chosen, isBackupCode: true }),
|
||||
]);
|
||||
|
||||
expect([r1, r2].filter((r) => r.status === 200)).toHaveLength(1);
|
||||
expect([r1, r2].filter((r) => r.status === 401)).toHaveLength(1);
|
||||
|
||||
// Exactly one code was consumed from the stored set.
|
||||
const db = DatabaseService.getInstance();
|
||||
const mfa = db.getUserMfa(db.getUserByUsername(u)!.id)!;
|
||||
const hashes = mfa.backup_codes_json ? (JSON.parse(mfa.backup_codes_json) as string[]) : [];
|
||||
expect(hashes.length).toBe(codes.length - 1);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('exhausts all backup codes: each works once, then none remain', async () => {
|
||||
const u = 'mfauser-backup-exhaust';
|
||||
const p = 'mfapassword123';
|
||||
const { backupCodes: codes } = await seedMfaUser(u, p);
|
||||
|
||||
for (const code of codes) {
|
||||
const login = await request(app).post('/api/auth/login').send({ username: u, password: p });
|
||||
const pending = findCookie(login.headers, 'sencho_mfa_pending')!;
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login/mfa')
|
||||
.set('Cookie', pending)
|
||||
.send({ code, isBackupCode: true });
|
||||
expect(res.status).toBe(200);
|
||||
}
|
||||
|
||||
const db = DatabaseService.getInstance();
|
||||
const mfa = db.getUserMfa(db.getUserByUsername(u)!.id)!;
|
||||
const remaining = mfa.backup_codes_json ? (JSON.parse(mfa.backup_codes_json) as string[]) : [];
|
||||
expect(remaining.length).toBe(0);
|
||||
|
||||
// A further attempt with a spent code is rejected cleanly, not crashed.
|
||||
const login = await request(app).post('/api/auth/login').send({ username: u, password: p });
|
||||
const pending = findCookie(login.headers, 'sencho_mfa_pending')!;
|
||||
const after = await request(app)
|
||||
.post('/api/auth/login/mfa')
|
||||
.set('Cookie', pending)
|
||||
.send({ code: codes[0], isBackupCode: true });
|
||||
expect(after.status).toBe(401);
|
||||
});
|
||||
|
||||
it('locks the user after MFA_MAX_FAILED (5) wrong codes and returns 423', async () => {
|
||||
const u = 'mfauser-lock';
|
||||
const p = 'mfapassword123';
|
||||
@@ -383,6 +500,24 @@ describe('MFA enrol + confirm', () => {
|
||||
expect(res.status).toBe(401);
|
||||
expect(db.getUserMfa(user.id)?.enabled).toBe(1);
|
||||
});
|
||||
|
||||
it('disables MFA when a valid backup code is supplied as proof of possession', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const { userId, backupCodes } = await seedMfaUser('disabler-backup', 'mfapassword123');
|
||||
const user = db.getUser(userId)!;
|
||||
const token = jwt.sign(
|
||||
{ username: 'disabler-backup', role: 'viewer', tv: user.token_version },
|
||||
TEST_JWT_SECRET,
|
||||
{ expiresIn: '1m' },
|
||||
);
|
||||
const res = await request(app)
|
||||
.post('/api/auth/mfa/disable')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ code: backupCodes[0], isBackupCode: true });
|
||||
expect(res.status).toBe(200);
|
||||
// Disable wipes the whole MFA record, so single-use of the code is moot here.
|
||||
expect(db.getUserMfa(userId)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Admin reset ──────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user