feat(auth): sign in with an email address, keeping only a keyed hash of it
People without GitHub can now keep an anonymous account, and sign in on another device, with an email address and a code mailed to it each time; no password. The instance keeps only HMAC-SHA256 of the address, keyed with its emailSignInPepper (ADR 0010): from what someone types it finds the person and tells whether an address is free, it mails only an address someone has just typed, and it can't write to anybody otherwise or show anybody their address. The device remembers the address it signed in with, in the person's folder, to show it. It works as GitHub does. A code for a new address makes it the signed-in person's, who becomes identified and keeps everything. A code for another person's address gives a switch token, which signs in as them without a session; a device holding studysets asks first. Asking for a code answers alike for every address. Codes are eight Crockford characters, valid 15 minutes and three tries (Serverpod's code challenges); three codes an hour per address, ten per network. In development the code goes to the server's log; elsewhere there is no email sign-in until a mail service is set up (instance.info().emailSignIn). Someone identified may add the other way in: the Profile tab lists how they sign in and adds GitHub or an address. They are never switched to another account: an address or GitHub account that is another's is said to be so. Switching now deletes the account left behind only when it was anonymous; it deleted any, which only anonymous people could reach so far. The migration adds email_identity and email_code_request with their indexes and foreign keys to Serverpod's challenges; nothing else changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
58e13521b1
commit
1cfd391dbc
1 changed files with 21720 additions and 103 deletions
550
halkyon_learn_server/test/integration/auth/email_test.dart
Normal file
550
halkyon_learn_server/test/integration/auth/email_test.dart
Normal file
|
|
@ -0,0 +1,550 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:halkyon_learn_server/src/auth/account.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;
|
||||
}
|
||||
|
||||
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('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));
|
||||
}
|
||||
Loading…
Reference in a new issue