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
5 changed files with 441 additions and 8 deletions
|
|
@ -20,6 +20,8 @@ export 'src/generated/protocol.dart' hide Protocol;
|
|||
export 'src/halkyon_base.dart';
|
||||
export 'src/instance/data_location.dart';
|
||||
export 'src/instance/source_code.dart';
|
||||
export 'src/password/account_resets.dart'
|
||||
show forgetLapsedResets, resetLapsesAfter;
|
||||
export 'src/password/authenticators.dart'
|
||||
show
|
||||
forgetUnconfirmedAuthenticators,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import 'dart:typed_data';
|
|||
import 'package:clock/clock.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/password/account_resets.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/register/sessions_ended.dart';
|
||||
|
|
@ -255,7 +256,9 @@ Future<AccountSessionsEnded> completeAccountReset(
|
|||
t.kind.equals(AccountResetKind.account),
|
||||
transaction: transaction,
|
||||
)).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);
|
||||
}
|
||||
if (reset.takesEffectAt.isAfter(now)) {
|
||||
|
|
@ -371,7 +374,18 @@ Future<ResetPending> _askForReset(
|
|||
transaction: transaction,
|
||||
);
|
||||
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",
|
||||
// "createdAt") VALUES ($1, $2, $3, $4)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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/scopes.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/password_config.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 confirmed = await confirmedAuthenticatorsOf(session, account);
|
||||
// SELECT * FROM "account_reset" WHERE "authUserId" = $1 AND "kind" = $2
|
||||
final reset = await AccountReset.db.findFirstRow(
|
||||
var reset = await AccountReset.db.findFirstRow(
|
||||
session,
|
||||
where: (t) =>
|
||||
t.authUserId.equals(account) &
|
||||
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 totp = totpCode?.trim() ?? '';
|
||||
final recovery = recoveryCode?.trim() ?? '';
|
||||
|
|
@ -1055,7 +1063,10 @@ Future<bool> secondFactorWorks(Session session, UuidValue person) async {
|
|||
t.authUserId.equals(person) &
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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