Waterflow

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/setup with 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 link https://<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-up creates 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 same invalid_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_required until 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_device and a right code, sign-in also returns a device_token (32 random bytes; the database keeps the SHA-256 in remembered_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_at is 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 a BroadcastChannel), 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 /v1 on its own origin. A browser request with any other Origin is refused, so one web client per server.
  • The mobile app (later) stores server addresses and session tokens, sends no Origin, and reads GET /v1/instance first (name, server_version, api_version, sign-up mode, setup_needed).

Edit this page · Report a problem