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
4 changed files with 41919 additions and 34 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 '
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue