fix(auth): a session's wrong codes end the session, not the account's codes

Wrong codes for removing an authenticator, new recovery codes or confirming
one counted toward the account's waits (the re-review's third finding): a
stolen session alone, without the password, could keep the owner's codes
waiting for up to a day at a time, renewed without end, holding them out
of signing in and of changing the password, the way to end that session,
and nobody was mailed. Those codes now count per session, where the session
is (the register's own, or a member's, which forwards the code): each try
counted before its code is checked (one statement, so tries sent at once
count each), a code accepted clearing the count, and the tenth wrong in a
row ending the session (sessionEnded; the device says it was signed out).
A try that checked no code (a refusal of something else) is taken back.
The account's waits now count only codes after a right password, and the
per-account ten tries an hour, which a session could fill as well, go.

Migration (module, Learn's and Todo's servers, Learn's device schema):
CREATE TABLE "session_code_failures" ("id" bigserial PRIMARY KEY,
"sessionId" uuid NOT NULL, "authUserId" uuid NOT NULL, "failures"
bigint NOT NULL, "updatedAt" timestamp NOT NULL) with a unique index on
"sessionId": a new table, nothing touched. Its rows go with a code
accepted, with the person, and hourly once their session is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-20 22:53:20 +02:00
commit e8c4a3d472
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -933,12 +933,10 @@ void main() {
await tryCode(totpCode: codeOf(complete.enrolment, at: now));
});
test('include those a signed-in device tries: removing an '
'authenticator or new recovery codes', () async {
test("never count a signed-in device's codes: a stolen session "
'holds nobody out of their codes', () async {
final complete = await annaComplete();
// Nine there (the signed-in tries are ten an hour, confirming
// the authenticator took one), the tenth signing in.
for (var i = 0; i < 9; i++) {
for (var i = 0; i < 10; i++) {
await expectLater(
atNow(
() => endpoints.authenticator.newRecoveryCodes(
@ -946,18 +944,16 @@ void main() {
code: '000000',
),
),
refused(PasswordRefusal.wrongCode),
refused(
i < 9
? PasswordRefusal.wrongCode
: PasswordRefusal.sessionEnded,
),
);
}
expect(await SecondFactorFailures.db.count(session), 0);
expect(mailbox.codesWait, isEmpty);
await expectLater(
tryCode(),
refused(PasswordRefusal.wrongCredentials),
);
await expectLater(
tryCode(totpCode: codeOf(complete.enrolment, at: now)),
waitsUntil(now.add(const Duration(hours: 1))),
);
await tryCode(totpCode: codeOf(complete.enrolment, at: now));
});
});
@ -1319,25 +1315,110 @@ void main() {
);
});
test('take at most 10 codes an hour from a signed-in person', () async {
test('count the codes a signed-in session tries: the tenth wrong '
'in a row ends it; a right one before clears the count', () async {
final complete = await annaComplete();
Future<List<String>> newCodes(String code) => atNow(
() => endpoints.authenticator.newRecoveryCodes(
asAnna(),
code: code,
),
);
for (var i = 0; i < 9; i++) {
await expectLater(
newCodes('000000'),
refused(PasswordRefusal.wrongCode),
);
}
await newCodes(codeOf(complete.enrolment, at: now));
nextStep();
for (var i = 0; i < 9; i++) {
await expectLater(
newCodes('000000'),
refused(PasswordRefusal.wrongCode),
);
}
expect(
await ServerSideSession.db.findById(session, annasPhone),
isNotNull,
);
await expectLater(
newCodes('000000'),
refused(PasswordRefusal.sessionEnded),
);
expect(
await ServerSideSession.db.findById(session, annasPhone),
isNull,
);
// Kept until the hourly pruning, then forgotten with the session.
expect(
await forgetUnconfirmedAuthenticators(session, now: now),
greaterThanOrEqualTo(1),
);
expect(await SessionCodeFailures.db.count(session), 0);
// The account's codes never wait for it.
expect(await SecondFactorFailures.db.count(session), 0);
await signIn(totpCode: codeOf(complete.enrolment, at: now));
});
test('count codes a session sends at once before they are checked: '
'nine checked, the session ended', () async {
await annaComplete();
final reasons = await Future.wait([
for (var i = 0; i < 30; i++)
atNow(() async {
try {
await endpoints.authenticator.newRecoveryCodes(
asAnna(),
code: '${100000 + i}',
);
return null;
} on PasswordRefusedException catch (refusal) {
return refusal.reason;
}
}),
]);
expect(
reasons.where((r) => r == PasswordRefusal.wrongCode),
hasLength(9),
);
expect(
reasons.where((r) => r == PasswordRefusal.sessionEnded),
hasLength(21),
);
expect(
await ServerSideSession.db.findById(session, annasPhone),
isNull,
);
});
test('take back a try that checked no code', () async {
final complete = await annaComplete();
for (var i = 0; i < 9; i++) {
await expectLater(
endpoints.authenticator.newRecoveryCodes(
asAnna(),
code: '000000',
atNow(
() => endpoints.authenticator.newRecoveryCodes(
asAnna(),
code: '000000',
),
),
refused(PasswordRefusal.wrongCode),
);
}
// The last authenticator: refused before any code is checked.
await expectLater(
atNow(
() => endpoints.authenticator.newRecoveryCodes(
() => endpoints.authenticator.remove(
asAnna(),
code: codeOf(complete.enrolment, at: now),
authenticator: complete.enrolment.authenticator,
code: '000000',
),
),
refused(PasswordRefusal.tooManyAttempts),
refused(PasswordRefusal.lastAuthenticator),
);
expect(
(await SessionCodeFailures.db.findFirstRow(session))?.failures,
9,
);
});