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:
Tizian 「ティツィアーン」 2026-10-15 19:42:21 +02:00
commit 1cfd391dbc
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -1762,6 +1762,112 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get profileSwitchConfirm => 'Switch';
@override
String get profileContinueWithEmail => 'Continue with email';
@override
String get profileSignInWays => 'Ways to sign in';
@override
String get profileWayGitHub => 'GitHub';
@override
String get profileWayEmail => 'Email address';
@override
String get profileWayLinked => 'Signs you in';
@override
String get profileAddGitHub => 'Add GitHub';
@override
String get profileAddEmail => 'Add an email address';
@override
String get profileGitHubTaken =>
'This GitHub account already signs in to another account.';
@override
String get emailSignInTitle => 'Continue with email';
@override
String get emailSignInIntro =>
'We mail you a code to sign in. This instance keeps only a fingerprint of your address, never the address itself, and writes to it only when you ask for a code.';
@override
String get emailSignInAddress => 'Email address';
@override
String get emailSignInSend => 'Send code';
@override
String emailSignInCodeSent(String address) {
return 'We mailed a code to $address. It\'s valid for 15 minutes.';
}
@override
String get emailSignInResent => 'A new code is on its way.';
@override
String get emailSignInCode => 'Code';
@override
String get emailSignInContinue => 'Continue';
@override
String get emailSignInResend => 'Send a new code';
@override
String get emailSignInOtherAddress => 'Use another address';
@override
String emailSignInSwitchTitle(String address) {
return 'Switch to the account of $address?';
}
@override
String get emailSignInSwitchMessage =>
'This address already has progress from another device. The studysets on this device can\'t be combined with it yet: if you switch, they are deleted.';
@override
String emailSignInTaken(String address) {
return '$address already signs in to another account. To switch to it, sign out first.';
}
@override
String emailSignInDone(String address) {
return 'Signed in with $address.';
}
@override
String get emailSignInUnavailable =>
'This instance doesn\'t offer signing in with email.';
@override
String get emailSignInNotAnAddress => 'This isn\'t an email address.';
@override
String get emailSignInTooManyCodes =>
'Too many codes in the last hour. Try again later.';
@override
String get emailSignInWrongCode => 'That code isn\'t right.';
@override
String get emailSignInTooManyTries =>
'Too many wrong codes. Ask for a new one.';
@override
String get emailSignInCodeExpired =>
'This code is no longer valid. Ask for a new one.';
@override
String get emailSignInOtherAddressLinked =>
'You already sign in with another address.';
@override
String get emailSignInFailed => 'Couldn\'t reach the instance. Try again.';
@override
String get publicProfileTitle => 'Public profile';
@ -1781,7 +1887,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get publicProfileAnonymousHint =>
'Sign in with GitHub to have a public profile and publish.';
'Sign in to have a public profile and publish.';
@override
String get publicProfileDisplayName => 'Display name';
@ -1996,7 +2102,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String accountRemoveMessage(String name) {
return 'Removes the account on $name from this device, with all the device holds of it. What it synced stays on $name: sign in there with GitHub to have it again. An anonymous account can\'t be signed into again.';
return 'Removes the account on $name from this device, with all the device holds of it. What it synced stays on $name: sign in there again to have it. An anonymous account can\'t be signed into again.';
}
@override
@ -5846,7 +5952,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get publishAnonymous =>
'Sign in with GitHub to have a public profile and publish.';
'Sign in to have a public profile and publish.';
@override
String get publishName => 'Name';
@ -6350,7 +6456,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get feedbackNeedsIdentity =>
'Sign in with GitHub to report to the author and suggest.';
'Sign in to report to the author and suggest.';
@override
String get kanjiExampleWords => 'Words with it';