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
4 changed files with 41919 additions and 34 deletions

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();

View file

@ -57,6 +57,15 @@ final class _Register implements AccountRegister {
/// The hosts sign-ins were bound to, as members named them.
final hosts = <String>[];
/// The accounts warned that a session was ended for its wrong proofs, and
/// those whose warning was dismissed.
final warned = <UuidValue>{};
final dismissed = <UuidValue>{};
/// Thrown by [noteSessionEndedForProofs] alone while set (as [failing]
/// throws from everything): the register down as a member tells it.
Exception? notingFails;
Future<T> _answer<T>(String call, T Function() answer) async {
asked.add(call);
if (failing case final error?) {
@ -199,6 +208,26 @@ final class _Register implements AccountRegister {
),
);
@override
Future<void> noteSessionEndedForProofs(
Session session,
UuidValue person,
) async {
// Not through [_answer]: a session's tenth wrong proof comes from the
// register refusing it, while [failing] refuses everything.
asked.add('noteSessionEndedForProofs');
if (notingFails case final error?) {
throw error;
}
warned.add(person);
}
@override
Future<void> dismissSessionEndedWarning(Session session, UuidValue person) =>
_answer('dismissSessionEndedWarning', () {
dismissed.add(person);
});
@override
Future<bool> passwordSignInOffered(Session session) =>
_answer('passwordSignInOffered', () => passwordOffered);
@ -1199,6 +1228,7 @@ void main() {
),
);
}
expect(register.warned, isEmpty);
await expectLater(
newCodes(),
throwsA(
@ -1213,6 +1243,61 @@ void main() {
await ServerSideSession.db.findById(session, bensLaptop),
isNull,
);
// The owner's warning is the register's to keep.
expect(register.warned, {ben});
});
test('ends the session all the same when the register, down, '
"can't be told to warn its owner", () async {
register
..failing = PasswordRefusedException(
reason: PasswordRefusal.wrongCredentials,
)
..notingFails = RegisterUnavailableException('test');
for (var i = 0; i < 10; i++) {
await expectLater(
endpoints.authenticator.newRecoveryCodes(
asBen(),
proof: OwnerProof(
challenge: const Uuid().v7obj(),
clientValue: ByteData(384),
clientProof: ByteData(32),
host: testHost,
totpCode: '000000',
),
),
throwsA(
isA<PasswordRefusedException>().having(
(refusal) => refusal.reason,
'reason',
i < 9
? PasswordRefusal.wrongCredentials
: PasswordRefusal.sessionEnded,
),
),
);
}
expect(
await ServerSideSession.db.findById(session, bensLaptop),
isNull,
);
expect(register.asked.last, 'noteSessionEndedForProofs');
expect(register.warned, isEmpty);
});
test("passes its person's dismissing of the warning to the register, "
"never another's", () async {
await endpoints.password.dismissSessionEndedWarning(asBen());
expect(register.dismissed, {ben});
await expectLater(
endpoints.password.dismissSessionEndedWarning(
sessionBuilder.copyWith(
authentication: AuthenticationOverride.unauthenticated(),
),
),
throwsA(isA<ServerpodUnauthenticatedException>()),
);
expect(register.dismissed, {ben});
});
test('a reset ends the sessions here made before the register ended '

View file

@ -562,6 +562,18 @@ void main() {
);
final ways = await remote.signInWaysOf(session, annaOnTodo);
expect(ways.password && ways.authenticator, isTrue);
expect(ways.sessionEndedForProofsAt, isNull);
// A member's session ended for its wrong proofs: the owner's
// warning, kept here, read and dismissed through the member.
await remote.noteSessionEndedForProofs(session, annaOnTodo);
final warned = now;
expect(
(await remote.signInWaysOf(
session,
annaOnTodo,
)).sessionEndedForProofsAt,
warned,
);
now = now.add(totpPeriod);
final challenge = await remote.beginPasswordSignIn(
@ -583,6 +595,15 @@ void main() {
expect(proof.account, annaOnTodo);
expect(answer.verifiesServer(proof.serverProof), isTrue);
expect(proof.passwordKeys.toJson(), device.passwordKeys.toJson());
expect(proof.sessionEndedForProofsAt, warned);
await remote.dismissSessionEndedWarning(session, annaOnTodo);
expect(
(await remote.signInWaysOf(
session,
annaOnTodo,
)).sessionEndedForProofsAt,
isNull,
);
final again = await remote.beginPasswordSignIn(
session,
address: 'anna@example.org',

View file

@ -1110,6 +1110,36 @@ class _PasswordEndpoint {
}
});
}
_ida.Future<void> dismissSessionEndedWarning(
_ist.TestSessionBuilder sessionBuilder,
) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
endpoint: 'password',
method: 'dismissSessionEndedWarning',
);
try {
var _localCallContext = await _endpointDispatch.getMethodCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'password',
methodName: 'dismissSessionEndedWarning',
parameters: _ist.testObjectToJson({}),
serializationManager: _serializationManager,
);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<void>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
}
});
}
}
class _PasswordSignInEndpoint {