Email sign-in's codes are mailed over SMTP (pub.dev's mailer), through whichever service the passwords name: emailSmtpHost, emailSmtpPort, emailSmtpUser, emailSmtpPassword and emailFrom (EMAIL_SMTP_* and EMAIL_FROM in deploy/.env, with EMAIL_SIGN_IN_PEPPER). TLS only: STARTTLS, or TLS directly on 465. learn.halkyon.eu sends through Proton's SMTP submission. Without a service, codes still go to the log in development, and elsewhere there is no email sign-in. The mail is plain text, in English or German: the code first and in the subject, so it shows in the notification and the system can offer it, the instance it signs in to, its 15 minutes, and that it can be ignored; no links, no tracking, no HTML. A mail that couldn't be sent is a refusal of its own (mailFailed): nothing of the request is kept, and the log names the error's type only, as the service's answer may name the address. A fake SMTP server on loopback drives the real mailer in the tests: it delivers, and nothing goes to a server without TLS. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
578 lines
19 KiB
Dart
578 lines
19 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:clock/clock.dart';
|
|
import 'package:halkyon_learn_server/src/auth/account.dart';
|
|
import 'package:halkyon_learn_server/src/auth/code_mail.dart';
|
|
import 'package:halkyon_learn_server/src/auth/email.dart';
|
|
import 'package:halkyon_learn_server/src/auth/scopes.dart';
|
|
import 'package:halkyon_learn_server/src/generated/protocol.dart';
|
|
import 'package:serverpod/serverpod.dart';
|
|
import 'package:serverpod_auth_idp_server/core.dart';
|
|
import 'package:test/test.dart';
|
|
|
|
import '../support/auth_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));
|
|
}
|
|
|
|
/// 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');
|
|
}
|
|
|
|
void main() {
|
|
withServerpod(
|
|
'Email sign-in',
|
|
// 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;
|
|
late UuidValue bensLaptop;
|
|
|
|
/// Identified, signing in with GitHub.
|
|
late UuidValue carla;
|
|
late UuidValue carlasPhone;
|
|
|
|
setUp(() async {
|
|
useServerAuth();
|
|
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(),
|
|
),
|
|
);
|
|
bensLaptop = 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),
|
|
);
|
|
carlasPhone = 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 asBen() =>
|
|
signedIn(ben, bensLaptop, isIdentified: true);
|
|
TestSessionBuilder asCarla() =>
|
|
signedIn(carla, carlasPhone, isIdentified: true);
|
|
TestSessionBuilder notSignedIn() => sessionBuilder.copyWith(
|
|
authentication: AuthenticationOverride.unauthenticated(),
|
|
);
|
|
|
|
/// Mails a code to [address] for [as] and types it.
|
|
Future<EmailCodeAccepted> signInWith(
|
|
TestSessionBuilder as,
|
|
String address,
|
|
) async {
|
|
final request = await endpoints.emailCode.sendCode(
|
|
as,
|
|
address: address,
|
|
language: 'en',
|
|
);
|
|
return await endpoints.emailCode.verifyCode(
|
|
as,
|
|
request: request,
|
|
code: mailbox.codeFor(address.trim().toLowerCase()),
|
|
);
|
|
}
|
|
|
|
group('refuses', () {
|
|
test('mailing and accepting codes when not signed in', () async {
|
|
await expectLater(
|
|
endpoints.emailCode.sendCode(
|
|
notSignedIn(),
|
|
address: 'anna@example.org',
|
|
language: 'en',
|
|
),
|
|
throwsA(isA<ServerpodUnauthenticatedException>()),
|
|
);
|
|
await expectLater(
|
|
endpoints.emailCode.verifyCode(
|
|
notSignedIn(),
|
|
request: const Uuid().v7obj(),
|
|
code: 'K7QX-M2PD',
|
|
),
|
|
throwsA(isA<ServerpodUnauthenticatedException>()),
|
|
);
|
|
expect(mailbox.sent, isEmpty);
|
|
});
|
|
|
|
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.info(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(
|
|
endpoints.emailCode.verifyCode(
|
|
asAnna(),
|
|
request: request,
|
|
code: 'AAAA-AAAA',
|
|
),
|
|
throwsA(_refusal(EmailSignInRefusal.wrongCode)),
|
|
);
|
|
}
|
|
await expectLater(
|
|
endpoints.emailCode.verifyCode(
|
|
asAnna(),
|
|
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 one replaced by '
|
|
'a newer code', () 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(
|
|
endpoints.emailCode.verifyCode(
|
|
asAnna(),
|
|
request: late,
|
|
code: lateCode,
|
|
),
|
|
throwsA(_refusal(EmailSignInRefusal.codeExpired)),
|
|
),
|
|
);
|
|
|
|
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(
|
|
endpoints.emailCode.verifyCode(
|
|
asAnna(),
|
|
request: replaced,
|
|
code: replacedCode,
|
|
),
|
|
throwsA(_refusal(EmailSignInRefusal.codeExpired)),
|
|
);
|
|
|
|
final newerCode = mailbox.codeFor('anna@example.org');
|
|
await endpoints.emailCode.verifyCode(
|
|
asAnna(),
|
|
request: newer,
|
|
code: newerCode,
|
|
);
|
|
await expectLater(
|
|
endpoints.emailCode.verifyCode(
|
|
asAnna(),
|
|
request: newer,
|
|
code: newerCode,
|
|
),
|
|
throwsA(_refusal(EmailSignInRefusal.codeExpired)),
|
|
);
|
|
});
|
|
|
|
test(
|
|
'a second address for a person who has one; nothing changes',
|
|
() async {
|
|
await expectLater(
|
|
signInWith(asBen(), 'ben@elsewhere.org'),
|
|
throwsA(_refusal(EmailSignInRefusal.otherAddressLinked)),
|
|
);
|
|
expect(await _addressesOf(session, ben), [
|
|
addressHash(session.serverpod, 'ben@example.org'),
|
|
]);
|
|
},
|
|
);
|
|
|
|
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',
|
|
);
|
|
}
|
|
await expectLater(
|
|
endpoints.emailCode.sendCode(
|
|
asAnna(),
|
|
address: 'Anna@Example.org',
|
|
language: 'en',
|
|
),
|
|
throwsA(_refusal(EmailSignInRefusal.tooManyCodes)),
|
|
);
|
|
for (var i = 0; i < 7; i++) {
|
|
await endpoints.emailCode.sendCode(
|
|
asAnna(),
|
|
address: 'anna$i@example.org',
|
|
language: 'en',
|
|
);
|
|
}
|
|
await expectLater(
|
|
endpoints.emailCode.sendCode(
|
|
asAnna(),
|
|
address: 'anna.else@example.org',
|
|
language: 'en',
|
|
),
|
|
throwsA(_refusal(EmailSignInRefusal.tooManyCodes)),
|
|
);
|
|
expect(mailbox.sent, hasLength(10));
|
|
});
|
|
|
|
test('a switch token used twice, or made up', () async {
|
|
final token = (await signInWith(
|
|
asAnna(),
|
|
'ben@example.org',
|
|
)).switchToken!;
|
|
await endpoints.emailSignIn.signIn(notSignedIn(), switchToken: token);
|
|
|
|
for (final used in [token, 'made-up']) {
|
|
await expectLater(
|
|
endpoints.emailSignIn.signIn(notSignedIn(), switchToken: used),
|
|
throwsA(_refusal(EmailSignInRefusal.codeExpired)),
|
|
);
|
|
}
|
|
});
|
|
|
|
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("a code for an address nobody has makes it the person's: they "
|
|
'become identified, on every session, and stop being anonymous; '
|
|
'the instance keeps only a keyed hash of it', () async {
|
|
final request = await endpoints.emailCode.sendCode(
|
|
asAnna(),
|
|
address: ' Anna@Example.org',
|
|
language: 'en',
|
|
);
|
|
final code = mailbox.codeFor('anna@example.org');
|
|
final accepted = await endpoints.emailCode.verifyCode(
|
|
asAnna(),
|
|
request: request,
|
|
// As typed off the mail: lower case, a space for the dash.
|
|
code: code.toLowerCase().replaceAll('-', ' '),
|
|
);
|
|
|
|
expect(accepted.switchToken, isNull);
|
|
final hash = addressHash(session.serverpod, 'anna@example.org');
|
|
expect(hash, matches(RegExp(r'^[0-9a-f]{64}$')));
|
|
expect(await _addressesOf(session, anna), [hash]);
|
|
final person = await AuthServices.instance.authUsers.get(
|
|
session,
|
|
authUserId: anna,
|
|
);
|
|
expect(person.scopeNames, contains(identified.name));
|
|
expect(
|
|
(await ServerSideSession.db.findById(
|
|
session,
|
|
annasPhone,
|
|
))?.scopeNames,
|
|
contains(identified.name),
|
|
);
|
|
expect(
|
|
await AnonymousAccount.db.count(
|
|
session,
|
|
where: (t) => t.authUserId.equals(anna),
|
|
),
|
|
0,
|
|
);
|
|
expect(await EmailCodeRequest.db.count(session), 0);
|
|
final kept = jsonEncode([
|
|
...await EmailIdentity.db.find(session),
|
|
...await EmailCodeRequest.db.find(session),
|
|
...await RateLimitedRequestAttempt.db.find(session),
|
|
]);
|
|
expect(kept.toLowerCase(), isNot(contains('anna')));
|
|
expect(kept, isNot(contains(code)));
|
|
});
|
|
|
|
test('a code for the address the person signs in with changes '
|
|
'nothing', () async {
|
|
final accepted = await signInWith(asBen(), 'ben@example.org');
|
|
|
|
expect(accepted.switchToken, isNull);
|
|
expect(await EmailIdentity.db.count(session), 1);
|
|
});
|
|
|
|
test("a code for another person's address changes nothing here, and "
|
|
'gives a token that signs in as them, without a session', () async {
|
|
final accepted = await signInWith(asAnna(), 'ben@example.org');
|
|
|
|
expect(accepted.switchToken, isNotNull);
|
|
expect(await _addressesOf(session, anna), isEmpty);
|
|
expect(
|
|
await AnonymousAccount.db.count(
|
|
session,
|
|
where: (t) => t.authUserId.equals(anna),
|
|
),
|
|
1,
|
|
);
|
|
|
|
final signedIn = await endpoints.emailSignIn.signIn(
|
|
notSignedIn(),
|
|
switchToken: accepted.switchToken!,
|
|
);
|
|
expect(signedIn.authUserId, ben);
|
|
expect(signedIn.scopeNames, contains(identified.name));
|
|
final sessions = await ServerSideSession.db.find(
|
|
session,
|
|
where: (t) => t.authUserId.equals(ben) & t.method.equals('email'),
|
|
);
|
|
expect(sessions, hasLength(1));
|
|
expect(await EmailCodeRequest.db.count(session), 0);
|
|
});
|
|
|
|
test('a person who signs in with GitHub adds an address', () async {
|
|
Future<(bool, bool)> waysOf(TestSessionBuilder person) async {
|
|
final ways = await endpoints.account.signInWays(person);
|
|
return (ways.gitHub, ways.email);
|
|
}
|
|
|
|
expect(await waysOf(asCarla()), (true, false));
|
|
|
|
final accepted = await signInWith(asCarla(), 'carla@example.org');
|
|
|
|
expect(accepted.switchToken, isNull);
|
|
expect(await waysOf(asCarla()), (true, true));
|
|
expect(await waysOf(asAnna()), (false, false));
|
|
});
|
|
|
|
test('tells the app that this instance offers it', () async {
|
|
expect(
|
|
(await endpoints.instance.info(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));
|
|
}
|