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:
parent
858fffdce3
commit
5e7d7f4e36
1 changed files with 41919 additions and 34 deletions
|
|
@ -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 '
|
||||
|
|
|
|||
Loading…
Reference in a new issue