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:
Tizian 「ティツィアーン」 2026-10-21 13:00:39 +02:00
commit 43fcc8aac2
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
7 changed files with 441 additions and 8 deletions

View file

@ -12,13 +12,16 @@ import 'package:serverpod/serverpod.dart';
/// through its `deletePerson`, so Learn's own data of theirs goes too. /// through its `deletePerson`, so Learn's own data of theirs goes too.
class PruningFutureCall extends FutureCall { class PruningFutureCall extends FutureCall {
/// Prunes one batch and logs how many people went; once GitHub sign-in /// Prunes one batch and logs how many people went; once GitHub sign-in
/// has ended (`GITHUB_SIGN_IN_ENDS`), forgets every GitHub ID too. /// has ended (`GITHUB_SIGN_IN_ENDS`), forgets every GitHub ID too; and
/// forgets the resets that lapsed (ADR 0017).
Future<void> pruneAnonymousPeople(Session session) async { Future<void> pruneAnonymousPeople(Session session) async {
final now = clock.now(); final now = clock.now();
final pruned = await pruneAbandonedAnonymousPeople(session, now: now); final pruned = await pruneAbandonedAnonymousPeople(session, now: now);
final gitHub = await forgetGitHubAccounts(session, now: now); final gitHub = await forgetGitHubAccounts(session, now: now);
final resets = await forgetLapsedResets(session, now: now);
session.log( session.log(
'Pruned $pruned abandoned anonymous people and $gitHub GitHub IDs.', 'Pruned $pruned abandoned anonymous people, $gitHub GitHub IDs and '
'$resets lapsed resets.',
); );
} }

View file

@ -11,13 +11,16 @@ import 'package:serverpod/serverpod.dart';
/// whose account the register no longer knows, daily. /// whose account the register no longer knows, daily.
class PruningFutureCall extends FutureCall { class PruningFutureCall extends FutureCall {
/// Prunes one batch and logs how many people went; once GitHub sign-in /// Prunes one batch and logs how many people went; once GitHub sign-in
/// has ended (`GITHUB_SIGN_IN_ENDS`), forgets every GitHub ID too. /// has ended (`GITHUB_SIGN_IN_ENDS`), forgets every GitHub ID too; and
/// forgets the resets that lapsed (ADR 0017).
Future<void> pruneAnonymousPeople(Session session) async { Future<void> pruneAnonymousPeople(Session session) async {
final now = clock.now(); final now = clock.now();
final pruned = await pruneAbandonedAnonymousPeople(session, now: now); final pruned = await pruneAbandonedAnonymousPeople(session, now: now);
final gitHub = await forgetGitHubAccounts(session, now: now); final gitHub = await forgetGitHubAccounts(session, now: now);
final resets = await forgetLapsedResets(session, now: now);
session.log( session.log(
'Pruned $pruned abandoned anonymous people and $gitHub GitHub IDs.', 'Pruned $pruned abandoned anonymous people, $gitHub GitHub IDs and '
'$resets lapsed resets.',
); );
} }

View file

@ -20,6 +20,8 @@ export 'src/generated/protocol.dart' hide Protocol;
export 'src/halkyon_base.dart'; export 'src/halkyon_base.dart';
export 'src/instance/data_location.dart'; export 'src/instance/data_location.dart';
export 'src/instance/source_code.dart'; export 'src/instance/source_code.dart';
export 'src/password/account_resets.dart'
show forgetLapsedResets, resetLapsesAfter;
export 'src/password/authenticators.dart' export 'src/password/authenticators.dart'
show show
forgetUnconfirmedAuthenticators, forgetUnconfirmedAuthenticators,

View file

@ -3,6 +3,7 @@ import 'dart:typed_data';
import 'package:clock/clock.dart'; import 'package:clock/clock.dart';
import 'package:halkyon_base_server/src/auth/email.dart'; import 'package:halkyon_base_server/src/auth/email.dart';
import 'package:halkyon_base_server/src/generated/protocol.dart'; import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:halkyon_base_server/src/password/account_resets.dart';
import 'package:halkyon_base_server/src/password/key_proof.dart'; import 'package:halkyon_base_server/src/password/key_proof.dart';
import 'package:halkyon_base_server/src/password/passwords.dart'; import 'package:halkyon_base_server/src/password/passwords.dart';
import 'package:halkyon_base_server/src/register/sessions_ended.dart'; import 'package:halkyon_base_server/src/register/sessions_ended.dart';
@ -255,7 +256,9 @@ Future<AccountSessionsEnded> completeAccountReset(
t.kind.equals(AccountResetKind.account), t.kind.equals(AccountResetKind.account),
transaction: transaction, transaction: transaction,
)).firstOrNull; )).firstOrNull;
if (reset == null) { // A week after it took effect, it lapsed (the full reading's M2): as
// never asked, its row left to the pruning.
if (reset == null || resetLapsed(reset, now)) {
throw refusedFor(PasswordRefusal.noReset); throw refusedFor(PasswordRefusal.noReset);
} }
if (reset.takesEffectAt.isAfter(now)) { if (reset.takesEffectAt.isAfter(now)) {
@ -371,7 +374,18 @@ Future<ResetPending> _askForReset(
transaction: transaction, transaction: transaction,
); );
if (waiting != null) { if (waiting != null) {
return ResetPending(takesEffectAt: waiting.takesEffectAt); // Asked again once it lapsed (the full reading's M2): it waits anew,
// with a new mail. (An authenticators' reset is asked only while one
// is confirmed, so never once carried out.)
if (!resetLapsed(waiting, now)) {
return ResetPending(takesEffectAt: waiting.takesEffectAt);
}
// DELETE FROM "account_reset" WHERE "id" = $1
await AccountReset.db.deleteRow(
session,
waiting,
transaction: transaction,
);
} }
// INSERT INTO "account_reset" ("authUserId", "kind", "takesEffectAt", // INSERT INTO "account_reset" ("authUserId", "kind", "takesEffectAt",
// "createdAt") VALUES ($1, $2, $3, $4) // "createdAt") VALUES ($1, $2, $3, $4)

View file

@ -0,0 +1,71 @@
import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:serverpod/serverpod.dart';
// Resets that wait (ADR 0017, D3), and how long they stay (the full
// reading's M2): a reset that took effect and was never carried out lapses
// a week later, as never asked, so an authenticator's reset asked and
// forgotten doesn't leave the account to the password alone for good, nor
// an account's reset to whoever holds the mailbox a year on. Asked again,
// it waits anew, with a new mail. A reset of the authenticators carried out
// never lapses: its row says the password alone signs in until an
// authenticator is confirmed, which removes it.
/// How long a reset that took effect waits to be carried out before it
/// lapses.
const resetLapsesAfter = Duration(days: 7);
/// Whether [reset] lapsed at [now]: a week or more since it took effect.
/// For a reset of the authenticators only while it isn't carried out (the
/// account still has a confirmed authenticator): the caller knows which.
bool resetLapsed(AccountReset reset, DateTime now) =>
!reset.takesEffectAt.add(resetLapsesAfter).isAfter(now);
/// Deletes the resets that lapsed at [now], and returns how many: an
/// account's, and an authenticators' reset not carried out (its account
/// still has a confirmed authenticator). Daily, with the pruning.
Future<int> forgetLapsedResets(
Session session, {
required DateTime now,
}) async {
final cutoff = now.toUtc().subtract(resetLapsesAfter);
// DELETE FROM "account_reset"
// WHERE "kind" = 'account' AND "takesEffectAt" <= $1 RETURNING *
final accounts = await AccountReset.db.deleteWhere(
session,
where: (t) =>
t.kind.equals(AccountResetKind.account) & (t.takesEffectAt <= cutoff),
);
// SELECT * FROM "account_reset"
// WHERE "kind" = 'authenticator' AND "takesEffectAt" <= $1
final candidates = await AccountReset.db.find(
session,
where: (t) =>
t.kind.equals(AccountResetKind.authenticator) &
(t.takesEffectAt <= cutoff),
);
if (candidates.isEmpty) {
return accounts.length;
}
// SELECT * FROM "totp_authenticator"
// WHERE "authUserId" = ANY($1) AND "confirmedAt" IS NOT NULL
final waiting = {
for (final row in await TotpAuthenticator.db.find(
session,
where: (t) =>
t.authUserId.inSet({
for (final reset in candidates) reset.authUserId,
}) &
t.confirmedAt.notEquals(null),
))
row.authUserId,
};
final lapsed = [
for (final reset in candidates)
if (waiting.contains(reset.authUserId)) reset,
];
if (lapsed.isNotEmpty) {
// DELETE FROM "account_reset" WHERE "id" = ANY($1)
await AccountReset.db.delete(session, lapsed);
}
return accounts.length + lapsed.length;
}

View file

@ -8,6 +8,7 @@ import 'package:halkyon_base_server/src/auth/email.dart';
import 'package:halkyon_base_server/src/auth/identified.dart'; import 'package:halkyon_base_server/src/auth/identified.dart';
import 'package:halkyon_base_server/src/auth/scopes.dart'; import 'package:halkyon_base_server/src/auth/scopes.dart';
import 'package:halkyon_base_server/src/generated/protocol.dart'; import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:halkyon_base_server/src/password/account_resets.dart';
import 'package:halkyon_base_server/src/password/key_proof.dart'; import 'package:halkyon_base_server/src/password/key_proof.dart';
import 'package:halkyon_base_server/src/password/password_config.dart'; import 'package:halkyon_base_server/src/password/password_config.dart';
import 'package:halkyon_base_server/src/password/second_factor_waits.dart'; import 'package:halkyon_base_server/src/password/second_factor_waits.dart';
@ -961,12 +962,19 @@ Future<SecondFactorPassed> secondFactor(
final now = clock.now().toUtc(); final now = clock.now().toUtc();
final confirmed = await confirmedAuthenticatorsOf(session, account); final confirmed = await confirmedAuthenticatorsOf(session, account);
// SELECT * FROM "account_reset" WHERE "authUserId" = $1 AND "kind" = $2 // SELECT * FROM "account_reset" WHERE "authUserId" = $1 AND "kind" = $2
final reset = await AccountReset.db.findFirstRow( var reset = await AccountReset.db.findFirstRow(
session, session,
where: (t) => where: (t) =>
t.authUserId.equals(account) & t.authUserId.equals(account) &
t.kind.equals(AccountResetKind.authenticator), t.kind.equals(AccountResetKind.authenticator),
); );
// Not carried out (an authenticator still confirmed) a week after it took
// effect, it lapsed (the full reading's M2): as never asked.
if (reset != null && confirmed.isNotEmpty && resetLapsed(reset, now)) {
// DELETE FROM "account_reset" WHERE "id" = $1
await AccountReset.db.deleteRow(session, reset);
reset = null;
}
final resetTookEffect = reset != null && !reset.takesEffectAt.isAfter(now); final resetTookEffect = reset != null && !reset.takesEffectAt.isAfter(now);
final totp = totpCode?.trim() ?? ''; final totp = totpCode?.trim() ?? '';
final recovery = recoveryCode?.trim() ?? ''; final recovery = recoveryCode?.trim() ?? '';
@ -1055,7 +1063,10 @@ Future<bool> secondFactorWorks(Session session, UuidValue person) async {
t.authUserId.equals(person) & t.authUserId.equals(person) &
t.kind.equals(AccountResetKind.authenticator), t.kind.equals(AccountResetKind.authenticator),
); );
return reset == null || reset.takesEffectAt.isAfter(clock.now()); final now = clock.now();
return reset == null ||
reset.takesEffectAt.isAfter(now) ||
resetLapsed(reset, now);
} }
/// Where to tell an account's owner that codes wait: the address the /// Where to tell an account's owner that codes wait: the address the

View file

@ -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,
);
});
});
},
);
}