Codes by mail were tied to nothing but their address (the re-review's
seventh finding): a hostile instance could answer that a setup never
finished, have the person type a code into it, relay it to start the real
instance's account reset, and 72 hours later relay a second to finish it
with its own password and keys; the mail said "to sign in" whatever the
code was for. A code is now asked for a purpose (signUp, finishSetup,
authenticatorReset, accountReset, deletion), only that purpose takes it (any
other is refused as no longer valid, leaving the code as it was), and its
mail says what it would do ("to reset your account on learn.halkyon.eu: a
new password and new keys, and what was encrypted with the old ones is
lost") and to type it only in the app on that instance, nowhere else. The
app asks for each code with the purpose of its step.
Migration (module, Learn's and Todo's servers, Learn's device schema):
ALTER TABLE "email_code_request" ADD COLUMN "purpose" text; nullable,
so the requests of the last 15 minutes before it stay, taken by nothing
and forgotten within the hour.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
497 lines
16 KiB
Dart
497 lines
16 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 EmailCodePurpose purpose,
|
|
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 {}
|
|
|
|
@override
|
|
Future<void> sendCodesWaitNotice(
|
|
Session session, {
|
|
required String to,
|
|
required DateTime until,
|
|
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 EmailCodePurpose purpose,
|
|
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');
|
|
|
|
@override
|
|
Future<void> sendCodesWaitNotice(
|
|
Session session, {
|
|
required String to,
|
|
required DateTime until,
|
|
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',
|
|
purpose: EmailCodePurpose.signUp,
|
|
);
|
|
|
|
expect(
|
|
await proveAddressCode(
|
|
session,
|
|
request: request,
|
|
code: mailbox.codeFor('anna@example.org'),
|
|
purpose: EmailCodePurpose.signUp,
|
|
),
|
|
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',
|
|
purpose: EmailCodePurpose.signUp,
|
|
),
|
|
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',
|
|
purpose: EmailCodePurpose.signUp,
|
|
),
|
|
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',
|
|
purpose: EmailCodePurpose.signUp,
|
|
),
|
|
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',
|
|
purpose: EmailCodePurpose.signUp,
|
|
);
|
|
for (var i = 0; i < 3; i++) {
|
|
await expectLater(
|
|
proveAddressCode(
|
|
session,
|
|
request: request,
|
|
code: 'AAAA-AAAA',
|
|
purpose: EmailCodePurpose.signUp,
|
|
),
|
|
throwsA(_refusal(EmailSignInRefusal.wrongCode)),
|
|
);
|
|
}
|
|
await expectLater(
|
|
proveAddressCode(
|
|
session,
|
|
request: request,
|
|
code: mailbox.codeFor('anna@example.org'),
|
|
purpose: EmailCodePurpose.signUp,
|
|
),
|
|
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',
|
|
purpose: EmailCodePurpose.signUp,
|
|
);
|
|
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,
|
|
purpose: EmailCodePurpose.signUp,
|
|
),
|
|
throwsA(_refusal(EmailSignInRefusal.codeExpired)),
|
|
),
|
|
);
|
|
|
|
Future<(UuidValue, String)> ask(String network) async {
|
|
final request = await sendSignInCode(
|
|
session,
|
|
address: 'anna@example.org',
|
|
language: 'en',
|
|
network: network,
|
|
purpose: EmailCodePurpose.signUp,
|
|
);
|
|
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,
|
|
purpose: EmailCodePurpose.signUp,
|
|
);
|
|
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,
|
|
purpose: EmailCodePurpose.signUp,
|
|
),
|
|
throwsA(_refusal(EmailSignInRefusal.codeExpired)),
|
|
);
|
|
await proveAddressCode(
|
|
session,
|
|
request: third,
|
|
code: thirdCode,
|
|
purpose: EmailCodePurpose.signUp,
|
|
);
|
|
await proveAddressCode(
|
|
session,
|
|
request: fourth,
|
|
code: fourthCode,
|
|
purpose: EmailCodePurpose.signUp,
|
|
);
|
|
await expectLater(
|
|
proveAddressCode(
|
|
session,
|
|
request: fourth,
|
|
code: fourthCode,
|
|
purpose: EmailCodePurpose.signUp,
|
|
),
|
|
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,
|
|
purpose: EmailCodePurpose.signUp,
|
|
);
|
|
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',
|
|
purpose: EmailCodePurpose.signUp,
|
|
);
|
|
final unknown = await endpoints.emailCode.sendCode(
|
|
asAnna(),
|
|
address: ' Nobody@Example.org ',
|
|
language: 'fr',
|
|
purpose: EmailCodePurpose.signUp,
|
|
);
|
|
|
|
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',
|
|
purpose: EmailCodePurpose.signUp,
|
|
);
|
|
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));
|
|
}
|