halkyon_base: a Serverpod module for sign-in, accounts and instance basics
What every Halkyon server has, taken from Halkyon Learn's: anonymous, GitHub and email-code sign-in with their endpoints, an account's ways in and its deletion, the pruning of abandoned anonymous people, of sign-up attempts and of expired codes, and the instance's basics (the sign-in ways offered, the source link, the minimum app build, where people's data and its backups are). Endpoints sit under the nickname `base`. A host configures it in one place before its pod starts: its name for the mails, its source link, its minimum build, and hooks that delete its own data with a person. The tables keep Learn's names, columns and indexes, so a host moving onto the module migrates nothing but version rows. Server package under the SSPL, client under the AGPL, as Learn's are. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
80fa7b9628
commit
af1de799f4
1 changed files with 17704 additions and 3 deletions
|
|
@ -0,0 +1,574 @@
|
|||
import 'dart:convert';
|
||||
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));
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
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(),
|
||||
),
|
||||
);
|
||||
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.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(
|
||||
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.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));
|
||||
}
|
||||
Loading…
Reference in a new issue