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>
439 lines
14 KiB
Dart
439 lines
14 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:clock/clock.dart';
|
|
import 'package:halkyon_base_server/base.dart';
|
|
import 'package:serverpod/serverpod.dart';
|
|
import 'package:serverpod_auth_idp_server/core.dart';
|
|
import 'package:test/test.dart';
|
|
|
|
import '../support/fixtures.dart';
|
|
import '../test_tools/serverpod_test_tools.dart';
|
|
|
|
/// Keeps every code mailed, in place of a mail service.
|
|
final class _Mailbox implements CodeMailer {
|
|
final sent = <({String to, String code, String language})>[];
|
|
|
|
@override
|
|
Future<void> send(
|
|
Session session, {
|
|
required String to,
|
|
required String code,
|
|
required String language,
|
|
}) async {
|
|
sent.add((to: to, code: code, language: language));
|
|
}
|
|
|
|
@override
|
|
Future<void> sendResetNotice(
|
|
Session session, {
|
|
required String to,
|
|
required AccountResetKind kind,
|
|
required DateTime takesEffectAt,
|
|
required String language,
|
|
}) async {}
|
|
|
|
/// The last code mailed to [address].
|
|
String codeFor(String address) =>
|
|
sent.lastWhere((mail) => mail.to == address).code;
|
|
}
|
|
|
|
/// A mail service that takes no mail.
|
|
final class _Refusing implements CodeMailer {
|
|
@override
|
|
Future<void> send(
|
|
Session session, {
|
|
required String to,
|
|
required String code,
|
|
required String language,
|
|
}) async => throw const SocketException('Connection refused');
|
|
|
|
@override
|
|
Future<void> sendResetNotice(
|
|
Session session, {
|
|
required String to,
|
|
required AccountResetKind kind,
|
|
required DateTime takesEffectAt,
|
|
required String language,
|
|
}) async => throw const SocketException('Connection refused');
|
|
}
|
|
|
|
void main() {
|
|
withServerpod(
|
|
'Codes by mail',
|
|
// Mailing and accepting codes count attempts in transactions of their
|
|
// own (Serverpod's rate limiter), which the test's database rollback
|
|
// can't wrap. So these tests delete what they make instead.
|
|
rollbackDatabase: RollbackDatabase.disabled,
|
|
(sessionBuilder, endpoints) {
|
|
late Session session;
|
|
late _Mailbox mailbox;
|
|
|
|
/// Anonymous, signed in on her phone.
|
|
late UuidValue anna;
|
|
late UuidValue annasPhone;
|
|
|
|
/// Identified, signing in with ben@example.org.
|
|
late UuidValue ben;
|
|
|
|
/// Identified, signing in with GitHub.
|
|
late UuidValue carla;
|
|
|
|
setUp(() async {
|
|
useBase(sessionBuilder);
|
|
session = sessionBuilder.build();
|
|
await _deleteAll(session);
|
|
mailbox = _Mailbox();
|
|
EmailCodes.of(session.serverpod).mailer = mailbox;
|
|
|
|
anna = (await AuthServices.instance.authUsers.create(session)).id;
|
|
await AnonymousAccount.db.insertRow(
|
|
session,
|
|
AnonymousAccount(authUserId: anna),
|
|
);
|
|
annasPhone = await signInNewDevice(session, anna);
|
|
ben = (await AuthServices.instance.authUsers.create(
|
|
session,
|
|
scopes: {identified},
|
|
)).id;
|
|
await EmailIdentity.db.insertRow(
|
|
session,
|
|
EmailIdentity(
|
|
authUserId: ben,
|
|
addressHash: addressHash(session.serverpod, 'ben@example.org'),
|
|
createdAt: clock.now(),
|
|
),
|
|
);
|
|
await signInNewDevice(
|
|
session,
|
|
ben,
|
|
scopes: {identified},
|
|
);
|
|
carla = (await AuthServices.instance.authUsers.create(
|
|
session,
|
|
scopes: {identified},
|
|
)).id;
|
|
await GitHubAccount.db.insertRow(
|
|
session,
|
|
GitHubAccount(userIdentifier: '1001', authUserId: carla),
|
|
);
|
|
await signInNewDevice(
|
|
session,
|
|
carla,
|
|
scopes: {identified},
|
|
);
|
|
});
|
|
tearDown(() async {
|
|
EmailCodes.of(session.serverpod).mailer = null;
|
|
await _deleteAll(sessionBuilder.build());
|
|
});
|
|
|
|
TestSessionBuilder signedIn(
|
|
UuidValue person,
|
|
UuidValue sessionId, {
|
|
bool isIdentified = false,
|
|
}) => sessionBuilder.copyWith(
|
|
authentication: AuthenticationOverride.authenticationInfo(
|
|
person.uuid,
|
|
{if (isIdentified) identified},
|
|
authId: sessionId.uuid,
|
|
),
|
|
);
|
|
TestSessionBuilder asAnna() => signedIn(anna, annasPhone);
|
|
TestSessionBuilder notSignedIn() => sessionBuilder.copyWith(
|
|
authentication: AuthenticationOverride.unauthenticated(),
|
|
);
|
|
|
|
/// Mails a code to [address] for [as] and types it.
|
|
group('refuses', () {
|
|
test('nothing that turns a code into a session: a code signs nobody '
|
|
"in, and makes no address anyone's", () async {
|
|
final request = await endpoints.emailCode.sendCode(
|
|
notSignedIn(),
|
|
address: 'anna@example.org',
|
|
language: 'en',
|
|
);
|
|
|
|
expect(
|
|
await proveAddressCode(
|
|
session,
|
|
request: request,
|
|
code: mailbox.codeFor('anna@example.org'),
|
|
),
|
|
addressHash(session.serverpod, 'anna@example.org'),
|
|
);
|
|
expect(await _addressesOf(session, anna), isEmpty);
|
|
expect(await ServerSideSession.db.count(session), 3);
|
|
expect(await EmailCodeRequest.db.count(session), 0);
|
|
});
|
|
|
|
test(
|
|
'everything on an instance that sends no mail, and says so',
|
|
() async {
|
|
EmailCodes.of(session.serverpod).mailer = null;
|
|
|
|
await expectLater(
|
|
endpoints.emailCode.sendCode(
|
|
asAnna(),
|
|
address: 'anna@example.org',
|
|
language: 'en',
|
|
),
|
|
throwsA(_refusal(EmailSignInRefusal.unavailable)),
|
|
);
|
|
expect(
|
|
(await endpoints.instance.basics(notSignedIn())).emailSignIn,
|
|
isFalse,
|
|
);
|
|
},
|
|
);
|
|
|
|
test('a code it could not mail, keeping nothing of it', () async {
|
|
EmailCodes.of(session.serverpod).mailer = _Refusing();
|
|
|
|
await expectLater(
|
|
endpoints.emailCode.sendCode(
|
|
asAnna(),
|
|
address: 'anna@example.org',
|
|
language: 'en',
|
|
),
|
|
throwsA(_refusal(EmailSignInRefusal.mailFailed)),
|
|
);
|
|
expect(await EmailCodeRequest.db.count(session), 0);
|
|
expect(await SecretChallenge.db.count(session), 0);
|
|
});
|
|
|
|
test('what is no address', () async {
|
|
for (final typed in ['anna', 'anna@', '@example.org', 'a b@c.de']) {
|
|
await expectLater(
|
|
endpoints.emailCode.sendCode(
|
|
asAnna(),
|
|
address: typed,
|
|
language: 'en',
|
|
),
|
|
throwsA(_refusal(EmailSignInRefusal.notAnAddress)),
|
|
reason: typed,
|
|
);
|
|
}
|
|
expect(mailbox.sent, isEmpty);
|
|
});
|
|
|
|
test('a wrong code, and after three wrong ones even the right one; '
|
|
'nothing changes', () async {
|
|
final request = await endpoints.emailCode.sendCode(
|
|
asAnna(),
|
|
address: 'anna@example.org',
|
|
language: 'en',
|
|
);
|
|
for (var i = 0; i < 3; i++) {
|
|
await expectLater(
|
|
proveAddressCode(
|
|
session,
|
|
request: request,
|
|
code: 'AAAA-AAAA',
|
|
),
|
|
throwsA(_refusal(EmailSignInRefusal.wrongCode)),
|
|
);
|
|
}
|
|
await expectLater(
|
|
proveAddressCode(
|
|
session,
|
|
request: request,
|
|
code: mailbox.codeFor('anna@example.org'),
|
|
),
|
|
throwsA(_refusal(EmailSignInRefusal.tooManyTries)),
|
|
);
|
|
expect(await _addressesOf(session, anna), isEmpty);
|
|
});
|
|
|
|
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',
|
|
language: 'en',
|
|
);
|
|
final lateCode = mailbox.codeFor('anna@example.org');
|
|
await withClock(
|
|
Clock.fixed(clock.now().add(const Duration(minutes: 16))),
|
|
() => expectLater(
|
|
proveAddressCode(
|
|
session,
|
|
request: late,
|
|
code: lateCode,
|
|
),
|
|
throwsA(_refusal(EmailSignInRefusal.codeExpired)),
|
|
),
|
|
);
|
|
|
|
Future<(UuidValue, String)> ask(String network) async {
|
|
final request = await sendSignInCode(
|
|
session,
|
|
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(
|
|
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 ask('anna@example.org', '198.51.100.${i ~/ 3}');
|
|
}
|
|
// Ten mailed to one address in the hour: no more, from anywhere.
|
|
await expectLater(
|
|
ask('anna@example.org', '192.0.2.200'),
|
|
throwsA(_refusal(EmailSignInRefusal.tooManyCodes)),
|
|
);
|
|
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 {
|
|
await expectLater(
|
|
endpoints.account.signInWays(notSignedIn()),
|
|
throwsA(isA<ServerpodUnauthenticatedException>()),
|
|
);
|
|
});
|
|
});
|
|
|
|
test('mails a code in the language asked for, the same whether anybody '
|
|
'has the address or not', () async {
|
|
final known = await endpoints.emailCode.sendCode(
|
|
asAnna(),
|
|
address: 'ben@example.org',
|
|
language: 'de',
|
|
);
|
|
final unknown = await endpoints.emailCode.sendCode(
|
|
asAnna(),
|
|
address: ' Nobody@Example.org ',
|
|
language: 'fr',
|
|
);
|
|
|
|
expect(known, isNot(unknown));
|
|
expect(
|
|
[for (final mail in mailbox.sent) (mail.to, mail.language)],
|
|
[('ben@example.org', 'de'), ('nobody@example.org', 'en')],
|
|
);
|
|
expect(
|
|
mailbox.sent.map((mail) => mail.code),
|
|
everyElement(matches(RegExp(r'^[0-9A-Z]{4}-[0-9A-Z]{4}$'))),
|
|
);
|
|
});
|
|
|
|
test('tells the app that this instance offers it', () async {
|
|
expect(
|
|
(await endpoints.instance.basics(notSignedIn())).emailSignIn,
|
|
true,
|
|
);
|
|
});
|
|
|
|
test('deleting a person deletes their address', () async {
|
|
await deletePerson(session, ben);
|
|
|
|
expect(await EmailIdentity.db.count(session), 0);
|
|
});
|
|
|
|
test('forgets the codes past their time, hourly', () async {
|
|
await endpoints.emailCode.sendCode(
|
|
asAnna(),
|
|
address: 'anna@example.org',
|
|
language: 'en',
|
|
);
|
|
final now = clock.now();
|
|
|
|
expect(await forgetExpiredEmailCodes(session, now: now), 0);
|
|
expect(
|
|
await forgetExpiredEmailCodes(
|
|
session,
|
|
now: now.add(const Duration(minutes: 16)),
|
|
),
|
|
1,
|
|
);
|
|
expect(await EmailCodeRequest.db.count(session), 0);
|
|
expect(await SecretChallenge.db.count(session), 0);
|
|
});
|
|
},
|
|
);
|
|
}
|
|
|
|
Matcher _refusal(EmailSignInRefusal reason) =>
|
|
isA<EmailSignInRefusedException>().having(
|
|
(exception) => exception.reason,
|
|
'reason',
|
|
reason,
|
|
);
|
|
|
|
/// The hashes of the addresses [person] signs in with.
|
|
Future<List<String>> _addressesOf(Session session, UuidValue person) async => [
|
|
for (final identity in await EmailIdentity.db.find(
|
|
session,
|
|
where: (t) => t.authUserId.equals(person),
|
|
))
|
|
identity.addressHash,
|
|
];
|
|
|
|
/// Deletes every rate-limit record, code, address and person; their
|
|
/// sessions and other identities go with them (`ON DELETE CASCADE`).
|
|
Future<void> _deleteAll(Session session) async {
|
|
await RateLimitedRequestAttempt.db.deleteWhere(
|
|
session,
|
|
where: (t) => t.id.notEquals(null),
|
|
);
|
|
await EmailCodeRequest.db.deleteWhere(
|
|
session,
|
|
where: (t) => t.id.notEquals(null),
|
|
);
|
|
await SecretChallenge.db.deleteWhere(
|
|
session,
|
|
where: (t) => t.id.notEquals(null),
|
|
);
|
|
await EmailIdentity.db.deleteWhere(
|
|
session,
|
|
where: (t) => t.id.notEquals(null),
|
|
);
|
|
await AuthUser.db.deleteWhere(session, where: (t) => t.id.notEquals(null));
|
|
}
|