fix(auth): the password alone calls off an account's reset waiting
A sign-in with the password alone, once a reset of the authenticators took effect, left a reset of the account waiting: whoever read the owner's mail could ask for one while the owner waited, and carry it out after the owner was back (the second reading's M2). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
85be0a9acc
commit
38bb090575
2 changed files with 64 additions and 1 deletions
|
|
@ -733,7 +733,8 @@ Future<SignInChallenge> takeChallenge(
|
|||
/// either, only a reset of the authenticators that took effect signs in: it
|
||||
/// is carried out then (the authenticators and recovery codes go), and the
|
||||
/// account must set one up again; until one is confirmed, the password
|
||||
/// alone signs in. Refused ([PasswordRefusedException]): an account without
|
||||
/// alone signs in, and calls off a reset of the account waiting (its owner
|
||||
/// is back). Refused ([PasswordRefusedException]): an account without
|
||||
/// a confirmed authenticator (and no reset), [PasswordRefusal.setupIncomplete];
|
||||
/// no code while a reset waits, [PasswordRefusal.notDue]; anything else,
|
||||
/// [PasswordRefusal.wrongCredentials].
|
||||
|
|
@ -760,6 +761,7 @@ Future<bool> secondFactor(
|
|||
// Carried out before (or after the account's reset): nothing to delete,
|
||||
// and an authenticator enrolled since stays to be confirmed.
|
||||
if (resetTookEffect) {
|
||||
await _callOffAccountReset(session, account);
|
||||
return true;
|
||||
}
|
||||
throw refusedFor(PasswordRefusal.setupIncomplete);
|
||||
|
|
@ -776,6 +778,7 @@ Future<bool> secondFactor(
|
|||
}
|
||||
} else if (resetTookEffect) {
|
||||
await _carryOutAuthenticatorReset(session, account);
|
||||
await _callOffAccountReset(session, account);
|
||||
return true;
|
||||
} else if (reset != null) {
|
||||
throw PasswordRefusedException(
|
||||
|
|
@ -888,6 +891,19 @@ Future<void> _carryOutAuthenticatorReset(
|
|||
);
|
||||
});
|
||||
|
||||
/// Calls off the reset of [account] that a code by mail asked for: its
|
||||
/// owner signed in with the password alone, once a reset of the
|
||||
/// authenticators took effect. That reset's row stays: until an
|
||||
/// authenticator is confirmed, the password alone signs in.
|
||||
Future<void> _callOffAccountReset(Session session, UuidValue account) =>
|
||||
// DELETE FROM "account_reset" WHERE "authUserId" = $1 AND "kind" = $2
|
||||
AccountReset.db.deleteWhere(
|
||||
session,
|
||||
where: (t) =>
|
||||
t.authUserId.equals(account) &
|
||||
t.kind.equals(AccountResetKind.account),
|
||||
);
|
||||
|
||||
/// Calls off every reset of [account] waiting: its owner signed in.
|
||||
Future<void> _callOffResets(Session session, UuidValue account) =>
|
||||
// DELETE FROM "account_reset" WHERE "authUserId" = $1
|
||||
|
|
|
|||
|
|
@ -1680,6 +1680,53 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
test('is called off by signing in with the password alone, once a '
|
||||
"reset of the authenticators took effect: the mailbox's holder "
|
||||
"can't carry it out after the owner is back", () async {
|
||||
await annaComplete();
|
||||
// Anna loses her phone: her password and a code by mail ask.
|
||||
await atNow(() async {
|
||||
final challenge = await endpoints.passwordSignIn.begin(
|
||||
notSignedIn(),
|
||||
address: 'anna@example.org',
|
||||
);
|
||||
final answer = device.answer(challenge);
|
||||
final code = await mailedCode('anna@example.org');
|
||||
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,
|
||||
);
|
||||
});
|
||||
// An hour on, whoever reads her mail asks for the account's reset.
|
||||
now = now.add(const Duration(hours: 1));
|
||||
await start();
|
||||
|
||||
// Her own reset takes effect: the password alone signs her in.
|
||||
now = now.add(resetDelay - const Duration(minutes: 59));
|
||||
expect((await signIn()).needsAuthenticator, isTrue);
|
||||
// And once more, with nothing left to carry out.
|
||||
expect((await signIn()).needsAuthenticator, isTrue);
|
||||
|
||||
now = now.add(const Duration(hours: 1));
|
||||
await expectLater(
|
||||
finish(await PasswordDevice.make(seed: 99)),
|
||||
refused(PasswordRefusal.noReset),
|
||||
);
|
||||
expect(
|
||||
await AccountReset.db.count(
|
||||
session,
|
||||
where: (t) => t.kind.equals(AccountResetKind.account),
|
||||
),
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('is refused for a code to another address, and for an address '
|
||||
'without a password', () async {
|
||||
await annaSetsPassword();
|
||||
|
|
|
|||
Loading…
Reference in a new issue