fix(backend): reject a replayed TOTP code within its validity window (#1389)

* fix(backend): reject a replayed TOTP code within its validity window

verifyTotp() was stateless — otplib's window:1 tolerance meant the
same 6-digit code could complete two independent logins inside its
~90s validity window. Track each admin's last-consumed step and
reject a code that doesn't advance past it.

* fix(backend): make the TOTP replay-tracking persist atomic

verifyTotpEncryptedStep() read two_factor_last_used_step, then a plain
UPDATE wrote the new step with no conditional guard — two concurrent
requests carrying the same captured code could both pass the check
before either UPDATE landed. The persist is now a conditional UPDATE
(only advances the step, checked via affected-row count), so a losing
concurrent request is correctly treated as a replay.

---------

Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
Paul Nothaft
2026-09-11 10:40:27 +02:00
committed by GitHub
co-authored by Paul Nothaft
parent e3247911a0
commit cdde937d7f
7 changed files with 426 additions and 13 deletions
@@ -0,0 +1,29 @@
/**
* Migration 213: TOTP replay protection for admin MFA (GHSA-qcwx-r25m-j869).
*
* verifyTotp()/verifyTotpEncrypted() were stateless: otplib's window:1
* tolerance means a captured 6-digit code stays valid across several real
* time-steps (~90s), so the same code could complete two independent admin
* logins. `two_factor_last_used_step` tracks, per admin, the absolute TOTP
* time-step (Math.floor(Date.now() / 30000)) that their last successfully
* consumed code matched; mfaService now rejects a code whose matched step
* doesn't advance past it.
*
* Additive and idempotent: only adds a column, guarded by hasColumn, so it
* is safe to re-run and touches no existing data.
*/
exports.up = async function (knex) {
if (!(await knex.schema.hasColumn('admin_users', 'two_factor_last_used_step'))) {
await knex.schema.alterTable('admin_users', (t) => {
t.integer('two_factor_last_used_step').nullable();
});
}
};
exports.down = async function (knex) {
if (await knex.schema.hasColumn('admin_users', 'two_factor_last_used_step')) {
await knex.schema.alterTable('admin_users', (t) => {
t.dropColumn('two_factor_last_used_step');
});
}
};