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>
2026-10-19 14:28:09 +02:00
|
|
|
import 'dart:async';
|
|
|
|
|
import 'dart:convert';
|
|
|
|
|
import 'dart:io';
|
|
|
|
|
|
|
|
|
|
import 'package:halkyon_base_server/base.dart';
|
|
|
|
|
import 'package:mailer/mailer.dart' as mailer;
|
|
|
|
|
import 'package:mailer/smtp_server.dart';
|
|
|
|
|
import 'package:serverpod/serverpod.dart';
|
|
|
|
|
import 'package:test/test.dart';
|
|
|
|
|
|
|
|
|
|
/// An SMTP server on a loopback port that takes one mail at a time, offers
|
|
|
|
|
/// no TLS, and keeps what it is told.
|
|
|
|
|
final class _FakeSmtp {
|
|
|
|
|
_FakeSmtp._(this._socket) {
|
|
|
|
|
_socket.listen(_serve);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static Future<_FakeSmtp> start() async =>
|
|
|
|
|
_FakeSmtp._(await ServerSocket.bind(InternetAddress.loopbackIPv4, 0));
|
|
|
|
|
|
|
|
|
|
final ServerSocket _socket;
|
|
|
|
|
|
|
|
|
|
/// The commands received, in order.
|
|
|
|
|
final commands = <String>[];
|
|
|
|
|
|
|
|
|
|
/// What each mail's DATA held, its lines joined.
|
|
|
|
|
final mails = <String>[];
|
|
|
|
|
|
|
|
|
|
int get port => _socket.port;
|
|
|
|
|
|
|
|
|
|
Future<void> close() => _socket.close();
|
|
|
|
|
|
|
|
|
|
void _serve(Socket client) {
|
|
|
|
|
client.write('220 fake ESMTP\r\n');
|
|
|
|
|
var step = '';
|
|
|
|
|
final data = StringBuffer();
|
|
|
|
|
utf8.decoder.bind(client).transform(const LineSplitter()).listen((line) {
|
|
|
|
|
if (step == 'data') {
|
|
|
|
|
if (line == '.') {
|
|
|
|
|
step = '';
|
|
|
|
|
mails.add(data.toString());
|
|
|
|
|
data.clear();
|
|
|
|
|
client.write('250 2.0.0 queued\r\n');
|
|
|
|
|
} else {
|
|
|
|
|
data.writeln(line);
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (step == 'auth') {
|
|
|
|
|
step = '';
|
|
|
|
|
client.write('235 2.7.0 Authentication successful\r\n');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
commands.add(line);
|
|
|
|
|
final command = line.toUpperCase();
|
|
|
|
|
if (command.startsWith('EHLO')) {
|
|
|
|
|
client.write('250-fake\r\n250 AUTH PLAIN\r\n');
|
|
|
|
|
} else if (command == 'AUTH PLAIN') {
|
|
|
|
|
step = 'auth';
|
|
|
|
|
client.write('334 \r\n');
|
|
|
|
|
} else if (command == 'DATA') {
|
|
|
|
|
step = 'data';
|
|
|
|
|
client.write('354 End data with <CR><LF>.<CR><LF>\r\n');
|
|
|
|
|
} else if (command == 'QUIT') {
|
|
|
|
|
client
|
|
|
|
|
..write('221 2.0.0 Bye\r\n')
|
|
|
|
|
..close();
|
|
|
|
|
} else {
|
|
|
|
|
client.write('250 2.0.0 OK\r\n');
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The text of [mail] as DATA held it: its headers and its body, decoded
|
|
|
|
|
/// from base64 where it was encoded so.
|
|
|
|
|
({String headers, String body}) _read(String mail) {
|
|
|
|
|
final at = mail.indexOf('\n\n');
|
|
|
|
|
final headers = mail.substring(0, at);
|
|
|
|
|
final body = mail.substring(at + 2);
|
|
|
|
|
return (
|
|
|
|
|
headers: headers,
|
|
|
|
|
body: headers.toLowerCase().contains('content-transfer-encoding: base64')
|
|
|
|
|
? utf8.decode(base64.decode(body.replaceAll(RegExp(r'\s'), '')))
|
|
|
|
|
: body,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void main() {
|
|
|
|
|
group('the mail', () {
|
|
|
|
|
test('is plain text with the code first, in the subject too, naming '
|
|
|
|
|
'the instance and how long the code lasts', () {
|
|
|
|
|
final mail = codeMail(
|
|
|
|
|
code: 'K7QX-M2PD',
|
|
|
|
|
language: 'en',
|
|
|
|
|
app: 'Halkyon Learn',
|
|
|
|
|
instance: 'learn.halkyon.eu',
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(mail.subject, 'K7QX-M2PD is your Halkyon Learn code');
|
|
|
|
|
expect(mail.body, startsWith('K7QX-M2PD\n\n'));
|
|
|
|
|
expect(mail.body, contains('sign in on learn.halkyon.eu'));
|
|
|
|
|
expect(mail.body, contains('15 minutes'));
|
|
|
|
|
expect(mail.body, isNot(contains('http')));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('in German for German, in English for anything else', () {
|
|
|
|
|
({String subject, String body}) mailIn(String language) => codeMail(
|
|
|
|
|
code: 'K7QX-M2PD',
|
|
|
|
|
language: language,
|
|
|
|
|
app: 'Halkyon Learn',
|
|
|
|
|
instance: 'x',
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(mailIn('de').subject, 'K7QX-M2PD ist dein Halkyon-Learn-Code');
|
|
|
|
|
expect(mailIn('fr').subject, 'K7QX-M2PD is your Halkyon Learn code');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('names the app the host is, in its text too', () {
|
|
|
|
|
({String subject, String body}) mailIn(String language) => codeMail(
|
|
|
|
|
code: 'K7QX-M2PD',
|
|
|
|
|
language: language,
|
|
|
|
|
app: 'Halkyon Todo',
|
|
|
|
|
instance: 'todo.example.org',
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(mailIn('en').subject, 'K7QX-M2PD is your Halkyon Todo code');
|
|
|
|
|
expect(mailIn('en').body, contains('Type this code in Halkyon Todo'));
|
|
|
|
|
expect(mailIn('de').subject, 'K7QX-M2PD ist dein Halkyon-Todo-Code');
|
|
|
|
|
expect(mailIn('de').body, contains('Gib diesen Code in Halkyon Todo'));
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
feat(auth): a password with TOTP on the register, and resets
The register's side of signing in with an address, a password that never
leaves the device (SRP-6a) and a code from an authenticator (ADR 0017),
with members forwarding every step through AccountRegister:
- password.set: salt, Argon2id's costs, SRP's verifier, the public keys
and the private keys wrapped twice, all made on the device; with the
address a mailed code proves, an anonymous person becomes identified
under their ID. It signs in once an authenticator is confirmed.
- passwordSignIn.begin and complete: SRP's two steps with a TOTP or
recovery code. An address nobody has is answered from a decoy key as
an account would be; every wrong credential is refused alike.
- authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound
to their row), each code accepted once, ten recovery codes with the
first, the last one kept.
- accountRecovery: a lost authenticator reset 72 hours after the
password and a mailed code or the recovery phrase ask, a lost password
set anew with the phrase's signature, or the account reset with new
keys 72 hours after a mailed code asks; a mail at each start, a
sign-in calling it off.
- An account with a password no longer signs in by a mailed code or
GitHub; no new GitHub account is linked where passwords are offered,
and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go.
Five tables, added by the module's migration; deletePerson deletes
their rows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
|
|
|
group("a reset's notice", () {
|
|
|
|
|
({String subject, String body}) notice(
|
|
|
|
|
AccountResetKind kind,
|
|
|
|
|
String language,
|
|
|
|
|
) => resetMail(
|
|
|
|
|
kind: kind,
|
|
|
|
|
takesEffectAt: DateTime.utc(2026, 10, 23, 14, 5, 59),
|
|
|
|
|
language: language,
|
|
|
|
|
app: 'Halkyon Learn',
|
|
|
|
|
instance: 'learn.halkyon.eu',
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
test('says what goes when, in UTC, on which instance, and that signing '
|
|
|
|
|
'in calls it off, in German for German and English else', () {
|
|
|
|
|
for (final kind in AccountResetKind.values) {
|
|
|
|
|
for (final language in ['en', 'de', 'fr']) {
|
|
|
|
|
final mail = notice(kind, language);
|
|
|
|
|
expect(mail.body, contains('2026-10-23 14:05 UTC'));
|
|
|
|
|
expect(mail.body, contains('learn.halkyon.eu'));
|
|
|
|
|
expect(mail.subject, contains('Halkyon Learn'));
|
|
|
|
|
expect(
|
|
|
|
|
mail.body,
|
|
|
|
|
language == 'de'
|
|
|
|
|
? contains('bricht es ab')
|
|
|
|
|
: contains('calls it off'),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
expect(
|
|
|
|
|
notice(AccountResetKind.account, 'en').body,
|
|
|
|
|
contains('can no longer be read'),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('holds no link and no code: it helps nobody but the owner', () {
|
|
|
|
|
for (final kind in AccountResetKind.values) {
|
|
|
|
|
final mail = notice(kind, 'en');
|
|
|
|
|
expect(mail.body, isNot(contains('http')));
|
|
|
|
|
expect(mail.body, isNot(matches(RegExp('[0-9A-Z]{4}-[0-9A-Z]{4}'))));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
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>
2026-10-19 14:28:09 +02:00
|
|
|
group('codes go', () {
|
|
|
|
|
final smtp = SmtpCodeMailer(
|
|
|
|
|
server: SmtpServer('localhost'),
|
|
|
|
|
from: const mailer.Address('learn@halkyon.eu'),
|
|
|
|
|
app: 'Halkyon Learn',
|
|
|
|
|
instance: 'learn.halkyon.eu',
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
test('through the SMTP service set up, in every run mode', () {
|
|
|
|
|
for (final runMode in [
|
|
|
|
|
ServerpodRunMode.development,
|
|
|
|
|
ServerpodRunMode.production,
|
|
|
|
|
]) {
|
|
|
|
|
expect(codeMailerFor(runMode: runMode, smtp: smtp), same(smtp));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('into the log in development without one, and nowhere elsewhere '
|
|
|
|
|
'(no email sign-in)', () {
|
|
|
|
|
expect(
|
|
|
|
|
codeMailerFor(runMode: ServerpodRunMode.development, smtp: null),
|
|
|
|
|
isA<LogCodeMailer>(),
|
|
|
|
|
);
|
|
|
|
|
for (final runMode in [
|
|
|
|
|
ServerpodRunMode.staging,
|
|
|
|
|
ServerpodRunMode.production,
|
|
|
|
|
ServerpodRunMode.test,
|
|
|
|
|
]) {
|
|
|
|
|
expect(codeMailerFor(runMode: runMode, smtp: null), isNull);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
group('over SMTP', () {
|
|
|
|
|
late _FakeSmtp server;
|
|
|
|
|
|
|
|
|
|
setUp(() async => server = await _FakeSmtp.start());
|
|
|
|
|
tearDown(() => server.close());
|
|
|
|
|
|
|
|
|
|
SmtpCodeMailer mailerFor({required bool allowInsecure}) => SmtpCodeMailer(
|
|
|
|
|
server: SmtpServer(
|
|
|
|
|
'127.0.0.1',
|
|
|
|
|
port: server.port,
|
|
|
|
|
username: 'learn@halkyon.eu',
|
|
|
|
|
password: 'token',
|
|
|
|
|
allowInsecure: allowInsecure,
|
|
|
|
|
),
|
|
|
|
|
from: const mailer.Address('learn@halkyon.eu', 'Halkyon Learn'),
|
|
|
|
|
app: 'Halkyon Learn',
|
|
|
|
|
instance: 'learn.halkyon.eu',
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
test(
|
|
|
|
|
'mails the code from the address set up to the address typed',
|
|
|
|
|
() async {
|
|
|
|
|
await mailerFor(allowInsecure: true).mail(
|
|
|
|
|
to: 'anna@example.org',
|
|
|
|
|
code: 'K7QX-M2PD',
|
|
|
|
|
language: 'de',
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(server.commands, contains('MAIL FROM:<learn@halkyon.eu>'));
|
|
|
|
|
expect(server.commands, contains('RCPT TO:<anna@example.org>'));
|
|
|
|
|
final mail = _read(server.mails.single);
|
|
|
|
|
expect(mail.headers, contains('Halkyon Learn'));
|
|
|
|
|
expect(mail.headers, contains('<learn@halkyon.eu>'));
|
|
|
|
|
expect(mail.headers, contains('text/plain'));
|
|
|
|
|
expect(mail.body, startsWith('K7QX-M2PD'));
|
|
|
|
|
expect(mail.body, contains('auf learn.halkyon.eu anzumelden'));
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
test('never over a connection without TLS', () async {
|
|
|
|
|
await expectLater(
|
|
|
|
|
mailerFor(allowInsecure: false).mail(
|
|
|
|
|
to: 'anna@example.org',
|
|
|
|
|
code: 'K7QX-M2PD',
|
|
|
|
|
language: 'en',
|
|
|
|
|
),
|
|
|
|
|
throwsA(isA<Exception>()),
|
|
|
|
|
);
|
|
|
|
|
expect(server.mails, isEmpty);
|
|
|
|
|
expect(
|
|
|
|
|
server.commands.where((command) => command.startsWith('AUTH')),
|
|
|
|
|
isEmpty,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|