feat(auth): a session ended for its wrong proofs warns its owner

The tenth wrong owner proof in a row ends a session (the third
reading); someone holding that session tried the password or its
codes. The register now keeps the moment with the account's password
(`account_password.sessionEndedForProofsAt`): the account's sign-in
ways and its next password sign-in carry it, until the owner dismisses
it (`password.dismissSessionEndedWarning`, their session alone: it
hides a warning and secures nothing) or sets a new password. A member
ending its own session tells the register
(`noteSessionEndedForProofs`); a register out of reach leaves the
owner unwarned, the session ended all the same.

Migration (module, Learn's and Todo's servers, Learn's device schema):
ALTER TABLE "account_password" ADD COLUMN "sessionEndedForProofsAt"
timestamp without time zone; nullable, so every row keeps its values
and has no warning.

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 12:05:18 +02:00
commit 5e7d7f4e36
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -422,6 +422,77 @@ void main() {
await change(from: onLaptop());
});
group('a session ended for its wrong proofs leaves its owner a '
'warning', () {
/// Sends ten wrong proofs from Anna's phone, the tenth ending its
/// session; the moment it ended.
Future<DateTime> endPhone() async {
final other = await PasswordDevice.make();
for (var i = 0; i < 9; i++) {
await expectLater(
change(totpCode: '000000', password: other.newPassword()),
refused(PasswordRefusal.wrongCredentials),
);
}
await expectLater(
change(totpCode: '000000', password: other.newPassword()),
refused(PasswordRefusal.sessionEnded),
);
return now;
}
Future<DateTime?> warningOn(TestSessionBuilder device) async =>
(await endpoints.account.signInWays(
device,
)).sessionEndedForProofsAt;
test('which her sign-in ways and her next sign-in tell, with when, '
'until she dismisses it', () async {
expect(await warningOn(onLaptop()), isNull);
final ended = await endPhone();
expect(await warningOn(onLaptop()), ended);
nextStep();
expect((await signIn()).sessionEndedForProofsAt, ended);
// Told, not dismissed: it stays until she says so.
expect(await warningOn(onLaptop()), ended);
await endpoints.password.dismissSessionEndedWarning(onLaptop());
expect(await warningOn(onLaptop()), isNull);
nextStep();
expect((await signIn()).sessionEndedForProofsAt, isNull);
});
test('which a new password clears', () async {
await endPhone();
nextStep();
await change(from: onLaptop());
expect(await warningOn(onLaptop()), isNull);
});
test('which nobody else reads or dismisses, nor anyone signed '
'out', () async {
final ended = await endPhone();
final bert = await newAnonymousPerson(session);
final bertsPhone = await signInNewDevice(session, bert);
final asBert = sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
bert.uuid,
{},
authId: bertsPhone.uuid,
),
);
expect(await warningOn(asBert), isNull);
await endpoints.password.dismissSessionEndedWarning(asBert);
await expectLater(
endpoints.password.dismissSessionEndedWarning(notSignedIn()),
throwsA(isA<ServerpodUnauthenticatedException>()),
);
expect(await warningOn(onLaptop()), ended);
});
});
test('a proof accepted clears the count: wrong ones before it never '
'add up to ten with those after', () async {
final other = await PasswordDevice.make();