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

@ -541,12 +541,46 @@
"profileSwitchMessage": "This GitHub account already has progress from another device. The studysets on this device can't be combined with it yet: if you switch, they are deleted.",
"profileSwitchStay": "Stay anonymous",
"profileSwitchConfirm": "Switch",
"profileContinueWithEmail": "Continue with email",
"profileSignInWays": "Ways to sign in",
"profileWayGitHub": "GitHub",
"profileWayEmail": "Email address",
"profileWayLinked": "Signs you in",
"profileAddGitHub": "Add GitHub",
"profileAddEmail": "Add an email address",
"profileGitHubTaken": "This GitHub account already signs in to another account.",
"emailSignInTitle": "Continue with email",
"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.",
"emailSignInAddress": "Email address",
"emailSignInSend": "Send code",
"emailSignInCodeSent": "We mailed a code to {address}. It's valid for 15 minutes.",
"@emailSignInCodeSent": {"placeholders": {"address": {"type": "String"}}},
"emailSignInResent": "A new code is on its way.",
"emailSignInCode": "Code",
"emailSignInContinue": "Continue",
"emailSignInResend": "Send a new code",
"emailSignInOtherAddress": "Use another address",
"emailSignInSwitchTitle": "Switch to the account of {address}?",
"@emailSignInSwitchTitle": {"placeholders": {"address": {"type": "String"}}},
"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.",
"emailSignInTaken": "{address} already signs in to another account. To switch to it, sign out first.",
"@emailSignInTaken": {"placeholders": {"address": {"type": "String"}}},
"emailSignInDone": "Signed in with {address}.",
"@emailSignInDone": {"placeholders": {"address": {"type": "String"}}},
"emailSignInUnavailable": "This instance doesn't offer signing in with email.",
"emailSignInNotAnAddress": "This isn't an email address.",
"emailSignInTooManyCodes": "Too many codes in the last hour. Try again later.",
"emailSignInWrongCode": "That code isn't right.",
"emailSignInTooManyTries": "Too many wrong codes. Ask for a new one.",
"emailSignInCodeExpired": "This code is no longer valid. Ask for a new one.",
"emailSignInOtherAddressLinked": "You already sign in with another address.",
"emailSignInFailed": "Couldn't reach the instance. Try again.",
"publicProfileTitle": "Public profile",
"profileSheetTitle": "Profile",
"profileSheetNotFound": "Nobody on this instance has this handle.",
"profileSheetUnreachable": "The profile couldn’t be loaded. Check your connection.",
"publicProfileIntro": "How others see you once you publish: the handle they find you by, your name, a few words and links. It saves as you write, once you have chosen your handle.",
"publicProfileAnonymousHint": "Sign in with GitHub to have a public profile and publish.",
"publicProfileAnonymousHint": "Sign in to have a public profile and publish.",
"publicProfileDisplayName": "Display name",
"publicProfileDisplayNameHint": "The name shown beside your handle",
"publicProfileHandle": "Handle",
@ -619,7 +653,7 @@
"accountAddSubtitle": "Choose the instance for the account to add.",
"accountRemoveTooltip": "Remove account",
"accountRemoveTitle": "Remove account?",
"accountRemoveMessage": "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.",
"accountRemoveMessage": "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.",
"@accountRemoveMessage": {"placeholders": {"name": {"type": "String"}}},
"accountRemoveConfirm": "Remove",
"settingsSignOut": "Sign out",
@ -1855,7 +1889,7 @@
"publishPublished": "Published · v{version} · {date}",
"@publishPublished": {"placeholders": {"version": {"type": "int"}, "date": {"type": "String"}}},
"publishIntro": "A release is this studyset with everything inside it, as it is now. Others can find it, learn from it and copy it. Each release stays as it was; later changes go into the next one.",
"publishAnonymous": "Sign in with GitHub to have a public profile and publish.",
"publishAnonymous": "Sign in to have a public profile and publish.",
"publishName": "Name",
"publishNameHint": "japanese",
"publishLocation": "Found at {location}",
@ -2018,7 +2052,7 @@
"suggestIntro": "The author sees it as you have it now, beside theirs. Public: everyone learning it reads it, with your public profile.",
"suggestSent": "Suggestion sent.",
"suggestNoDerivatives": "Its licence forbids adaptations: no suggestions.",
"feedbackNeedsIdentity": "Sign in with GitHub to report to the author and suggest.",
"feedbackNeedsIdentity": "Sign in to report to the author and suggest.",
"kanjiExampleWords": "Words with it",
"settingsAssistant": "Assistant",