fix(auth): limits by address and network, so nobody holds an owner out
Anyone who knew an address could spend its whole hour: 10 sign-ins, 5 phrase proofs and 3 codes, each new code voiding the last (the second reading's M3). Now: - sign-ins begun count per address and network (10 an hour), phrase proofs likewise (5), and networks as before (30); - failed proofs count per address from every network: past 30 an hour (wrong passwords, phrases' signatures by another key, an address nobody has alike) no sign-in or proof begins for it anywhere, so guessing from many networks makes at most 30 guesses an hour, while holding an owner out takes failing without end from at least three; longer windows would outlive the hourly pruning of the limits' notes; - a signed-in change of password begins 10 times an hour per account; - codes by mail count per address and network (3 an hour), per address from every network (10, no mailbox flooded) and per network (10), and up to 3 are valid at once, a fourth voiding the oldest, so codes others ask for don't void the one its owner is typing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
5b9cb85b0f
commit
26488b40ce
2 changed files with 300 additions and 131 deletions
|
|
@ -244,8 +244,9 @@ void main() {
|
|||
expect(await _addressesOf(session, anna), isEmpty);
|
||||
});
|
||||
|
||||
test('a code past its 15 minutes, a code used, and one replaced by '
|
||||
'a newer code', () async {
|
||||
test('a code past its 15 minutes, a code used, and the oldest of '
|
||||
'four: up to three are valid at once, so codes others ask for '
|
||||
"don't void the one the owner is typing", () async {
|
||||
final late = await endpoints.emailCode.sendCode(
|
||||
asAnna(),
|
||||
address: 'anna@example.org',
|
||||
|
|
@ -264,75 +265,71 @@ void main() {
|
|||
),
|
||||
);
|
||||
|
||||
final replaced = await endpoints.emailCode.sendCode(
|
||||
asAnna(),
|
||||
address: 'anna@example.org',
|
||||
language: 'en',
|
||||
);
|
||||
final replacedCode = mailbox.codeFor('anna@example.org');
|
||||
final newer = await endpoints.emailCode.sendCode(
|
||||
asAnna(),
|
||||
address: 'anna@example.org',
|
||||
language: 'en',
|
||||
);
|
||||
await expectLater(
|
||||
proveAddressCode(
|
||||
Future<(UuidValue, String)> ask(String network) async {
|
||||
final request = await sendSignInCode(
|
||||
session,
|
||||
request: replaced,
|
||||
code: replacedCode,
|
||||
),
|
||||
throwsA(_refusal(EmailSignInRefusal.codeExpired)),
|
||||
);
|
||||
|
||||
final newerCode = mailbox.codeFor('anna@example.org');
|
||||
await proveAddressCode(
|
||||
session,
|
||||
request: newer,
|
||||
code: newerCode,
|
||||
);
|
||||
await expectLater(
|
||||
proveAddressCode(
|
||||
session,
|
||||
request: newer,
|
||||
code: newerCode,
|
||||
),
|
||||
throwsA(_refusal(EmailSignInRefusal.codeExpired)),
|
||||
);
|
||||
});
|
||||
|
||||
test('a fourth code for one address within an hour, and an '
|
||||
'eleventh from one network', () async {
|
||||
for (var i = 0; i < 3; i++) {
|
||||
await endpoints.emailCode.sendCode(
|
||||
asAnna(),
|
||||
address: 'anna@example.org',
|
||||
language: 'en',
|
||||
network: network,
|
||||
);
|
||||
return (request, mailbox.codeFor('anna@example.org'));
|
||||
}
|
||||
|
||||
final (owners, ownersCode) = await ask('203.0.113.1');
|
||||
final (second, secondCode) = await ask('198.51.100.7');
|
||||
final (third, thirdCode) = await ask('198.51.100.7');
|
||||
// Three valid: the owner's still is.
|
||||
await proveAddressCode(session, request: owners, code: ownersCode);
|
||||
final (fourth, fourthCode) = await ask('198.51.100.7');
|
||||
final (fifth, _) = await ask('192.0.2.9');
|
||||
// Five asked for, three valid: the two oldest left went.
|
||||
await expectLater(
|
||||
proveAddressCode(session, request: second, code: secondCode),
|
||||
throwsA(_refusal(EmailSignInRefusal.codeExpired)),
|
||||
);
|
||||
await proveAddressCode(session, request: third, code: thirdCode);
|
||||
await proveAddressCode(session, request: fourth, code: fourthCode);
|
||||
await expectLater(
|
||||
proveAddressCode(session, request: fourth, code: fourthCode),
|
||||
throwsA(_refusal(EmailSignInRefusal.codeExpired)),
|
||||
);
|
||||
expect(fifth, isNotNull);
|
||||
});
|
||||
|
||||
test('a fourth code for one address from one network within an '
|
||||
'hour, an eleventh for one address from any, and an eleventh '
|
||||
'from one network', () async {
|
||||
Future<void> ask(String address, String network) => sendSignInCode(
|
||||
session,
|
||||
address: address,
|
||||
language: 'en',
|
||||
network: network,
|
||||
);
|
||||
for (var i = 0; i < 3; i++) {
|
||||
await ask('anna@example.org', '203.0.113.1');
|
||||
}
|
||||
await expectLater(
|
||||
endpoints.emailCode.sendCode(
|
||||
asAnna(),
|
||||
address: 'Anna@Example.org',
|
||||
language: 'en',
|
||||
),
|
||||
ask('Anna@Example.org', '203.0.113.1'),
|
||||
throwsA(_refusal(EmailSignInRefusal.tooManyCodes)),
|
||||
);
|
||||
// Another network still gets one: whoever filled the first count
|
||||
// holds back no other, the owner's among them.
|
||||
for (var i = 0; i < 7; i++) {
|
||||
await endpoints.emailCode.sendCode(
|
||||
asAnna(),
|
||||
address: 'anna$i@example.org',
|
||||
language: 'en',
|
||||
);
|
||||
await ask('anna@example.org', '198.51.100.${i ~/ 3}');
|
||||
}
|
||||
// Ten mailed to one address in the hour: no more, from anywhere.
|
||||
await expectLater(
|
||||
endpoints.emailCode.sendCode(
|
||||
asAnna(),
|
||||
address: 'anna.else@example.org',
|
||||
language: 'en',
|
||||
),
|
||||
ask('anna@example.org', '192.0.2.200'),
|
||||
throwsA(_refusal(EmailSignInRefusal.tooManyCodes)),
|
||||
);
|
||||
expect(mailbox.sent, hasLength(10));
|
||||
for (var i = 0; i < 10; i++) {
|
||||
await ask('anna$i@example.org', '192.0.2.9');
|
||||
}
|
||||
await expectLater(
|
||||
ask('anna.else@example.org', '192.0.2.9'),
|
||||
throwsA(_refusal(EmailSignInRefusal.tooManyCodes)),
|
||||
);
|
||||
expect(mailbox.sent, hasLength(20));
|
||||
});
|
||||
|
||||
test('saying how someone signs in when not signed in', () async {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import 'dart:typed_data';
|
|||
|
||||
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/srp/srp_math.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_auth_idp_server/core.dart';
|
||||
|
|
@ -668,51 +670,85 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
test('counts at most 10 sign-ins begun for an address and 30 from a '
|
||||
'network in an hour, an unknown address as any', () async {
|
||||
test('counts sign-ins begun per address and network, 10 an hour, '
|
||||
'and per network, 30; another network signs the address in all '
|
||||
'the same, an unknown address counted as any', () async {
|
||||
Future<void> begin(String address, String network) => atNow(
|
||||
() => beginPasswordSignIn(
|
||||
session,
|
||||
address: address,
|
||||
network: network,
|
||||
),
|
||||
);
|
||||
for (final address in ['anna@example.org', 'nobody@example.org']) {
|
||||
for (var i = 0; i < 10; i++) {
|
||||
await atNow(
|
||||
() => endpoints.passwordSignIn.begin(
|
||||
notSignedIn(),
|
||||
address: address,
|
||||
),
|
||||
await begin(address, '203.0.113.1');
|
||||
}
|
||||
await expectLater(
|
||||
begin(address, '203.0.113.1'),
|
||||
refused(PasswordRefusal.tooManyAttempts),
|
||||
);
|
||||
// Whoever filled the first network's count holds no other back.
|
||||
await begin(address, '198.51.100.7');
|
||||
}
|
||||
for (var i = 0; i < 30; i++) {
|
||||
await begin('third$i@example.org', '192.0.2.9');
|
||||
}
|
||||
await expectLater(
|
||||
begin('fresh@example.org', '192.0.2.9'),
|
||||
refused(PasswordRefusal.tooManyAttempts),
|
||||
);
|
||||
now = now.add(const Duration(hours: 1, seconds: 1));
|
||||
await begin('anna@example.org', '203.0.113.1');
|
||||
});
|
||||
|
||||
test('counts failed proofs per address from every network: past 30 '
|
||||
'an hour, no sign-in begins for it anywhere, an address nobody '
|
||||
'has alike', () async {
|
||||
final complete = await annaComplete();
|
||||
Future<void> failFrom(String address, String network) => atNow(
|
||||
() async {
|
||||
final challenge = await beginPasswordSignIn(
|
||||
session,
|
||||
address: address,
|
||||
network: network,
|
||||
);
|
||||
final answer = device.answer(
|
||||
challenge,
|
||||
privateValue: BigInt.from(4242),
|
||||
);
|
||||
await expectLater(
|
||||
endpoints.passwordSignIn.complete(
|
||||
notSignedIn(),
|
||||
challenge: challenge.challenge,
|
||||
clientValue: answer.clientValue,
|
||||
clientProof: answer.clientProof,
|
||||
totpCode: '000000',
|
||||
host: testHost,
|
||||
),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
},
|
||||
);
|
||||
for (final address in ['anna@example.org', 'nobody@example.org']) {
|
||||
for (var i = 0; i < 30; i++) {
|
||||
// Ten from each network, within its own count.
|
||||
await failFrom(address, '203.0.113.${i ~/ 10}');
|
||||
}
|
||||
await expectLater(
|
||||
atNow(
|
||||
() => endpoints.passwordSignIn.begin(
|
||||
notSignedIn(),
|
||||
() => beginPasswordSignIn(
|
||||
session,
|
||||
address: address,
|
||||
network: '198.51.100.7',
|
||||
),
|
||||
),
|
||||
refused(PasswordRefusal.tooManyAttempts),
|
||||
);
|
||||
}
|
||||
for (var i = 0; i < 10; i++) {
|
||||
await atNow(
|
||||
() => endpoints.passwordSignIn.begin(
|
||||
notSignedIn(),
|
||||
address: 'third$i@example.org',
|
||||
),
|
||||
);
|
||||
}
|
||||
await expectLater(
|
||||
atNow(
|
||||
() => endpoints.passwordSignIn.begin(
|
||||
notSignedIn(),
|
||||
address: 'fresh@example.org',
|
||||
),
|
||||
),
|
||||
refused(PasswordRefusal.tooManyAttempts),
|
||||
);
|
||||
// An hour on, the count is past: the owner signs in.
|
||||
now = now.add(const Duration(hours: 1, seconds: 1));
|
||||
await atNow(
|
||||
() => endpoints.passwordSignIn.begin(
|
||||
notSignedIn(),
|
||||
address: 'anna@example.org',
|
||||
),
|
||||
);
|
||||
await signIn(totpCode: codeOf(complete.enrolment, at: now));
|
||||
});
|
||||
|
||||
test('takes each recovery code once, in place of a code', () async {
|
||||
|
|
@ -1771,25 +1807,65 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
test('counts at most 5 proofs begun for an address an hour', () async {
|
||||
for (var i = 0; i < 5; i++) {
|
||||
await atNow(
|
||||
() => endpoints.accountRecovery.beginKeyProof(
|
||||
notSignedIn(),
|
||||
address: 'nobody@example.org',
|
||||
test(
|
||||
'counts at most 5 proofs begun for an address from a network '
|
||||
'an hour; another network begins its own, and failed proofs '
|
||||
"count toward the address's ceiling as failed sign-ins do",
|
||||
() async {
|
||||
final complete = await annaComplete();
|
||||
Future<KeyProofChallenge> begin(String network) => atNow(
|
||||
() => beginKeyProof(
|
||||
session,
|
||||
address: 'anna@example.org',
|
||||
network: network,
|
||||
),
|
||||
);
|
||||
}
|
||||
await expectLater(
|
||||
atNow(
|
||||
() => endpoints.accountRecovery.beginKeyProof(
|
||||
notSignedIn(),
|
||||
address: 'nobody@example.org',
|
||||
for (var i = 0; i < 5; i++) {
|
||||
await begin('203.0.113.1');
|
||||
}
|
||||
await expectLater(
|
||||
begin('203.0.113.1'),
|
||||
refused(PasswordRefusal.tooManyAttempts),
|
||||
);
|
||||
// A proof by another key fails, and counts.
|
||||
for (var i = 0; i < 30; i++) {
|
||||
final challenge = await begin('198.51.100.${i ~/ 5}');
|
||||
final newPassword = device.newPassword();
|
||||
final signature = await device.sign(
|
||||
challenge,
|
||||
keys: await otherSigningKeys(),
|
||||
password: newPassword,
|
||||
);
|
||||
await expectLater(
|
||||
atNow(
|
||||
() => endpoints.accountRecovery.resetPassword(
|
||||
notSignedIn(),
|
||||
challenge: challenge.challenge,
|
||||
signature: signature,
|
||||
password: newPassword,
|
||||
totpCode: codeOf(complete.enrolment, at: now),
|
||||
host: testHost,
|
||||
),
|
||||
),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
}
|
||||
await expectLater(
|
||||
begin('192.0.2.9'),
|
||||
refused(PasswordRefusal.tooManyAttempts),
|
||||
);
|
||||
await expectLater(
|
||||
atNow(
|
||||
() => beginPasswordSignIn(
|
||||
session,
|
||||
address: 'anna@example.org',
|
||||
network: '192.0.2.9',
|
||||
),
|
||||
),
|
||||
),
|
||||
refused(PasswordRefusal.tooManyAttempts),
|
||||
);
|
||||
});
|
||||
refused(PasswordRefusal.tooManyAttempts),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('a lost password without the phrase', () {
|
||||
|
|
|
|||
Loading…
Reference in a new issue