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
7 changed files with 441 additions and 8 deletions
|
|
@ -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.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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/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
|
||||||
|
|
|
||||||
|
|
@ -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