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. - Remembered devices: with
remember_deviceand a right code, sign-in also returns adevice_token(32 random bytes; the database keeps the SHA-256 inremembered_devices, 30 days). The browser keeps it per username and sends it at the next sign-in, which then skips the code, never the password. A new password or a change to two-factor (off, by the user or a reset link) forgets every remembered device, through a trigger on the credentials; signing out other devices forgets all but the one asking; the daily purge removes expired ones. - 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 (or the user’s own time, below), signed out, or the account disabled or purged ends it. Sign out, sign out other devices, and a password change (other devices) delete sessions; signing out other devices also forgets their remembered devices. - Signed-in devices: each session also has an id and a short label of the browser and system that signed in
(
Chrome · Windows,Safari · iOS: no versions, no address), so Profile and security lists where the user is signed in and signs out any one device (GET /v1/auth/sessions,DELETE /v1/auth/sessions/{id}). The token’s hash never leaves the database. - Signing out after inactivity (5 minutes by default): each user chooses, on Profile and security, how
many minutes (1 to 1440; the app offers 5 to 60, or never) they may be inactive, for all their devices
(
users.idle_sign_out_minutes,PATCH /v1/me). It’s theirs, not the server admin’s: the admin hosts the server, but each user keeps their own books. The server ends that user’s session unused that long plus a minute (session_lifetime(user);last_used_atis kept to the minute, so nobody is signed out early), which covers closed tabs and sleeping devices. A tab left open on screen keeps its session in use with its background refreshes, so the web app also watches for the user: with no click, key, touch, scroll or mouse move in any tab of the browser for that long (tabs tell each other on aBroadcastChannel), it warns a minute before, then signs out, and the sign-in page says why. - 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 waterflow 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, updates, the daily jobs’ schedule, invites, and accounts (disable, which ends their sessions; reset
links). 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).