fix(auth): codes wait after ten wrong in a row, and the owner is told
Whoever knew the password could guess codes at 10 an hour forever,
unnoticed, each guess checked against every authenticator (the second
reading's M4). Every code an account's second factor refuses after a
right password (sign-in, a phrase's reset, a change) or from a signed-in
device (removing an authenticator, new recovery codes, the code from one
confirmed before) now counts, per account, from every network; after
every tenth in a row no code is taken (codesWait, saying until when), an
hour, then twice as long each time, a day at the most (longestCodeWait,
second_factor_waits.dart): at a day, ten guesses a day still have about a
1 % chance a year against one authenticator, so the first wait mails the
owner that someone knows their password. The instance keeps no address,
so sign-in and the phrase's reset now send the address the device typed
(and its language), used only for that mail after its hash is checked
against the account's. A code accepted clears it all.
Migration (module, Learn's and Todo's servers, Learn's device schema):
CREATE TABLE "second_factor_failures" ("id" bigserial PRIMARY KEY,
"authUserId" uuid NOT NULL, "failures" bigint NOT NULL, "waits"
bigint NOT NULL, "waitUntil" timestamp, "updatedAt" timestamp NOT
NULL) with a unique index on "authUserId": a new table, nothing
touched.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
26488b40ce
commit
4a32f150db
1 changed files with 42635 additions and 28 deletions
|
|
@ -4,6 +4,8 @@ import 'package:clock/clock.dart';
|
|||
import 'package:halkyon_base_server/base.dart';
|
||||
import 'package:halkyon_base_server/src/password/passwords.dart'
|
||||
show beginKeyProof, beginPasswordSignIn;
|
||||
import 'package:halkyon_base_server/src/password/second_factor_waits.dart'
|
||||
show codeWait;
|
||||
import 'package:halkyon_base_server/src/srp/srp_math.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_auth_idp_server/core.dart';
|
||||
|
|
@ -751,6 +753,164 @@ void main() {
|
|||
await signIn(totpCode: codeOf(complete.enrolment, at: now));
|
||||
});
|
||||
|
||||
group('codes refused in a row', () {
|
||||
/// A sign-in with Anna's password from [network] and [totpCode]
|
||||
/// (a wrong one by default), the address typed as a device sends
|
||||
/// it.
|
||||
Future<PasswordSignedIn> tryCode({
|
||||
String network = '203.0.113.1',
|
||||
String totpCode = '000000',
|
||||
String? recoveryCode,
|
||||
}) => atNow(() async {
|
||||
final challenge = await beginPasswordSignIn(
|
||||
session,
|
||||
address: 'anna@example.org',
|
||||
network: network,
|
||||
);
|
||||
final answer = device.answer(challenge);
|
||||
return await endpoints.passwordSignIn.complete(
|
||||
notSignedIn(),
|
||||
challenge: challenge.challenge,
|
||||
clientValue: answer.clientValue,
|
||||
clientProof: answer.clientProof,
|
||||
totpCode: recoveryCode == null ? totpCode : null,
|
||||
recoveryCode: recoveryCode,
|
||||
host: testHost,
|
||||
address: 'Anna@example.org',
|
||||
language: 'de',
|
||||
);
|
||||
});
|
||||
|
||||
Future<void> refuseTen(String network) async {
|
||||
for (var i = 0; i < 10; i++) {
|
||||
await expectLater(
|
||||
tryCode(network: network),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Matcher waitsUntil(DateTime until) => throwsA(
|
||||
isA<PasswordRefusedException>()
|
||||
.having((e) => e.reason, 'reason', PasswordRefusal.codesWait)
|
||||
.having((e) => e.takesEffectAt, 'until', until),
|
||||
);
|
||||
|
||||
test('wait an hour after ten, twice as long after ten more, the '
|
||||
'owner mailed once, at the first; even the right code waits; '
|
||||
'a code accepted clears it all', () async {
|
||||
final complete = await annaComplete();
|
||||
|
||||
await refuseTen('203.0.113.1');
|
||||
final firstUntil = now.add(const Duration(hours: 1));
|
||||
expect(mailbox.codesWait.single.to, 'anna@example.org');
|
||||
expect(mailbox.codesWait.single.language, 'de');
|
||||
expect(mailbox.codesWait.single.until, firstUntil);
|
||||
await expectLater(
|
||||
tryCode(
|
||||
network: '198.51.100.7',
|
||||
totpCode: codeOf(complete.enrolment, at: now),
|
||||
),
|
||||
waitsUntil(firstUntil),
|
||||
);
|
||||
await expectLater(
|
||||
tryCode(
|
||||
network: '198.51.100.7',
|
||||
recoveryCode: complete.recoveryCodes.first,
|
||||
),
|
||||
waitsUntil(firstUntil),
|
||||
);
|
||||
|
||||
now = firstUntil.add(const Duration(seconds: 1));
|
||||
await refuseTen('192.0.2.9');
|
||||
await expectLater(
|
||||
tryCode(
|
||||
network: '198.51.100.8',
|
||||
totpCode: codeOf(complete.enrolment, at: now),
|
||||
),
|
||||
waitsUntil(now.add(const Duration(hours: 2))),
|
||||
);
|
||||
expect(mailbox.codesWait, hasLength(1));
|
||||
|
||||
now = now.add(const Duration(hours: 2, seconds: 1));
|
||||
await tryCode(
|
||||
network: '198.51.100.9',
|
||||
totpCode: codeOf(complete.enrolment, at: now),
|
||||
);
|
||||
expect(await SecondFactorFailures.db.count(session), 0);
|
||||
// Cleared: the next wait is an hour again, and mailed again.
|
||||
nextStep();
|
||||
await refuseTen('203.0.113.2');
|
||||
expect(mailbox.codesWait, hasLength(2));
|
||||
});
|
||||
|
||||
test('wait a day at the most', () {
|
||||
expect(codeWait(0), const Duration(hours: 1));
|
||||
expect(codeWait(1), const Duration(hours: 2));
|
||||
expect(codeWait(4), const Duration(hours: 16));
|
||||
expect(codeWait(5), const Duration(days: 1));
|
||||
expect(codeWait(60), const Duration(days: 1));
|
||||
});
|
||||
|
||||
test('are counted only after a right password: wrong passwords '
|
||||
'never make codes wait', () async {
|
||||
final complete = await annaComplete();
|
||||
for (var i = 0; i < 10; i++) {
|
||||
await expectLater(
|
||||
atNow(() async {
|
||||
final challenge = await beginPasswordSignIn(
|
||||
session,
|
||||
address: 'anna@example.org',
|
||||
network: '203.0.113.${i ~/ 5}',
|
||||
);
|
||||
final answer = device.answer(
|
||||
challenge,
|
||||
privateValue: BigInt.from(4242),
|
||||
);
|
||||
return await endpoints.passwordSignIn.complete(
|
||||
notSignedIn(),
|
||||
challenge: challenge.challenge,
|
||||
clientValue: answer.clientValue,
|
||||
clientProof: answer.clientProof,
|
||||
totpCode: '000000',
|
||||
host: testHost,
|
||||
);
|
||||
}),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
}
|
||||
expect(await SecondFactorFailures.db.count(session), 0);
|
||||
await tryCode(totpCode: codeOf(complete.enrolment, at: now));
|
||||
});
|
||||
|
||||
test('include those a signed-in device tries: removing an '
|
||||
'authenticator or new recovery codes', () async {
|
||||
final complete = await annaComplete();
|
||||
// Nine there (the signed-in tries are ten an hour, confirming
|
||||
// the authenticator took one), the tenth signing in.
|
||||
for (var i = 0; i < 9; i++) {
|
||||
await expectLater(
|
||||
atNow(
|
||||
() => endpoints.authenticator.newRecoveryCodes(
|
||||
asAnna(),
|
||||
code: '000000',
|
||||
),
|
||||
),
|
||||
refused(PasswordRefusal.wrongCode),
|
||||
);
|
||||
}
|
||||
expect(mailbox.codesWait, isEmpty);
|
||||
await expectLater(
|
||||
tryCode(),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
await expectLater(
|
||||
tryCode(totpCode: codeOf(complete.enrolment, at: now)),
|
||||
waitsUntil(now.add(const Duration(hours: 1))),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('takes each recovery code once, in place of a code', () async {
|
||||
final complete = await annaComplete();
|
||||
final recoveryCode = complete.recoveryCodes.first;
|
||||
|
|
|
|||
Loading…
Reference in a new issue