fix(auth): codes after the recovery phrase count apart from the password's

The full reading's M3: whoever knows the password made the account's codes
wait, and that wait refused the phrase's owner their reset too, so they were
held out of both without end. Codes now count per account and per way they
came (SecondFactorPath: after the password, after the phrase), each with
its own waits; a code accepted either way clears both.

Migration (module, Learn's and Todo's servers; Learn's device schema
rebuilds the server-only table):
  DROP INDEX "second_factor_failures_person_idx";
  ALTER TABLE "second_factor_failures" ADD COLUMN "path" text NOT NULL
    DEFAULT 'password'::text;
  CREATE UNIQUE INDEX "second_factor_failures_person_idx" ON
    "second_factor_failures" USING btree ("authUserId", "path");
Existing rows are the password's, unique per account, so unique per
account and way too.

Also the hosts' generated future calls, for the pruning's doc comment the
lapsed resets' commit changed.

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-21 13:06:26 +02:00
commit 4d1cd2045c
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -1,7 +1,7 @@
import 'package:clock/clock.dart';
import 'package:halkyon_base_server/base.dart';
import 'package:halkyon_base_server/src/password/passwords.dart'
show beginPasswordSignIn, completePasswordSignIn;
show beginKeyProof, beginPasswordSignIn, completePasswordSignIn;
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
import 'package:test/test.dart';
@ -202,12 +202,99 @@ void main() {
);
}
/// Anna's password reset with her recovery phrase and [totp] (or
/// [recovery]), a new password for the same keys.
/// From [network] (5 proofs begin per address and network in an
/// hour); the new password [elsewhere]'s, so a refusal leaves the
/// device's as it was.
Future<AuthSuccess> resetWithPhrase({
String? totp,
String? recovery,
String network = '198.51.100.7',
PasswordDevice? elsewhere,
}) => atNow(() async {
final challenge = await beginKeyProof(
session,
address: 'anna@example.org',
network: network,
);
final newPassword = (elsewhere ?? device).newPassword();
return await endpoints.accountRecovery.resetPassword(
notSignedIn(),
challenge: challenge.challenge,
signature: await device.sign(challenge, password: newPassword),
password: newPassword,
totpCode: totp,
recoveryCode: recovery,
host: testHost,
address: 'anna@example.org',
language: 'en',
);
});
Future<List<AccountReset>> resets() => AccountReset.db.find(
session,
where: (t) => t.authUserId.equals(anna),
orderBy: (t) => t.kind,
);
group("the phrase's codes count apart from the password's (M3)", () {
test('codes waiting after the password never hold back the phrase: '
'its owner resets the password with it and a code', () async {
final enrolment = await annaComplete();
for (var i = 0; i < 10; i++) {
await expectLater(
signInFrom('192.0.2.9', totp: '000000'),
refused(PasswordRefusal.wrongCredentials),
);
}
expect(mailbox.codesWait, hasLength(1));
nextStep();
final signedIn = await resetWithPhrase(
totp: codeOf(enrolment, at: now),
);
expect(signedIn.authUserId, anna);
});
test('ten wrong codes after the phrase make its own codes wait, the '
"password's still taken", () async {
final enrolment = await annaComplete();
final elsewhere = await PasswordDevice.make(seed: 31);
for (var i = 0; i < 10; i++) {
await expectLater(
resetWithPhrase(
totp: '000000',
network: '192.0.2.$i',
elsewhere: elsewhere,
),
refused(PasswordRefusal.wrongCredentials),
);
}
expect(mailbox.codesWait, hasLength(1));
nextStep();
await expectLater(
resetWithPhrase(
totp: codeOf(enrolment, at: now),
elsewhere: elsewhere,
),
refused(PasswordRefusal.codesWait),
);
final signedIn = await signInFrom(
'198.51.100.7',
totp: codeOf(enrolment, at: now),
);
expect(signedIn.needsAuthenticator, isFalse);
// A code accepted clears both.
expect(
await SecondFactorFailures.db.count(
session,
where: (t) => t.authUserId.equals(anna),
),
0,
);
});
});
group('lapse a week after they took effect (M2)', () {
test('a reset of the authenticators not carried out: the password '
'alone signs in no more, a code still does', () async {