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

@ -2890,6 +2890,186 @@ abstract class AppLocalizations {
/// **'Switch'**
String get profileSwitchConfirm;
/// No description provided for @profileContinueWithEmail.
///
/// In en, this message translates to:
/// **'Continue with email'**
String get profileContinueWithEmail;
/// No description provided for @profileSignInWays.
///
/// In en, this message translates to:
/// **'Ways to sign in'**
String get profileSignInWays;
/// No description provided for @profileWayGitHub.
///
/// In en, this message translates to:
/// **'GitHub'**
String get profileWayGitHub;
/// No description provided for @profileWayEmail.
///
/// In en, this message translates to:
/// **'Email address'**
String get profileWayEmail;
/// No description provided for @profileWayLinked.
///
/// In en, this message translates to:
/// **'Signs you in'**
String get profileWayLinked;
/// No description provided for @profileAddGitHub.
///
/// In en, this message translates to:
/// **'Add GitHub'**
String get profileAddGitHub;
/// No description provided for @profileAddEmail.
///
/// In en, this message translates to:
/// **'Add an email address'**
String get profileAddEmail;
/// No description provided for @profileGitHubTaken.
///
/// In en, this message translates to:
/// **'This GitHub account already signs in to another account.'**
String get profileGitHubTaken;
/// No description provided for @emailSignInTitle.
///
/// In en, this message translates to:
/// **'Continue with email'**
String get emailSignInTitle;
/// No description provided for @emailSignInIntro.
///
/// In en, this message translates to:
/// **'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.'**
String get emailSignInIntro;
/// No description provided for @emailSignInAddress.
///
/// In en, this message translates to:
/// **'Email address'**
String get emailSignInAddress;
/// No description provided for @emailSignInSend.
///
/// In en, this message translates to:
/// **'Send code'**
String get emailSignInSend;
/// No description provided for @emailSignInCodeSent.
///
/// In en, this message translates to:
/// **'We mailed a code to {address}. It\'s valid for 15 minutes.'**
String emailSignInCodeSent(String address);
/// No description provided for @emailSignInResent.
///
/// In en, this message translates to:
/// **'A new code is on its way.'**
String get emailSignInResent;
/// No description provided for @emailSignInCode.
///
/// In en, this message translates to:
/// **'Code'**
String get emailSignInCode;
/// No description provided for @emailSignInContinue.
///
/// In en, this message translates to:
/// **'Continue'**
String get emailSignInContinue;
/// No description provided for @emailSignInResend.
///
/// In en, this message translates to:
/// **'Send a new code'**
String get emailSignInResend;
/// No description provided for @emailSignInOtherAddress.
///
/// In en, this message translates to:
/// **'Use another address'**
String get emailSignInOtherAddress;
/// No description provided for @emailSignInSwitchTitle.
///
/// In en, this message translates to:
/// **'Switch to the account of {address}?'**
String emailSignInSwitchTitle(String address);
/// No description provided for @emailSignInSwitchMessage.
///
/// In en, this message translates to:
/// **'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.'**
String get emailSignInSwitchMessage;
/// No description provided for @emailSignInTaken.
///
/// In en, this message translates to:
/// **'{address} already signs in to another account. To switch to it, sign out first.'**
String emailSignInTaken(String address);
/// No description provided for @emailSignInDone.
///
/// In en, this message translates to:
/// **'Signed in with {address}.'**
String emailSignInDone(String address);
/// No description provided for @emailSignInUnavailable.
///
/// In en, this message translates to:
/// **'This instance doesn\'t offer signing in with email.'**
String get emailSignInUnavailable;
/// No description provided for @emailSignInNotAnAddress.
///
/// In en, this message translates to:
/// **'This isn\'t an email address.'**
String get emailSignInNotAnAddress;
/// No description provided for @emailSignInTooManyCodes.
///
/// In en, this message translates to:
/// **'Too many codes in the last hour. Try again later.'**
String get emailSignInTooManyCodes;
/// No description provided for @emailSignInWrongCode.
///
/// In en, this message translates to:
/// **'That code isn\'t right.'**
String get emailSignInWrongCode;
/// No description provided for @emailSignInTooManyTries.
///
/// In en, this message translates to:
/// **'Too many wrong codes. Ask for a new one.'**
String get emailSignInTooManyTries;
/// No description provided for @emailSignInCodeExpired.
///
/// In en, this message translates to:
/// **'This code is no longer valid. Ask for a new one.'**
String get emailSignInCodeExpired;
/// No description provided for @emailSignInOtherAddressLinked.
///
/// In en, this message translates to:
/// **'You already sign in with another address.'**
String get emailSignInOtherAddressLinked;
/// No description provided for @emailSignInFailed.
///
/// In en, this message translates to:
/// **'Couldn\'t reach the instance. Try again.'**
String get emailSignInFailed;
/// No description provided for @publicProfileTitle.
///
/// In en, this message translates to:
@ -2923,7 +3103,7 @@ abstract class AppLocalizations {
/// No description provided for @publicProfileAnonymousHint.
///
/// In en, this message translates to:
/// **'Sign in with GitHub to have a public profile and publish.'**
/// **'Sign in to have a public profile and publish.'**
String get publicProfileAnonymousHint;
/// No description provided for @publicProfileDisplayName.
@ -3301,7 +3481,7 @@ abstract class AppLocalizations {
/// No description provided for @accountRemoveMessage.
///
/// In en, this message translates to:
/// **'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.'**
/// **'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.'**
String accountRemoveMessage(String name);
/// No description provided for @accountRemoveConfirm.
@ -9366,7 +9546,7 @@ abstract class AppLocalizations {
/// No description provided for @publishAnonymous.
///
/// In en, this message translates to:
/// **'Sign in with GitHub to have a public profile and publish.'**
/// **'Sign in to have a public profile and publish.'**
String get publishAnonymous;
/// No description provided for @publishName.
@ -10200,7 +10380,7 @@ abstract class AppLocalizations {
/// No description provided for @feedbackNeedsIdentity.
///
/// In en, this message translates to:
/// **'Sign in with GitHub to report to the author and suggest.'**
/// **'Sign in to report to the author and suggest.'**
String get feedbackNeedsIdentity;
/// No description provided for @kanjiExampleWords.