Sign-in and accounts
Setup, invites, signing in, sessions, two-factor, resets and the admin.
The API signs people in itself: no email, no outside service. Code: backend/app/core/sign_in.py (passwords, codes, tokens, rate limits) and backend/app/api/routes/v1/sign_in.py; the tables and functions are at the end of the baseline migration.
Who gets in
- Setup (the first account). While no admin exists, every start prints a new setup code (
XXXX-XXXX-XXXX) in the server’s log, and the web app shows only “Set up this server”.POST /v1/setupwith the code creates the account, which the database makes admin (claim_setup()locks the settings row, so two people can’t both succeed). Whoever can read the log installed the server; someone who finds it first can’t take it over. - Invites. The admin creates one (
XXXX-XXXX, one use, 1 to 30 days) on Server admin; the API returns the code, the linkhttps://<server>/join?code=<code>built from the address the admin’s browser uses, and the link as a QR code. The database keeps only the SHA-256 of the code’s normal form (case, dashes, spaces and look-alike letters don’t matter). The mobile app will read the same QR code. - Joining.
POST /v1/auth/sign-upcreates the account and claims the invite in one transaction; the database refuses an account with no way in (users_need_a_way_in: the admin from setup, a claimed invite, or open sign-up). - Open sign-up is the admin’s choice (Server admin, New accounts); invite-only is the default.
Signing in
- Username and password (
POST /v1/auth/sign-in). Passwords: 10 to 256 characters, argon2id. A wrong username and a wrong password give the sameinvalid_credentials, in the same time (a dummy hash is checked). - Two-factor (optional): TOTP from any authenticator app, one 30-second step of drift either way. When it’s on,
sign-in answers
code_requireduntil a code comes; a recovery code (ten, one use each, shown once) works in the same field. Turning it off or making new recovery codes needs a current code. - Sessions: 32 random bytes, sent as
Authorization: Bearer; the database keeps the SHA-256 (sessions).session_user_id()on every request; unused for 30 days, signed out, or the account disabled or purged ends it. Sign out, sign out other devices, and a password change (other devices) delete sessions. - Rate limits (in memory, per server): 30 attempts per address per 15 minutes for sign-in, setup, resets and invite checks; 10 per username per 15 minutes; 5 sign-ups per address per hour; 10 password or two-factor changes per user per 15 minutes; 10 book invites per owner per 15 minutes; 5 new usernames per user per hour (saving the same one doesn’t count). Defaults.
Forgotten passwords
No email, so: the admin makes a reset link (Server admin, Accounts; 24 hours, one use); using it sets a new
password, turns two-factor off and ends every session. If the admin is locked out, the operator runs
docker compose exec msaving python -m app.admin reset-password <username> (or make-admin).
The admin
One account with users.is_admin, set only by the database. Server admin in the web app: the server’s name and
sign-up mode, invites, accounts (disable, which ends their sessions; reset links), and updates. Every admin
function checks am_admin(); the admin can’t disable themselves.
Clients
- The web app is served by the server and calls
/v1on its own origin. A browser request with any otherOriginis refused, so one web client per server. - The mobile app (later) stores server addresses and session tokens, sends no
Origin, and readsGET /v1/instancefirst (name,server_version,api_version, sign-up mode,setup_needed). See Mobile.