fix(auth): a reset not carried out lapses a week after it took effect
The full reading's M2: a reset of the authenticators asked and forgotten left the account to the password alone for good, and an account's reset could be carried out a year after it was asked. Now a reset that took effect and wasn't carried out lapses a week later, as never asked: the password alone no longer signs in, a code still does, and asked again it waits anew with a new mail. A reset of the authenticators carried out never lapses (the password alone signs in until one is confirmed). The daily pruning forgets lapsed rows (forgetLapsedResets). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
ac7007e19b
commit
43fcc8aac2
1 changed files with 441 additions and 8 deletions
|
|
@ -0,0 +1,329 @@
|
|||
import 'package:clock/clock.dart';
|
||||
import 'package:halkyon_base_server/base.dart';
|
||||
import 'package:halkyon_base_server/src/password/passwords.dart'
|
||||
show beginPasswordSignIn, completePasswordSignIn;
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_auth_idp_server/core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../support/fixtures.dart';
|
||||
import '../support/mailbox.dart';
|
||||
import '../support/password_device.dart';
|
||||
import '../support/password_support.dart';
|
||||
import '../test_tools/serverpod_test_tools.dart';
|
||||
|
||||
// Resets an owner can't be held out of calling off, and resets that lapse
|
||||
// (ADR 0017, the full reading of 21 Oct 2026: its H1, M2 and M3).
|
||||
void main() {
|
||||
withServerpod(
|
||||
'Resets',
|
||||
// Rate limits and taken challenges write in transactions of their own,
|
||||
// which the test's rollback can't wrap: these tests delete what they
|
||||
// make instead.
|
||||
rollbackDatabase: RollbackDatabase.disabled,
|
||||
(sessionBuilder, endpoints) {
|
||||
late Session session;
|
||||
late Serverpod pod;
|
||||
late Mailbox mailbox;
|
||||
late PasswordDevice device;
|
||||
|
||||
/// The tests' time, moved on by hand.
|
||||
late DateTime now;
|
||||
|
||||
/// Anonymous, signed in on her phone.
|
||||
late UuidValue anna;
|
||||
late UuidValue annasPhone;
|
||||
|
||||
setUp(() async {
|
||||
useBase(sessionBuilder);
|
||||
session = sessionBuilder.build();
|
||||
pod = session.serverpod;
|
||||
await deleteAll(session);
|
||||
mailbox = Mailbox();
|
||||
EmailCodes.of(pod).mailer = mailbox;
|
||||
usePasswordKeys(pod, testPasswordKeys);
|
||||
now = DateTime.utc(2026, 10, 20, 12);
|
||||
anna = await newAnonymousPerson(session);
|
||||
annasPhone = await signInNewDevice(session, anna);
|
||||
device = await PasswordDevice.make();
|
||||
});
|
||||
tearDown(() async {
|
||||
EmailCodes.of(pod).mailer = null;
|
||||
usePasswordKeys(pod, null);
|
||||
await deleteAll(sessionBuilder.build());
|
||||
});
|
||||
|
||||
/// [body] at the tests' time.
|
||||
Future<T> atNow<T>(Future<T> Function() body) =>
|
||||
withClock(Clock(() => now), body);
|
||||
|
||||
/// The next TOTP step: a code of the last is used.
|
||||
void nextStep() => now = now.add(totpPeriod);
|
||||
|
||||
TestSessionBuilder asAnna() => sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
anna.uuid,
|
||||
{},
|
||||
authId: annasPhone.uuid,
|
||||
),
|
||||
);
|
||||
TestSessionBuilder notSignedIn() => sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.unauthenticated(),
|
||||
);
|
||||
|
||||
/// A code mailed to [address] for [purpose]: the request and the code.
|
||||
Future<({UuidValue request, String code})> mailedCode(
|
||||
String address, {
|
||||
EmailCodePurpose purpose = EmailCodePurpose.signUp,
|
||||
}) => atNow(() async {
|
||||
final request = await endpoints.emailCode.sendCode(
|
||||
notSignedIn(),
|
||||
address: address,
|
||||
language: 'en',
|
||||
purpose: purpose,
|
||||
);
|
||||
return (request: request, code: mailbox.codeFor(address));
|
||||
});
|
||||
|
||||
/// Anna's password alone, for a change's challenge.
|
||||
Future<OwnerProof> passwordAlone() => atNow(
|
||||
() async => device.ownerProof(
|
||||
await endpoints.password.beginChange(asAnna()),
|
||||
),
|
||||
);
|
||||
|
||||
/// Anna: a password and a confirmed authenticator.
|
||||
Future<AuthenticatorEnrolment> annaComplete() => atNow(() async {
|
||||
final code = await mailedCode('anna@example.org');
|
||||
await endpoints.password.set(
|
||||
asAnna(),
|
||||
setup: device.setup(),
|
||||
request: code.request,
|
||||
code: code.code,
|
||||
);
|
||||
final enrolment = await endpoints.authenticator.enrol(
|
||||
asAnna(),
|
||||
name: 'Phone',
|
||||
label: 'anna@example.org',
|
||||
);
|
||||
await endpoints.authenticator.confirm(
|
||||
asAnna(),
|
||||
authenticator: enrolment.authenticator,
|
||||
code: codeOf(enrolment),
|
||||
proof: await passwordAlone(),
|
||||
);
|
||||
nextStep();
|
||||
return enrolment;
|
||||
});
|
||||
|
||||
/// A sign-in of Anna's from [network], with [totp] or none.
|
||||
Future<PasswordProof> signInFrom(
|
||||
String network, {
|
||||
String? totp,
|
||||
PasswordDevice? with_,
|
||||
}) => atNow(() async {
|
||||
final challenge = await beginPasswordSignIn(
|
||||
session,
|
||||
address: 'anna@example.org',
|
||||
network: network,
|
||||
);
|
||||
final answer = (with_ ?? device).answer(challenge);
|
||||
return await completePasswordSignIn(
|
||||
session,
|
||||
challenge: challenge.challenge,
|
||||
clientValue: answer.clientValue,
|
||||
clientProof: answer.clientProof,
|
||||
totpCode: totp,
|
||||
host: testHost,
|
||||
address: 'anna@example.org',
|
||||
language: 'en',
|
||||
);
|
||||
});
|
||||
|
||||
/// Whoever knows Anna's password and has her mailbox asks a reset of
|
||||
/// her authenticators.
|
||||
Future<ResetPending> askAuthenticatorReset() async {
|
||||
final code = await mailedCode(
|
||||
'anna@example.org',
|
||||
purpose: EmailCodePurpose.authenticatorReset,
|
||||
);
|
||||
return await atNow(() async {
|
||||
final challenge = await endpoints.passwordSignIn.begin(
|
||||
notSignedIn(),
|
||||
address: 'anna@example.org',
|
||||
);
|
||||
final answer = device.answer(challenge);
|
||||
return await endpoints.accountRecovery.resetAuthenticator(
|
||||
notSignedIn(),
|
||||
challenge: challenge.challenge,
|
||||
clientValue: answer.clientValue,
|
||||
clientProof: answer.clientProof,
|
||||
address: 'anna@example.org',
|
||||
language: 'en',
|
||||
request: code.request,
|
||||
code: code.code,
|
||||
host: testHost,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Whoever has Anna's mailbox asks the reset of her account.
|
||||
Future<ResetPending> askAccountReset() async {
|
||||
final code = await mailedCode(
|
||||
'anna@example.org',
|
||||
purpose: EmailCodePurpose.accountReset,
|
||||
);
|
||||
return await atNow(
|
||||
() => endpoints.accountRecovery.startAccountReset(
|
||||
notSignedIn(),
|
||||
address: 'anna@example.org',
|
||||
language: 'en',
|
||||
request: code.request,
|
||||
code: code.code,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Carries out the account's reset with [setup], as whoever has the
|
||||
/// mailbox does once it is due.
|
||||
Future<AuthSuccess> completeAccountReset(PasswordSetup setup) async {
|
||||
final code = await mailedCode(
|
||||
'anna@example.org',
|
||||
purpose: EmailCodePurpose.accountReset,
|
||||
);
|
||||
return await atNow(
|
||||
() => endpoints.accountRecovery.completeAccountReset(
|
||||
notSignedIn(),
|
||||
address: 'anna@example.org',
|
||||
request: code.request,
|
||||
code: code.code,
|
||||
setup: setup,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<AccountReset>> resets() => AccountReset.db.find(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(anna),
|
||||
orderBy: (t) => t.kind,
|
||||
);
|
||||
|
||||
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 {
|
||||
final enrolment = await annaComplete();
|
||||
final asked = await askAuthenticatorReset();
|
||||
now = asked.takesEffectAt.add(
|
||||
resetLapsesAfter + const Duration(minutes: 1),
|
||||
);
|
||||
await expectLater(
|
||||
signInFrom('198.51.100.7'),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
expect(await resets(), isEmpty);
|
||||
expect(
|
||||
await TotpAuthenticator.db.count(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(anna),
|
||||
),
|
||||
1,
|
||||
);
|
||||
final signedIn = await signInFrom(
|
||||
'198.51.100.7',
|
||||
totp: codeOf(enrolment, at: now),
|
||||
);
|
||||
expect(signedIn.needsAuthenticator, isFalse);
|
||||
});
|
||||
|
||||
test('within the week, the password alone carries it out', () async {
|
||||
await annaComplete();
|
||||
final asked = await askAuthenticatorReset();
|
||||
now = asked.takesEffectAt.add(
|
||||
resetLapsesAfter - const Duration(minutes: 1),
|
||||
);
|
||||
final signedIn = await signInFrom('198.51.100.7');
|
||||
expect(signedIn.needsAuthenticator, isTrue);
|
||||
});
|
||||
|
||||
test('once carried out, it never lapses: the password alone signs in '
|
||||
'until an authenticator is confirmed', () async {
|
||||
await annaComplete();
|
||||
final asked = await askAuthenticatorReset();
|
||||
now = asked.takesEffectAt;
|
||||
expect(
|
||||
(await signInFrom('198.51.100.7')).needsAuthenticator,
|
||||
isTrue,
|
||||
);
|
||||
now = now.add(resetLapsesAfter * 4);
|
||||
expect(
|
||||
(await signInFrom('198.51.100.8')).needsAuthenticator,
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('a reset of the account: its code a year later carries out '
|
||||
'nothing, and it can be asked again, with a new mail', () async {
|
||||
await annaComplete();
|
||||
final asked = await askAccountReset();
|
||||
now = asked.takesEffectAt.add(
|
||||
resetLapsesAfter + const Duration(minutes: 1),
|
||||
);
|
||||
final other = await PasswordDevice.make(seed: 5);
|
||||
await expectLater(
|
||||
completeAccountReset(other.setup()),
|
||||
refused(PasswordRefusal.noReset),
|
||||
);
|
||||
expect(mailbox.notices, hasLength(1));
|
||||
final again = await askAccountReset();
|
||||
expect(again.takesEffectAt, now.add(resetDelay));
|
||||
expect(mailbox.notices, hasLength(2));
|
||||
await expectLater(
|
||||
completeAccountReset(other.setup()),
|
||||
refused(PasswordRefusal.notDue),
|
||||
);
|
||||
});
|
||||
|
||||
test('asked again after it lapsed, a reset of the authenticators '
|
||||
'waits anew, with a new mail', () async {
|
||||
await annaComplete();
|
||||
final asked = await askAuthenticatorReset();
|
||||
now = asked.takesEffectAt.add(
|
||||
resetLapsesAfter + const Duration(minutes: 1),
|
||||
);
|
||||
final again = await askAuthenticatorReset();
|
||||
expect(again.takesEffectAt, now.add(resetDelay));
|
||||
expect(mailbox.notices, hasLength(2));
|
||||
});
|
||||
|
||||
test('are pruned, those carried out staying', () async {
|
||||
await annaComplete();
|
||||
final asked = await askAccountReset();
|
||||
await askAuthenticatorReset();
|
||||
now = asked.takesEffectAt.add(
|
||||
resetLapsesAfter + const Duration(minutes: 1),
|
||||
);
|
||||
// Bob's authenticators' reset, carried out long ago.
|
||||
final bob = await newAnonymousPerson(session);
|
||||
await AccountReset.db.insertRow(
|
||||
session,
|
||||
AccountReset(
|
||||
authUserId: bob,
|
||||
kind: AccountResetKind.authenticator,
|
||||
takesEffectAt: DateTime.utc(2025),
|
||||
createdAt: DateTime.utc(2025),
|
||||
),
|
||||
);
|
||||
expect(await forgetLapsedResets(session, now: now), 2);
|
||||
expect(await resets(), isEmpty);
|
||||
expect(
|
||||
await AccountReset.db.count(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(bob),
|
||||
),
|
||||
1,
|
||||
);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue