mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-25 09:46:47 +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:
@@ -2752,6 +2752,33 @@ export class DatabaseService {
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically consume a single backup-code hash. Re-reads the stored set,
|
||||
* removes `matchedHash` if still present, and persists the shrunk set in
|
||||
* one synchronous transaction. Returns true when the hash was present (and
|
||||
* is now consumed), false when it was already gone (e.g. a concurrent
|
||||
* /login/mfa request carrying the same code consumed it first). This is the
|
||||
* single-use enforcement point: callers verify the code, then gate success
|
||||
* on this returning true, so two concurrent verifications of the same code
|
||||
* cannot both succeed.
|
||||
*/
|
||||
public consumeBackupCodeHash(userId: number, matchedHash: string): boolean {
|
||||
const consume = this.db.transaction((): boolean => {
|
||||
const row = this.db
|
||||
.prepare('SELECT backup_codes_json FROM user_mfa WHERE user_id = ?')
|
||||
.get(userId) as { backup_codes_json: string | null } | undefined;
|
||||
const hashes: string[] = row?.backup_codes_json ? JSON.parse(row.backup_codes_json) : [];
|
||||
const idx = hashes.indexOf(matchedHash);
|
||||
if (idx === -1) return false;
|
||||
hashes.splice(idx, 1);
|
||||
this.db
|
||||
.prepare('UPDATE user_mfa SET backup_codes_json = ?, updated_at = ? WHERE user_id = ?')
|
||||
.run(JSON.stringify(hashes), Date.now(), userId);
|
||||
return true;
|
||||
});
|
||||
return consume();
|
||||
}
|
||||
|
||||
// --- Role Assignments ---
|
||||
|
||||
public getRoleAssignments(userId: number, resourceType: ResourceType, resourceId: string): RoleAssignment[] {
|
||||
|
||||
Reference in New Issue
Block a user