diff --git a/CHANGELOG.md b/CHANGELOG.md index 723d63f..b6d0773 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,406 @@ as described in the [versioning policy](docs/dev/guides/versioning.md). ## [Unreleased] +## [2.1.0] - 2026-09-26 + +Security release: fixes every finding of the security audit of 2026-09-26 +and of the four independent re-audits that followed it. +Some fixes refuse what was unsafe to accept, as the versioning policy allows +for security fixes: each such change is listed under **Security**. No +deployment exists yet, so the migrations were consolidated: each table is +defined whole in the migration that creates it, and a database is created +from scratch (read **Upgrading**). + +### Security + +- Production refuses `LOCKOUT_THRESHOLD` above 50, request limits above + 10 000 a minute, refresh or session lifetimes above a year, + `REGISTRATIONS_PER_IP_PER_HOUR=0`, `PWNED_PASSWORDS_ENABLED=false`, and a + `TRUSTED_PROXY_CIDRS` network wider than `/24` (IPv4) or `/64` (IPv6). New + `deploy/api/nftables-auth-api.conf` lets only nginx and root reach the + published ports; nginx masks the codes carried by URLs in its access log; + the internal listener refuses bodies and slow requests; the image is built + with `--locked`; the Redis ACL template allows the Lua scripts by name. + +- Introspection of the access tokens of others is reserved to resource servers + (new `allows_introspection` client setting; register yours with it), and a + resource server registered with scopes sees only those. Access tokens carry + `sub_type` and `session_type`, also reported by introspection. The ID + token's `auth_time` is when the password was proved for the consent. + `prompt=none`, `request`, `request_uri` and response modes other than + `query` are refused; `max_age` and `prompt=login` ask for the password + again. An authorization request belongs to the first user who looks at it. + A code replayed under another client's id revokes nothing (it is logged). A + device's consent is intersected with the user's permissions at the + approval. + +- Audit partitions created after `deploy/db/auth-api-grants.sql` ran lose + `UPDATE` and `DELETE` for the runtime role (the owner's default privileges + granted them), and partitions are created two years ahead at most. + `forget_account_traces` deletes the account with its traces, so it can no + longer anonymize the audit trail of an account that stays. Key identifiers + in ciphertexts are derived by HKDF. A stored password hash asking for far + more than the configured Argon2 cost is refused before any work. + +- Nobody grants a role a permission they do not hold (`403`), whether they + hold the role or not: `roles:manage` alone no longer lets two accounts give + each other every permission. A client's audit entry lists the settings that + changed. A rename, a session revoked by its owner and a re-authentication + are written with their audit entry. A failed webhook delivery records a fixed + message instead of the client error, which carried the URL. A forced reset + records no administrator address. Status and permissions checked before a + suspension, an erasure or a role grant are read under the account's lock. + The export shows only the network of failed sign-ins and of mailed-link + requests. + +- A password reset removes the second factors, passkeys and external + identities added in the 72 hours before it was asked for + (`RESET_REVOKES_FACTORS_ADDED_HOURS`) and lists them in its mail. New `DELETE + /admin/users/{id}/access-factors` (re-authentication, audited as + `access_factors_removed`) and `revoke_access_factors` on `POST + /admin/users/{id}/password-reset` remove every way in but the password; an + administrator's last second factor stays. The owner is mailed of an unlock. + +- Second factors: 10 wrong TOTP codes an hour per address and 30 per account + (100 before); spending the account budget mails the owner (new + `second_factor_attempts` template). Recovery-code budgets cover an hour + instead of a day. `POST /auth/two-factor/email/resend` is limited to 2 per + challenge and 10 an hour per account, keeps the previous code usable, and + checks the account status. A password change ends the open second-factor + challenges. Regenerating recovery codes is refused while Redis is down. + +- A password attempt is reserved atomically in Redis (per account and per + address) before the hash is computed: a burst of simultaneous guesses no + longer outruns the budgets, which were read before the hash and written + after. With a CAPTCHA required, the identifier's budget no longer answers + `429` (every attempt already costs a challenge); the lockout still applies. + An unknown identifier costs the same database work as a wrong password, and + at most five second-factor challenges stay open per account. + +- Every OAuth consent needs a recent re-authentication, the instance's own + application included: an access token alone could approve an authorization + request for it and obtain a new, long-lived session. Signing an account out + from the administration needs one too, and mails the owner only when + sessions were actually ended. + +- nginx: `PUT` is allowed (the administration's role, client and webhook + updates answered `405`), `/.well-known/` serves the OAuth and OpenID Connect + metadata (they answered `403`), and the strict zone follows the API's strict + bucket by method and path through a map that a test checks against the + routers (it named routes that do not exist and missed most credential + routes). +- The internal listener (`/metrics`, detailed `/ready`) requires + `Authorization: Bearer `, required in production; Prometheus + reads it from a file. The public `/ready` reuses its answer for a second. +- `docker-compose.api.yml` mounts the secrets as files written by + `scripts/write-secrets.sh` to `/etc/auth-api/secrets` (root-only directory, + files readable by the image's user only): they no longer appear in `docker + inspect` or the process environment. +- `restore-db.sh` takes the database URL from `RESTORE_DATABASE_URL` or a file + (`-D`) instead of `-d `, out of `ps` and the shell history. + `pgbackrest.conf` is installed mode 600 and carries a commented offsite + repository. + +- Refused at start-up: `LOCKOUT_DURATION_SECS` under 60, `DEVICE_AUTH_TTL_SECS` + above 1800, a blank required variable (it counted as set), and in + production `DEVICE_AUTH_VERIFICATION_URI` without HTTPS and a + `TRUSTED_PROXY_CIDRS` network wider than `/8` (IPv4) or `/32` (IPv6). + `PWNED_PASSWORDS_FAIL_OPEN` stays allowed in production, documented as the + one fail-open switch kept, with its alert. + +- The maintenance functions keep minimums only the schema owner can lower + (`maintenance_floors`: six months of audit partitions, 30 days before an + audit address is coarsened, a day before a pending account is purged): the + runtime role can no longer use them to erase the audit trail. Only `v2` + ciphertexts (bound to their row) are read, and a secret in another format + stops the start. In production `DATABASE_URL`, `DATABASE_READ_URL` and + `REDIS_URL` must carry a password; `deploy/db/pg_hba.auth-api.conf` and + `deploy/db/users.acl.template` hold the rules to install. A blank variable + leaves its place to `X_FILE`. + +- Device flow: every approval needs a recent re-authentication, the instance's + own application included, and `GET /oauth/device/{user_code}` adds `scopes`, + `unavailable_scopes`, `unrestricted` and the session allowance. Unknown user + codes are budgeted per signed-in user as well as per address. +- A public client's request budget is split by client address (a confidential + client keeps one budget, spent once it has authenticated), and every client + authentication failure reads `client authentication failed`. +- Codes and approvals follow the client as registered now: a redirect URI + removed since the request receives no code and its codes no longer redeem, + and access tokens are narrowed to the client's current scopes at every + refresh. Authorization responses carry `iss` (RFC 9207). Access tokens carry + the header `typ: at+jwt` and a `client_id` claim for client sessions. +- Introspection describes a refresh token only to its own client and never a + personal access token. The metadata's `scopes_supported` lists only the + scopes some registered client may ask for. Outgoing calls (CAPTCHA, breached + passwords, identity providers) no longer follow redirects, and a provider's + key set is fetched again once when an ID token names an unknown key. + +- A rotated refresh token presented again within the 1-second grace window is + forgiven only from the network and user agent that rotated it; from anywhere + else the family is revoked as for any replay. Registrations are budgeted per + client address (`REGISTRATIONS_PER_IP_PER_HOUR`, 20 by default, `429` past + it), accounts never verified are purged after 2 days instead of 7 + (`CLEANUP_UNVERIFIED_ACCOUNT_DAYS`), and failed sign-ins are budgeted under + the identifier they are recorded with. + +- `GET /users/me/audit` no longer shows the id and address of an administrator + who changed the account, as the export already did. A refresh replay records + `same_network` instead of the two addresses in its audit metadata, which is + never coarsened nor erased. The purge of accounts never verified delivers + `user.deleted` to webhooks too. The export adds passkeys, personal access + tokens, linked identities and where each mailed link was asked from. Two + accounts confirming the same new address at once: the second gets `409 + email_taken` instead of `500`. + +- Administration: nobody adds to a role they hold a permission they lack + (`403`), the default role never grants an administrative permission (`409 + default_role_administration`), and an account holding administrative + permissions cannot remove its last second factor (`409 + administrator_needs_second_factor`; the grant itself is now checked under the + account's lock). Withdrawing or deleting a role, unlocking (never one's own + account), reactivating, deleting a client, deleting a webhook and retrying a + delivery need a recent re-authentication. `DELETE /admin/users/{id}` no longer + takes `current_password` in its body: re-authenticate with `POST + /users/me/reauth` first. The owner is mailed when an administrator suspends + or reactivates the account, changes its roles or signs it out (new + `changed_by_administrator` template); deleting a role writes `role_revoked` + in each holder's history. Sessions revoked by a suspension or a forced reset + are read in the revoking transaction. `--grant-role` and `--register-client` + commit with their audit entry, and `--register-client` validates like the + administration. + +- Wrong passwords lock the password for `LOCKOUT_DURATION_SECS` only: the count + covers a day, restarts after any completed sign-in (second factor, sign-in + link, passkey, external identity), an administrator's unlock, a password + reset or the end of a lock, and one guess per lock period no longer keeps an + account locked for good. Passkeys, sign-in links, external identities and + personal access tokens keep working during a lock. A locked password answers + `401 invalid_credentials` like a wrong one (it answered `403 + account_locked`), and the owner is mailed (new `account_locked` template, + English and French). +- Reset and sign-in links coexist until one is used (a new request revoked the + previous link), and their budget counts per client address (3 an hour) before + the account's (10 an hour). Second-factor failure budgets count per address, + with a ceiling five times higher for the account, and a password reset + restarts them. Signing in again within the minute an e-mail code stays fresh + continues the challenge instead of failing, and a second-factor sign-in + without Redis answers `503`. + +- `POST /auth/verify-email` takes the `password` of the registration that sent + the link, along with the `token`. A later registration on a pending address + could otherwise mail the owner a link carrying the attacker's password; a + resent link asks for the password the account was created with. A wrong + password answers `401 invalid_credentials` and leaves the link unused. + +- Tokens delegated to a client application (another client than the + instance's own, or any session restricted to consented scopes) and tokens + obtained from a personal access token no longer act as the account: the + account routes (`/users/me/*`), the approval routes + (`/oauth/authorization-requests/*`, `/oauth/device/*`) and `/admin/*` answer + `403 first_party_session_required`. Such a token could previously approve a + device flow of the instance's application and obtain an unrestricted + session. Logout, `/oauth/userinfo` and resource servers are unchanged. +- Approving a device of a client other than the instance's own application + requires a recent re-authentication, or `current_password` in the body of + `POST /oauth/device/verify`; `GET /oauth/device/{user_code}` tells it in + `reauthentication_required`. +- A registration on an address whose account is still pending verification + carries its own password, username and locale in its verification link, and + the link applies them. Registering someone's address first no longer lets an + attacker choose the password the owner activates. The links of a pending + account now coexist until one of them verifies it. +- Adding a passkey, a personal access token or an external identity e-mails + the owner (new `access_added` template, English and French). The e-mail sent + after a password change now lists what still opens the account, and a reset + sends it too. A reset that verifies a pending account deletes its second + factors, recovery codes, passkeys, identities and tokens. +- The callback of an external identity link no longer links: the link is made + by `POST /users/me/external-identities/complete`, once the binding proves the + browser that started the flow. A victim opening the provider URL of a link an + attacker started no longer gets their identity linked to the attacker's + account. +- `/admin/*` requires a session whose sign-in proved a second factor (TOTP, + email code, recovery code, or a passkey), recorded on the session; an + enrolled factor is no longer enough. An administrator + who signed in with a password alone, or a sign-in link, gets + `403 two_factor_required`. +- A role granting an administrative permission goes only to an active account + with a verified second factor or a passkey (`409 + administrator_without_second_factor`, also refused by `--grant-role`), and + no administrator grants a role to their own account (`403`). +- Creating, updating or re-keying a webhook, suspending an account, forcing a + password reset and removing a client secret need a recent re-authentication + (`403 reauthentication_required`). Webhook changes and redeliveries are + audited with the host of the endpoint, and client secret and unlock changes + commit with their audit entry. +- The last active account able to manage roles can no longer be suspended or + deleted, by an administrator or by its owner (`409 last_administrator`), and + two concurrent role withdrawals can no longer both pass the check. Suspended + accounts no longer count as able to manage roles. +- The database schema can belong to a separate owner role: the API then + connects with a role limited to reading and writing data + (`deploy/db/auth-api-grants.sql`), which cannot alter the schema, rewrite or + truncate the audit log, or change the permission catalog. The maintenance + functions that need more run with their owner's privileges. +- `deploy/db/postgresql.auth-api.conf` logs slow statements without their bound + values (`log_parameter_max_length = 0`): password hashes and token digests no + longer reach the PostgreSQL log. +- Requests to paths no route matches spend the general rate-limit budget, and + the HTTP metrics label them all ``: a scan no longer creates one + series per URL. +- The public `GET /ready` answers only `{"status": ...}`; which dependency is + down is served at `/ready` on the internal metrics listener. +- A passkey sign-in without the credential's user handle is refused + (`401 invalid_credentials`). +- Webhook deliveries no longer connect to local-use NAT64 (`64:ff9b:1::/48`) + or the former 6to4 relay range (`192.88.99.0/24`). +- An account's export no longer names the administrator who changed it, nor + the address they acted from. +- A lockout that cannot be applied is logged as an error and counted in + `auth_lockout_failures_total` (alert `AuthApiLockoutFailing`). +- Every variable can be given as `X_FILE`, the path of a file holding its + value (a Docker, Kubernetes or systemd secret), which keeps it out of + `docker inspect` and the process environment. + +- Granting, withdrawing, emptying or deleting a role needs every permission + it grants (`403`), checked under the role's lock: `roles:manage` alone can + no longer hand `admin` to an account it controls, nor strip it from a + greater administrator. +- Removing an account's access factors revokes every session, browser and + device ones included; a forced reset with `revoke_access_factors` is one + transaction (a refusal revokes nothing). The primary client is designated + and changed from the command line only + (`409 primary_client_managed_by_command_line`). Command-line changes record + the operator and host in the audit log. +- A session that exhausted its re-authentication budget stops filling the + account's: a stolen session can no longer lock its owner out of revoking it. + An administrator's unlock and a password reset forgive earlier sign-in + failures. E-mail change codes are budgeted per account across flows, and a + flow starts at most once a minute. The budgets guarding a link, a device + code or an e-mail change target now fail closed (`503`) when Redis cannot + count them; the mailbox budgets last as long as the links they bound. +- A registration on an address that already has an account reserves its + username until the link would expire: whether a username is taken no longer + tells whether an address is registered. Renames honour reservations. +- Every consent and device approval is audited (`client_authorized`, + `device_approved`, with the client, scopes and the requesting device), and + handing a session to a client is recorded as a sign-in, with the new-device + alert; a device session's `auth_time` is when the password was proven. +- Third-party clients never carry the account's roles; a client without + scopes needs `"unrestricted": true` (`422` otherwise). Redirect URIs must be + `https`, loopback `http` or a private-use scheme with a dot. Wrong client + secrets are budgeted per address and client id; a code replayed under a + public client's id without its verifier revokes nothing; + `error_description` no longer tells an account's state; an identity + provider's discovery document must name its configured issuer; OpenID + Connect scopes are refused with `client_credentials`; the primary client's + device flow is counted against its default quota and, for an account with a + second factor, approved only from a session that proved one + (`403 second_factor_session_required`). +- A consumed TOTP code stays refused 120 seconds, judged on the application's + clock. `POST /users/me/two-factor/recovery-codes/use` is removed: it granted + nothing and let a stolen session burn recovery codes. +- Production refuses an `ENCRYPTION_KEY` that is printable text, and every + configuration refuses a CORS entry that is not an origin and two JWT keys + sharing a key id (now 16 hex digits). A stored password hash may cost at most + twice the configured Argon2 parameters; profiles M, L and XL get 640, 768 + and 768 MiB so that worst case fits. +- The nftables rule also closes the compose bridge (`172.30.0.0/24`); nginx + limits count an IPv6 client's /64; `deploy/api/logrotate-auth-api` keeps the + access log 14 days; the infrastructure check verifies that PostgreSQL and + Redis listen on the VPN address only. + +- Every administrative invariant holds on every route: a role gains an + administrative permission only when each holder is active with a second + factor (`409 holders_without_second_factor`), and each holder is audited and + told; the primary client can neither be deleted nor have its secret changed + over HTTP; a password reset keeps the last second factor of an account + holding administration; the primary client needs a second-factor session in + the authorization code flow too; changing the password ends a lockout. +- An account whose address is not verified answers a password sign-in like a + wrong password (`401 invalid_credentials`, no longer `403 + email_not_verified`) and gets a new verification link: registering an + address and signing in with one's own password no longer tells whether it + had an account. Verification links are budgeted per client address first; a + registered address holds one username reservation at a time; a reset that + activates a pending account adopts the username of its latest registration; + a refresh refused for its address answers `token_invalid`. +- Sign-in challenges and e-mail change flows are kept in Redis under their + digest; a sign-in e-mail code completes its own challenge only (codes are + budgeted per challenge and per account); `POST /users/me/two-factor/email/send` + answers `404` unless a method is being set up; a lockout is applied, audited + and mailed once; no route hashes a password longer than 256 bytes. +- Administrative reads (account search, account detail, `/admin/audit`) are + audited in the administrator's history (`admin_data_read`, without the + search). The owner's history and export no longer show a command-line + operator or host; strangers' user agents in the export are reduced to their + family. The runtime role cannot delete events from the outbox or webhook + deliveries (owner functions with floors do). Pointing a webhook at another + host regenerates its secret, returned once by `PUT /admin/webhooks/{id}`. +- OAuth: an identity provider's endpoints must use its issuer's transport; + redirect URIs with a fragment or credentials are refused; a client + credentials token is introspected against the client's current scopes; a + refresh may narrow `scope` and is refused (`invalid_scope`) when it widens + it; device refusals are audited (`device_denied`). +- Production names trusted proxies one address at a time (`/32`, `/128`) and + bounds `RECOVERY_CODE_EXPIRY_DAYS` to 1-730; weakened settings are logged at + startup. An IPv4-mapped peer is compared as IPv4. The NATS broker runs + read-only with a PID limit, its monitoring endpoint on loopback and the + exporter in its network namespace. nginx redirects to a fixed host and + limits connections per client network. `write-secrets.sh` reads pass + directly. The runtime role executes the owner-privileged functions by name. + New `METRICS_HOST`. Errors of the Pwned Passwords API are logged without + their URL. + +### Upgrading + +- Administrators signed in before the upgrade sign in again with their second + factor: sessions opened earlier carry no proof of it. +- The migrations were rewritten (18 files instead of 28): a database migrated + by an earlier version is not upgraded but recreated. Development databases: + drop and recreate them (`make dev` migrates the new one). +- Create the database with two roles (database deployment guide, section + 2.5): `auth_api_owner` runs the migrations, the API connects as `auth_api` + after `deploy/db/auth-api-grants.sql`. +- Check the settings refused at start-up (listed under Security) against your + environment before upgrading. +- Monitoring that reads the dependencies from the public `/ready` must query + the internal listener instead (`http://10.0.0.1:9465/ready`), with the new + `METRICS_TOKEN` (`pass insert prod/auth-api/metrics-token`, `openssl rand -hex + 32`); install it for Prometheus as the monitoring guide shows. +- Install `deploy/api/nftables-auth-api.conf` on the API VPS and run the + rolling update with `sudo -E` (update guide); copy the new ACL template line + to `/etc/redis/users.acl`. +- Resource servers that introspect access tokens need `allows_introspection` + (`PUT /admin/clients/{client_id}`). +- Deployments export the secrets as before, then run `./write-secrets.sh` + before `./rolling-update.sh` (update guide, section 4). +- Copy the new `log_parameter_max_length` lines of + `deploy/db/postgresql.auth-api.conf` and reload PostgreSQL. +- Reinstall `deploy/api/nftables-auth-api.conf` (new bridge rule) and install + `deploy/api/logrotate-auth-api` (nginx guide, Logs). Check that Redis is 7 + or later. +- Scripts saving clients without scopes through `PUT /admin/clients/{id}` add + `"unrestricted": true`; the primary client is changed with + `auth-api --register-client ... --primary`. Clients registered with other + redirect schemes than `https`, loopback `http` or `reverse.domain:` are + refused at their next save. +- Callers of `POST /users/me/two-factor/recovery-codes/use` stop calling it: + recovery codes are used at sign-in (`/auth/two-factor/recovery`). +- Resource servers that authorized third-party tokens by role authorize them + by permission: those tokens no longer carry roles. +- Front ends that told an unverified user so at sign-in: `/auth/login` now + answers `401 invalid_credentials` and mails a new link; point users at their + mailbox after a registration instead. +- `TRUSTED_PROXY_CIDRS` lists addresses in production (`172.30.0.1/32` as + shipped). `write-secrets.sh` reads pass itself: stop exporting the secrets + before running it. Reapply `deploy/db/auth-api-grants.sql` (functions granted + by name, no delete on the outbox and deliveries). Update + `docker-compose.api.yml` and `nats.conf` together (monitoring on loopback). +- Scripts calling `PUT /admin/webhooks/{id}` with a new host store the + `secret` of the response. + ## [2.0.1] - 2026-09-18 Dependency and image updates; no change to the API, the events or the @@ -377,8 +777,8 @@ HTTP contract and the configuration: read **Breaking changes** and `noeviction`, append-only persistence, the default user disabled and an `auth_api` ACL user without administrative or dangerous commands (`REDIS_URL` becomes `redis://auth_api:@10.0.0.2:6379`); kernel - settings (overcommit, swappiness, no transparent huge pages). Migration 0027 - vacuums `sessions` and `login_attempts` once 2 % of their rows changed + settings (overcommit, swappiness, no transparent huge pages). The migrations + vacuum `sessions` and `login_attempts` once 2 % of their rows changed instead of 20 %. The API VPS no longer opens a WireGuard port it never listened on. - Backups fail loudly and restore safely. `backup-db.sh` reads diff --git a/Cargo.lock b/Cargo.lock index 70d12ab..d379e93 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -186,7 +186,7 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "auth-api" -version = "2.0.1" +version = "2.1.0" dependencies = [ "aes-gcm", "anyhow", @@ -218,12 +218,14 @@ dependencies = [ "proptest", "rand 0.10.2", "rand_core 0.6.4", + "regex", "reqwest", "serde", "serde_json", "sha1 0.11.0", "sha2 0.11.0", "sqlx", + "subtle", "tera", "testkit", "thiserror 2.0.20", diff --git a/Cargo.toml b/Cargo.toml index 2cbc4a6..1f742e5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "auth-api" -version = "2.0.1" +version = "2.1.0" edition = "2024" rust-version = "1.88" publish = false @@ -44,6 +44,7 @@ ciborium = "0.2.2" form_urlencoded = "1.2" hmac = "0.13.0" sha2 = "0.11.0" +subtle = "2.6" sqlx = { version = "0.8.6", default-features = false, features = ["runtime-tokio-rustls", "postgres", "uuid", "time", "derive", "json", "ipnetwork", "migrate"] } tera = { version = "2.0", features = ["glob_fs"] } thiserror = "2.0.18" @@ -70,6 +71,7 @@ testkit = { path = "crates/testkit" } criterion = { version = "0.8.2", features = ["html_reports"] } futures = "0.3" proptest = "1.9.0" +regex = "1" tokio = { version = "1.50.0", features = ["full", "test-util"] } tower = { version = "0.5.3", features = ["util"] } diff --git a/Dockerfile b/Dockerfile index d1d9758..656a71c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,11 +31,11 @@ FROM chef AS builder COPY --from=planner /app/recipe.json recipe.json # Cache layer: compile dependencies only -RUN cargo chef cook --release --recipe-path recipe.json +RUN cargo chef cook --release --locked --recipe-path recipe.json # Compile the binary COPY . . -RUN cargo build --release --bin auth-api +RUN cargo build --release --locked --bin auth-api # ============================================================================= # Stage 4: Runtime diff --git a/Makefile b/Makefile index e10af04..edff326 100644 --- a/Makefile +++ b/Makefile @@ -226,8 +226,8 @@ release: infra-check stack-test ## Build a signed release bundle in dist/ (VERSI docker save auth-api:$(VERSION) | gzip > dist/auth-api-$(VERSION)/auth-api-$(VERSION).image.tar.gz docker image inspect --format '{{.Id}}' auth-api:$(VERSION) > dist/auth-api-$(VERSION)/IMAGE_ID git archive HEAD migrations docker-compose.api.yml docker-compose.api.l.yml config.prod.env \ - nats.conf deploy/profiles deploy/db nginx/nginx.conf \ - scripts/backup-db.sh scripts/restore-db.sh scripts/backup-drill.sh scripts/rolling-update.sh \ + nats.conf deploy/profiles deploy/db deploy/api nginx/nginx.conf \ + scripts/backup-db.sh scripts/restore-db.sh scripts/backup-drill.sh scripts/rolling-update.sh scripts/write-secrets.sh \ docs/deploy/guides/prometheus-alerts.yml deploy/monitoring | tar -x -C dist/auth-api-$(VERSION) cd dist/auth-api-$(VERSION) && find . -type f ! -name 'SHA256SUMS*' -print0 | sort -z | xargs -0 sha256sum > SHA256SUMS ssh-keygen -Y sign -q -f $(RELEASE_SIGNING_KEY) -n auth-api-release dist/auth-api-$(VERSION)/SHA256SUMS diff --git a/benches/core_benches.rs b/benches/core_benches.rs index 1c00e3f..fb93177 100644 --- a/benches/core_benches.rs +++ b/benches/core_benches.rs @@ -92,8 +92,9 @@ fn totp_benches(c: &mut Criterion) { let secret = totp::generate_secret(); // The production path: a keyring and a versioned ciphertext. let keyring = auth_api::utils::crypto::Keyring::new([7u8; 32], None); + let owner = uuid::Uuid::nil(); let encrypted = keyring - .encrypt(&secret) + .encrypt(&secret, owner.as_bytes()) .expect("failed to encrypt benchmark secret"); group.bench_function("generate_secret", |b| b.iter(totp::generate_secret)); @@ -111,6 +112,7 @@ fn totp_benches(c: &mut Criterion) { let code = totp::current_code(&secret).expect("code"); totp::verify_code( black_box(&encrypted), + owner, black_box(&code), black_box(&keyring), 1, diff --git a/crates/testkit/src/app.rs b/crates/testkit/src/app.rs index ed508d0..a7603b8 100644 --- a/crates/testkit/src/app.rs +++ b/crates/testkit/src/app.rs @@ -374,7 +374,18 @@ impl TestApp { /// Delete the anti-spam cooldown of email 2FA, so the next login can send /// a code right away. pub async fn clear_email_2fa_cooldown(&self, user_id: uuid::Uuid) { - self.delete_redis_key(&format!("email2fa_cd:{user_id}")) + // The setup cooldown, each challenge's, and the account's hourly + // budget of sign-in codes. + if let Ok(mut conn) = self.redis.get().await { + let keys: Vec = conn + .keys(format!("email2fa_cd:{user_id}*")) + .await + .unwrap_or_default(); + if !keys.is_empty() { + let _: Result<(), _> = conn.del(keys).await; + } + } + self.delete_redis_key(&format!("email2fa_send_user:{user_id}")) .await; } @@ -384,11 +395,15 @@ impl TestApp { let mut conn = self.redis.get().await.expect("redis connection failed"); let raw: String = conn - .get(format!("email_change_flow:{flow_token}")) + .get(format!( + "email_change_flow:{}", + auth_api::utils::crypto::token_id(flow_token) + )) .await .expect("email_change flow state not found in Redis"); let state: serde_json::Value = serde_json::from_str(&raw).unwrap(); + let user_id: uuid::Uuid = state["user_id"].as_str().unwrap().parse().unwrap(); let hash_b64 = state["otp_hash"] .as_str() .expect("otp_hash missing from flow state"); @@ -396,13 +411,24 @@ impl TestApp { .decode(hash_b64) .unwrap(); - brute_force_otp(&hash_bytes) + brute_force_otp(&hash_bytes, |code| { + self.state + .keyring + .otp_digest("email_change", user_id.as_bytes(), code) + }) } /// Clear the per-user email-change cooldown so a second flow can start. pub async fn clear_email_change_cooldown(&self, user_id: uuid::Uuid) { self.delete_redis_key(&format!("email_change_cd:{user_id}")) .await; + self.clear_email_change_start_cooldown(user_id).await; + } + + /// Lift the minute between two starts of an e-mail change. + pub async fn clear_email_change_start_cooldown(&self, user_id: uuid::Uuid) { + self.delete_redis_key(&format!("email_change_start_cd:{user_id}")) + .await; } pub async fn clear_recent_reauth(&self, access_token: &str) { @@ -520,12 +546,16 @@ pub fn test_config(db_url: &str, redis_url: &str, nats_url: &str) -> Config { sensitive_action_reauth_secs: 600, new_device_alerts: true, magic_links: true, + registrations_per_ip_per_hour: 10_000, + reset_revokes_factors_added_hours: 72, }, captcha: CaptchaConfig { secret: None, verify_url: "https://hcaptcha.com/siteverify".into(), request_timeout_secs: 1, fail_open_on_error: false, + site_key: None, + expected_hostnames: Vec::new(), }, cors: CorsConfig { allowed_origins: vec!["*".into()], @@ -594,17 +624,21 @@ pub fn test_config(db_url: &str, redis_url: &str, nats_url: &str) -> Config { }, metrics: MetricsConfig { enabled: false, + host: "127.0.0.1".into(), port: 9464, + token: None, }, } } /// Recover a 6-digit OTP from its SHA-256 digest. -pub fn brute_force_otp(expected_hash: &[u8]) -> String { - use sha2::{Digest, Sha256}; +/// The six-digit code whose digest, by `digest`, is `expected_hash`: tests +/// hold the application keyring, an attacker holding only the stored digest +/// does not. +pub fn brute_force_otp(expected_hash: &[u8], digest: impl Fn(&str) -> [u8; 32]) -> String { (0u32..1_000_000) .map(|n| format!("{n:06}")) - .find(|candidate| Sha256::digest(candidate.as_bytes()).as_slice() == expected_hash) + .find(|candidate| digest(candidate).as_slice() == expected_hash) .expect("OTP not found in the 6-digit space") } @@ -659,6 +693,7 @@ mod tests { #[test] fn otp_is_recovered_from_its_digest() { use sha2::{Digest, Sha256}; - assert_eq!(brute_force_otp(&Sha256::digest(b"004217")), "004217"); + let digest = |code: &str| -> [u8; 32] { Sha256::digest(code.as_bytes()).into() }; + assert_eq!(brute_force_otp(&digest("004217"), digest), "004217"); } } diff --git a/crates/testkit/src/mailpit.rs b/crates/testkit/src/mailpit.rs index 5d3996b..4423885 100644 --- a/crates/testkit/src/mailpit.rs +++ b/crates/testkit/src/mailpit.rs @@ -73,7 +73,11 @@ impl MailpitClient { pub async fn wait_for_message(&self, email: &str, subject: &str) -> Option { // Record "now" before we start polling so we can discard any pre-existing // messages that happen to match the same email + subject. - let not_before = time::OffsetDateTime::now_utc() - time::Duration::milliseconds(500); // small back-buffer for clock skew + // Back-buffer: the message may have been sent a while before this call + // (padded responses, the contract validators built on a process's + // first request). Addresses are unique per test, so older messages + // to the same address come from earlier runs, seconds before. + let not_before = time::OffsetDateTime::now_utc() - time::Duration::seconds(3); let deadline = std::time::Instant::now() + std::time::Duration::from_millis(5_000); while std::time::Instant::now() < deadline { diff --git a/crates/verifier/tests/verify.rs b/crates/verifier/tests/verify.rs index 5d99991..f58aa84 100644 --- a/crates/verifier/tests/verify.rs +++ b/crates/verifier/tests/verify.rs @@ -76,8 +76,8 @@ async fn forged_expired_and_foreign_tokens_are_refused() { async fn introspection_catches_a_token_revoked_before_it_expires() { let app = TestApp::spawn().await; sqlx::query( - "INSERT INTO registered_clients (client_id, display_name, client_secret_hash) - VALUES ('resource-server', 'Resource server', $1)", + "INSERT INTO registered_clients (client_id, display_name, client_secret_hash, allows_introspection) + VALUES ('resource-server', 'Resource server', $1, TRUE)", ) .bind(auth_api::utils::crypto::sha256(b"aacs_rs-secret").to_vec()) .execute(&app.db) diff --git a/deploy/api/logrotate-auth-api b/deploy/api/logrotate-auth-api new file mode 100644 index 0000000..4a4d526 --- /dev/null +++ b/deploy/api/logrotate-auth-api @@ -0,0 +1,21 @@ +# Rotation of the auth-api access log: 14 days at most. The log holds client +# addresses and user agents; keeping them longer than the application keeps +# full addresses in its audit log would undo its retention policy +# (docs/dev/privacy.md). Takes precedence over the nginx package's weekly +# rotation of /var/log/nginx/*.log for this file. +# +# Install: sudo install -m 644 deploy/api/logrotate-auth-api /etc/logrotate.d/auth-api +# Check: sudo logrotate --debug /etc/logrotate.d/auth-api +/var/log/nginx/auth-api.access.log { + daily + rotate 14 + missingok + notifempty + compress + delaycompress + create 0640 www-data adm + sharedscripts + postrotate + invoke-rc.d nginx rotate >/dev/null 2>&1 || true + endscript +} diff --git a/deploy/api/nftables-auth-api.conf b/deploy/api/nftables-auth-api.conf new file mode 100644 index 0000000..f0d8209 --- /dev/null +++ b/deploy/api/nftables-auth-api.conf @@ -0,0 +1,30 @@ +# Only nginx (and root, for the rolling update's readiness checks) reaches the +# API instances on the API VPS. Docker publishes them on 127.0.0.1:3001-3004, +# and the application trusts X-Forwarded-For from the address those +# connections arrive with: without this rule, any other process on the host +# could forge a client address and bypass the per-address limits. +# +# Install (docs/deploy/api/deployment.md): +# sudo install -m 644 deploy/api/nftables-auth-api.conf /etc/nftables.d/auth-api.conf +# echo 'include "/etc/nftables.d/*.conf"' | sudo tee -a /etc/nftables.conf +# sudo systemctl enable --now nftables && sudo nft -f /etc/nftables.conf +# +# The instances are also reachable at their own addresses on the compose +# bridge (172.30.0.0/24), where connections arrive from the gateway +# 172.30.0.1 - the trusted proxy. The second rule closes that path too, for the +# application port and the internal listener; it also covers the published +# ports when Docker rewrites them to the bridge (userland proxy disabled). +# +# Check: `sudo -u nobody curl -s 127.0.0.1:3001/live` and +# `sudo -u nobody curl -s 172.30.0.2:3000/live` are refused, while +# `sudo curl -fsS 127.0.0.1:3001/live` and `curl -fsS https:///live` answer. +# www-data is the user nginx workers run as on Debian; the Docker port proxy +# runs as root. + +table inet auth_api_local { + chain output { + type filter hook output priority 0; policy accept; + oifname "lo" tcp dport 3001-3004 meta skuid != { 0, "www-data" } reject with tcp reset + ip daddr 172.30.0.0/24 tcp dport { 3000, 9464 } meta skuid != { 0, "www-data" } reject with tcp reset + } +} diff --git a/deploy/db/auth-api-grants.sql b/deploy/db/auth-api-grants.sql new file mode 100644 index 0000000..e89cf4d --- /dev/null +++ b/deploy/db/auth-api-grants.sql @@ -0,0 +1,62 @@ +-- Privileges of the runtime role `auth_api` on a schema owned by +-- `auth_api_owner` (docs/deploy/database/deployment.md, section 2.1). Run as +-- postgres in the auth-api database, after the first migration run: +-- sudo -u postgres psql -d auth_api -f auth-api-grants.sql +-- Running it again is harmless. Later migrations run as `auth_api_owner` and +-- the default privileges below extend to the tables they create. +-- +-- The runtime role reads and writes data and nothing else: it cannot alter, +-- drop or truncate a table, disable a trigger, rewrite the audit log, change +-- the permission catalog or the migration history. A compromised application +-- or an SQL injection is bounded by that. + +GRANT USAGE ON SCHEMA public TO auth_api; +GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO auth_api; +GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO auth_api; +-- The functions running with the owner's privileges are granted by name: a +-- function added later runs for the application only once listed here. Every +-- other function keeps PostgreSQL's default EXECUTE for everyone. +GRANT EXECUTE ON FUNCTION + rotate_audit_log_partitions(INTEGER, INTEGER), + forget_account_traces(UUID), + purge_unverified_accounts(INTERVAL, INTEGER), + coarsen_audit_addresses(INTERVAL, INTEGER), + cleanup_published_events(INTERVAL, INTEGER), + cleanup_finished_webhook_deliveries(INTERVAL, INTEGER) +TO auth_api; + +ALTER DEFAULT PRIVILEGES FOR ROLE auth_api_owner IN SCHEMA public + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO auth_api; +ALTER DEFAULT PRIVILEGES FOR ROLE auth_api_owner IN SCHEMA public + GRANT USAGE, SELECT ON SEQUENCES TO auth_api; + +-- The audit log is append-only for the application: rows are rewritten only +-- by the maintenance functions of the migrations, which run as the owner, and removed only +-- with their partition. +REVOKE UPDATE, DELETE ON audit_log FROM auth_api; +DO $$ +DECLARE + partition RECORD; +BEGIN + FOR partition IN + SELECT c.relname FROM pg_inherits i + JOIN pg_class c ON c.oid = i.inhrelid + JOIN pg_class p ON p.oid = i.inhparent + WHERE p.relname = 'audit_log' + LOOP + EXECUTE format('REVOKE UPDATE, DELETE ON %I FROM auth_api', partition.relname); + END LOOP; +END +$$; + +-- Outgoing events and webhook deliveries leave only through the owner's +-- retention functions: the runtime role marks them sent or failed, never +-- deletes them, so an unpublished `user.deleted` cannot be made to vanish. +REVOKE DELETE ON event_outbox, webhook_deliveries FROM auth_api; + +-- The permission catalog and the migration history change with migrations only. +REVOKE INSERT, UPDATE, DELETE ON permissions FROM auth_api; +REVOKE INSERT, UPDATE, DELETE ON _sqlx_migrations FROM auth_api; +-- The minimums of the maintenance functions are the owner's to change: the +-- runtime role calls the functions but cannot lower what they keep. +REVOKE INSERT, UPDATE, DELETE ON maintenance_floors FROM auth_api; diff --git a/deploy/db/auth-api-role.sql b/deploy/db/auth-api-role.sql index a732e9c..b257e2c 100644 --- a/deploy/db/auth-api-role.sql +++ b/deploy/db/auth-api-role.sql @@ -3,8 +3,8 @@ -- -- A statement or a lock wait gives up before the API's own 30-second request -- timeout, and a connection left idle inside a transaction is closed instead of --- holding its locks. Migrations lift the statement timeout for their own --- session (see docs/deploy/database/deployment.md, section 2.5). +-- holding its locks. Migrations run as `auth_api_owner`, which carries no +-- such limit (see docs/deploy/database/deployment.md, section 2.5). ALTER ROLE auth_api SET statement_timeout = '25s'; ALTER ROLE auth_api SET lock_timeout = '10s'; ALTER ROLE auth_api SET idle_in_transaction_session_timeout = '60s'; diff --git a/deploy/db/pg_hba.auth-api.conf b/deploy/db/pg_hba.auth-api.conf new file mode 100644 index 0000000..c92a5c1 --- /dev/null +++ b/deploy/db/pg_hba.auth-api.conf @@ -0,0 +1,12 @@ +# Access rules of the auth-api database, appended to +# /etc/postgresql/17/main/pg_hba.conf (docs/deploy/database/deployment.md, +# section 2.2). Only the API VPS, through its WireGuard address, and only with +# a password (SCRAM): no `trust`, no wider network. Rules are read top down: +# nothing above them may grant more. +# +# Where the tunnel is not the trust boundary, write `hostssl` instead of `host` +# and add `sslmode=verify-full` to both connection URLs. + +# TYPE DATABASE USER ADDRESS METHOD +host auth_api auth_api 10.0.0.1/32 scram-sha-256 +host auth_api auth_api_owner 10.0.0.1/32 scram-sha-256 diff --git a/deploy/db/pgbackrest.conf b/deploy/db/pgbackrest.conf index 8b13757..73c331d 100644 --- a/deploy/db/pgbackrest.conf +++ b/deploy/db/pgbackrest.conf @@ -1,8 +1,9 @@ # pgBackRest for auth-api, profiles M and L: continuous WAL archiving for # point-in-time recovery, an encrypted repository, a full backup every week and a # differential one every day, two full backups kept (about two weeks of history). -# Copy to /etc/pgbackrest/pgbackrest.conf (owner postgres, mode 640) and set the -# cipher passphrase from pass (docs/deploy/database/deployment.md, section 4.7). +# Copy to /etc/pgbackrest/pgbackrest.conf, owner postgres, mode 600: it holds +# the cipher passphrase, set from pass (docs/deploy/database/deployment.md, +# section 4.7). Nobody else on the host may read it. [global] repo1-path=/var/lib/pgbackrest @@ -16,8 +17,19 @@ archive-async=y spool-path=/var/spool/pgbackrest log-level-console=info log-level-file=detail -# Offsite copy: add a second repository (repo2-type=s3, repo2-s3-bucket=..., -# repo2-retention-full=4); pgBackRest pushes every backup and WAL segment to both. +# Offsite copy: a server lost with its disk takes repo1 with it. Uncomment and +# fill a second repository; pgBackRest pushes every backup and WAL segment to +# both. Its key and secret come from pass as well. +# repo2-type=s3 +# repo2-path=/auth-api +# repo2-s3-bucket=REPLACE_WITH_bucket +# repo2-s3-endpoint=REPLACE_WITH_endpoint +# repo2-s3-region=REPLACE_WITH_region +# repo2-s3-key=REPLACE_WITH_pass_prod/auth-api/pgbackrest-s3-key +# repo2-s3-key-secret=REPLACE_WITH_pass_prod/auth-api/pgbackrest-s3-secret +# repo2-retention-full=4 +# repo2-cipher-type=aes-256-cbc +# repo2-cipher-pass=REPLACE_WITH_pass_prod/auth-api/pgbackrest-cipher-pass [auth_api] pg1-path=/var/lib/postgresql/17/main diff --git a/deploy/db/postgresql.auth-api.conf b/deploy/db/postgresql.auth-api.conf index fd7aef3..9d7c50b 100644 --- a/deploy/db/postgresql.auth-api.conf +++ b/deploy/db/postgresql.auth-api.conf @@ -34,7 +34,11 @@ shared_preload_libraries = 'pg_stat_statements' track_io_timing = on # Logs: slow statements, lock waits, long autovacuum runs and checkpoints. +# Statements are logged without their bound values: a slow insert carries a +# password hash, a token digest or a typed identifier. log_min_duration_statement = 250ms +log_parameter_max_length = 0 +log_parameter_max_length_on_error = 0 log_lock_waits = on log_autovacuum_min_duration = 1s log_checkpoints = on diff --git a/deploy/db/users.acl.template b/deploy/db/users.acl.template new file mode 100644 index 0000000..d9d5830 --- /dev/null +++ b/deploy/db/users.acl.template @@ -0,0 +1,9 @@ +# Redis users of auth-api, installed as /etc/redis/users.acl (mode 0600, owner +# redis) by docs/deploy/database/deployment.md, section 3.1, which replaces +# REDIS_PASSWORD_SHA256 with the SHA-256 of the password kept in pass. The +# default user is off: a connection without the password can do nothing. The +# API's user runs every command but the administrative and dangerous ones; +# the scripts of the rate limiter and the budgets (EVAL, EVALSHA, SCRIPT LOAD) +# are allowed by name, whatever category a Redis version files them under. +user default off +user auth_api on #REDIS_PASSWORD_SHA256 ~* &* +@all -@dangerous -@admin +eval +evalsha +script|load +eval +evalsha +script|load diff --git a/deploy/monitoring/docker-compose.monitoring.yml b/deploy/monitoring/docker-compose.monitoring.yml index 60b2b65..aaa31c3 100644 --- a/deploy/monitoring/docker-compose.monitoring.yml +++ b/deploy/monitoring/docker-compose.monitoring.yml @@ -20,6 +20,8 @@ services: volumes: - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro - ./rules:/etc/prometheus/rules:ro + # The API's METRICS_TOKEN, one line, mode 0400 (monitoring guide). + - ./secrets:/etc/prometheus/secrets:ro - prometheus_data:/prometheus read_only: true cap_drop: [ALL] diff --git a/deploy/monitoring/prometheus.yml b/deploy/monitoring/prometheus.yml index bde95aa..b1208ac 100644 --- a/deploy/monitoring/prometheus.yml +++ b/deploy/monitoring/prometheus.yml @@ -18,6 +18,11 @@ scrape_configs: # The API instances (metrics listener of docker-compose.api.yml). Each one # also publishes its container's memory, limit and CPU throttling. - job_name: auth-api + # The internal listener requires METRICS_TOKEN (pass + # prod/auth-api/metrics-token), written to this file on the monitoring host. + authorization: + type: Bearer + credentials_file: /etc/prometheus/secrets/auth-api-metrics-token static_configs: # Profile L: add 10.0.0.1:9467 and 10.0.0.1:9468. - targets: ["10.0.0.1:9465", "10.0.0.1:9466"] diff --git a/deploy/monitoring/rules/infrastructure.test.yml b/deploy/monitoring/rules/infrastructure.test.yml index ba241aa..1f59b4c 100644 --- a/deploy/monitoring/rules/infrastructure.test.yml +++ b/deploy/monitoring/rules/infrastructure.test.yml @@ -173,6 +173,8 @@ tests: values: "0 1 1" - series: 'auth_webhook_deliveries_total{instance="10.0.0.1:9465",outcome="failed"}' values: "0 0 1 1" + - series: 'auth_lockout_failures_total{job="auth-api",instance="10.0.0.1:9466",step="lock"}' + values: "0 1 1" alert_rule_test: - eval_time: 15m alertname: AuthApiNotificationsFailing @@ -191,6 +193,13 @@ tests: exp_alerts: - exp_labels: { severity: warning, job: auth-api, instance: "10.0.0.1:9465" } exp_annotations: { summary: "a retention job failed on 10.0.0.1:9465" } + - eval_time: 2m + alertname: AuthApiLockoutFailing + exp_alerts: + - exp_labels: { severity: critical, job: auth-api, instance: "10.0.0.1:9466" } + exp_annotations: + summary: "10.0.0.1:9466 could not lock an account under password guessing" + description: "Brute force is then held by the rate limits only: check the database, then the error logs (\"lockout could not be applied\")." - eval_time: 3m alertname: AuthApiWebhooksFailing exp_alerts: diff --git a/deploy/monitoring/rules/infrastructure.yml b/deploy/monitoring/rules/infrastructure.yml index 168396c..be01a51 100644 --- a/deploy/monitoring/rules/infrastructure.yml +++ b/deploy/monitoring/rules/infrastructure.yml @@ -204,3 +204,11 @@ groups: severity: warning annotations: summary: "a retention job failed on {{ $labels.instance }}" + + - alert: AuthApiLockoutFailing + expr: sum by (job, instance) (increase(auth_lockout_failures_total[15m])) > 0 + labels: + severity: critical + annotations: + summary: "{{ $labels.instance }} could not lock an account under password guessing" + description: "Brute force is then held by the rate limits only: check the database, then the error logs (\"lockout could not be applied\")." diff --git a/deploy/profiles/l.env b/deploy/profiles/l.env index 27311ff..00caa79 100644 --- a/deploy/profiles/l.env +++ b/deploy/profiles/l.env @@ -2,7 +2,7 @@ # Four instances: add docker-compose.api.l.yml. API VPS: 20 vCPU, 8 GB RAM. # Sizing rules: docs/deploy/guides/operations.md, section 9. API_CPUS=4 -API_MEMORY=512M +API_MEMORY=768M API_MEMORY_RESERVATION=256M ARGON2_MAX_CONCURRENCY=4 DB_MAX_CONNECTIONS=16 diff --git a/deploy/profiles/m.env b/deploy/profiles/m.env index 01130ce..c456534 100644 --- a/deploy/profiles/m.env +++ b/deploy/profiles/m.env @@ -1,7 +1,7 @@ # Profile M: up to 1 million accounts, 56 sign-ins per second at peak. # API VPS: 8 vCPU, 4 GB RAM. Sizing rules: docs/deploy/guides/operations.md, section 9. API_CPUS=3 -API_MEMORY=512M +API_MEMORY=640M API_MEMORY_RESERVATION=256M ARGON2_MAX_CONCURRENCY=3 DB_MAX_CONNECTIONS=12 diff --git a/deploy/profiles/xl.env b/deploy/profiles/xl.env index b5a67c4..f77da7c 100644 --- a/deploy/profiles/xl.env +++ b/deploy/profiles/xl.env @@ -6,7 +6,7 @@ # Extrapolated from the measured per-CPU rate, not validated by `make sizing`: # docs/deploy/guides/operations.md, section 9. API_CPUS=4 -API_MEMORY=512M +API_MEMORY=768M API_MEMORY_RESERVATION=256M ARGON2_MAX_CONCURRENCY=4 DB_MAX_CONNECTIONS=12 diff --git a/docker-compose.api.yml b/docker-compose.api.yml index 43d1fa9..71c7a79 100644 --- a/docker-compose.api.yml +++ b/docker-compose.api.yml @@ -3,8 +3,8 @@ # # Sizing comes from a profile passed with --env-file (deploy/profiles/s.env, # m.env or l.env): CPU and memory per instance, Argon2 concurrency, pool sizes, -# broker limits. Secrets are exported from pass before running (see -# docs/deploy/guides/update.md). scripts/rolling-update.sh replaces the +# broker limits. Secrets are exported from pass and written to root-only files +# by scripts/write-secrets.sh before running (see docs/deploy/guides/update.md). scripts/rolling-update.sh replaces the # instances one at a time, so an update never stops the service. x-api: &api @@ -12,25 +12,44 @@ x-api: &api image: auth-api:${AUTH_API_VERSION:?set AUTH_API_VERSION to the release being deployed} env_file: config.prod.env environment: - # Secrets, exported from pass. `:?` stops compose when one is missing. - DATABASE_URL: ${DATABASE_URL:?export DATABASE_URL} - REDIS_URL: ${REDIS_URL:?export REDIS_URL} - JWT_PRIVATE_KEY: ${JWT_PRIVATE_KEY:?export JWT_PRIVATE_KEY} - JWT_PUBLIC_KEY: ${JWT_PUBLIC_KEY:?export JWT_PUBLIC_KEY} - # Only during a key rotation; empty means unset. - JWT_PREVIOUS_PUBLIC_KEY: ${JWT_PREVIOUS_PUBLIC_KEY:-} - JWT_NEXT_PUBLIC_KEY: ${JWT_NEXT_PUBLIC_KEY:-} - ENCRYPTION_KEY: ${ENCRYPTION_KEY:?export ENCRYPTION_KEY} - PREVIOUS_ENCRYPTION_KEY: ${PREVIOUS_ENCRYPTION_KEY:-} - SMTP_USERNAME: ${SMTP_USERNAME:?export SMTP_USERNAME} - SMTP_PASSWORD: ${SMTP_PASSWORD:?export SMTP_PASSWORD} - CAPTCHA_SECRET: ${CAPTCHA_SECRET:?export CAPTCHA_SECRET} + # Secrets are files written by scripts/write-secrets.sh and mounted below: + # each variable X is read from the file named by X_FILE, so the values + # appear neither in `docker inspect` nor in the process environment. An + # optional secret (a rotation's previous or next key) is an empty file + # outside a rotation. + DATABASE_URL_FILE: /run/secrets/database_url + REDIS_URL_FILE: /run/secrets/redis_url + JWT_PRIVATE_KEY_FILE: /run/secrets/jwt_private_key + JWT_PUBLIC_KEY_FILE: /run/secrets/jwt_public_key + JWT_PREVIOUS_PUBLIC_KEY_FILE: /run/secrets/jwt_previous_public_key + JWT_NEXT_PUBLIC_KEY_FILE: /run/secrets/jwt_next_public_key + ENCRYPTION_KEY_FILE: /run/secrets/encryption_key + PREVIOUS_ENCRYPTION_KEY_FILE: /run/secrets/previous_encryption_key + SMTP_USERNAME_FILE: /run/secrets/smtp_username + SMTP_PASSWORD_FILE: /run/secrets/smtp_password + CAPTCHA_SECRET_FILE: /run/secrets/captcha_secret # nats://@nats:4222, the token of nats-auth.conf. - NATS_URL: ${NATS_URL:?export NATS_URL} + NATS_URL_FILE: /run/secrets/nats_url + # Bearer token of the internal listener, given to Prometheus too. + METRICS_TOKEN_FILE: /run/secrets/metrics_token # Sizing, from the profile. ARGON2_MAX_CONCURRENCY: ${ARGON2_MAX_CONCURRENCY:?pass a profile with --env-file} DB_MAX_CONNECTIONS: ${DB_MAX_CONNECTIONS:?pass a profile with --env-file} REDIS_POOL_SIZE: ${REDIS_POOL_SIZE:?pass a profile with --env-file} + secrets: + - database_url + - redis_url + - jwt_private_key + - jwt_public_key + - jwt_previous_public_key + - jwt_next_public_key + - encryption_key + - previous_encryption_key + - smtp_username + - smtp_password + - captcha_secret + - nats_url + - metrics_token restart: unless-stopped # The app writes nothing to disk (logs go to stdout, templates are read-only), # needs no Linux capability and must not gain privileges. @@ -111,6 +130,13 @@ services: secrets: - source: nats_auth target: /etc/nats/auth.conf + # The exporter shares this network namespace to read the monitoring + # endpoint, which listens on loopback only: its port is published here. + ports: + - "${METRICS_BIND_ADDRESS:-10.0.0.1}:7777:7777" + read_only: true + tmpfs: + - /tmp:size=16m cap_drop: - ALL security_opt: @@ -126,8 +152,9 @@ services: limits: cpus: "${NATS_CPUS:?pass a profile with --env-file}" memory: ${NATS_MEMORY:?pass a profile with --env-file} + pids: 128 healthcheck: - test: ["CMD", "wget", "--spider", "-q", "http://localhost:8222/healthz"] + test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:8222/healthz"] interval: 5s timeout: 5s retries: 10 @@ -138,9 +165,10 @@ services: nats-exporter: image: natsio/prometheus-nats-exporter:0.20.2@sha256:c623b608e148e31e1c1c878673a197f1828e58ce90de4f01d22f1baa84c8fee9 restart: unless-stopped - command: ["-varz", "-jsz=all", "http://nats:8222"] - ports: - - "${METRICS_BIND_ADDRESS:-10.0.0.1}:7777:7777" + command: ["-varz", "-jsz=all", "http://127.0.0.1:8222"] + # In the broker's network namespace: the monitoring endpoint is not on the + # compose network, where any container could read connections and streams. + network_mode: "service:nats" read_only: true cap_drop: - ALL @@ -156,13 +184,39 @@ services: limits: cpus: "0.1" memory: 32M + pids: 32 depends_on: nats: condition: service_healthy - networks: - - auth-api secrets: + # The application's secrets (scripts/write-secrets.sh). + database_url: + file: ${AUTH_API_SECRETS_DIR:-/etc/auth-api/secrets}/database_url + redis_url: + file: ${AUTH_API_SECRETS_DIR:-/etc/auth-api/secrets}/redis_url + jwt_private_key: + file: ${AUTH_API_SECRETS_DIR:-/etc/auth-api/secrets}/jwt_private_key + jwt_public_key: + file: ${AUTH_API_SECRETS_DIR:-/etc/auth-api/secrets}/jwt_public_key + jwt_previous_public_key: + file: ${AUTH_API_SECRETS_DIR:-/etc/auth-api/secrets}/jwt_previous_public_key + jwt_next_public_key: + file: ${AUTH_API_SECRETS_DIR:-/etc/auth-api/secrets}/jwt_next_public_key + encryption_key: + file: ${AUTH_API_SECRETS_DIR:-/etc/auth-api/secrets}/encryption_key + previous_encryption_key: + file: ${AUTH_API_SECRETS_DIR:-/etc/auth-api/secrets}/previous_encryption_key + smtp_username: + file: ${AUTH_API_SECRETS_DIR:-/etc/auth-api/secrets}/smtp_username + smtp_password: + file: ${AUTH_API_SECRETS_DIR:-/etc/auth-api/secrets}/smtp_password + captcha_secret: + file: ${AUTH_API_SECRETS_DIR:-/etc/auth-api/secrets}/captcha_secret + nats_url: + file: ${AUTH_API_SECRETS_DIR:-/etc/auth-api/secrets}/nats_url + metrics_token: + file: ${AUTH_API_SECRETS_DIR:-/etc/auth-api/secrets}/metrics_token nats_auth: # authorization { token: "..." }, written from pass, mode 0600 # (docs/deploy/api/secrets.md). @@ -176,8 +230,9 @@ volumes: # application sees the bridge gateway (172.30.0.1) as its peer - not # 127.0.0.1. TRUSTED_PROXY_CIDRS in config.prod.env must name that gateway, # or X-Forwarded-For is ignored and every client shares one rate-limit bucket. -# Any process on the host can send a forged X-Forwarded-For through those ports: -# keep shell access to the API VPS to the operators. +# Any process on the host could send a forged X-Forwarded-For through those +# ports, or straight to the instances on this subnet: the rule of +# deploy/api/nftables-auth-api.conf lets only nginx and root through both. networks: auth-api: driver: bridge diff --git a/docs/deploy/api/deployment.md b/docs/deploy/api/deployment.md index e2aa068..d4beee2 100644 --- a/docs/deploy/api/deployment.md +++ b/docs/deploy/api/deployment.md @@ -106,37 +106,38 @@ the copy rather than `config.prod.env`. ### 1.5 Run the migrations ```bash -DATABASE_URL=$(pass prod/auth-api/database-url) \ +DATABASE_URL=$(pass prod/auth-api/database-owner-url) \ sqlx migrate run --source /srv/auth-api/releases/auth-api-X.Y.Z/migrations ``` --- -### 1.6 Export secrets and start +### 1.6 Write the secrets and start + +`write-secrets.sh` reads each secret from pass into its file under +`/etc/auth-api/secrets`, without exporting it (see [Secrets](secrets.md)). ```bash cd /srv/auth-api export AUTH_API_VERSION=X.Y.Z -export DATABASE_URL=$(pass prod/auth-api/database-url) -export REDIS_URL=$(pass prod/auth-api/redis-url) -export JWT_PRIVATE_KEY=$(pass prod/auth-api/jwt-private-key) -export JWT_PUBLIC_KEY=$(pass prod/auth-api/jwt-public-key) -export ENCRYPTION_KEY=$(pass prod/auth-api/encryption-key) -export SMTP_USERNAME=$(pass prod/auth-api/smtp-username) -export SMTP_PASSWORD=$(pass prod/auth-api/smtp-password) -export CAPTCHA_SECRET=$(pass prod/auth-api/captcha-secret) -export NATS_URL=$(pass prod/auth-api/nats-url) +./write-secrets.sh # Owned by root: the broker runs without capabilities (see Secrets) sudo install -m 600 -o root -g root /dev/null nats-auth.conf printf 'authorization { token: "%s" }\n' "$(pass prod/auth-api/nats-auth-token)" \ | sudo tee nats-auth.conf > /dev/null docker compose --env-file profile.env -f docker-compose.api.yml up -d --wait -curl -fsS http://127.0.0.1:3001/ready && curl -fsS http://127.0.0.1:3002/ready +sudo curl -fsS http://127.0.0.1:3001/ready && sudo curl -fsS http://127.0.0.1:3002/ready ``` Both instances must answer `/ready` before nginx is pointed at them. +Only nginx and root may connect to the published ports: the application +trusts `X-Forwarded-For` from them, so any other local process could forge a +client address. Install the rule of `deploy/api/nftables-auth-api.conf` (its +header gives the commands and the check); the rolling update runs as root +(`sudo -E ./rolling-update.sh`) for its readiness checks. + --- ### 1.7 Register the client applications diff --git a/docs/deploy/api/nginx.md b/docs/deploy/api/nginx.md index f549ae7..bb83ced 100644 --- a/docs/deploy/api/nginx.md +++ b/docs/deploy/api/nginx.md @@ -86,11 +86,12 @@ Two zones mirror the API's own rate limiting as a first line of defense: | Zone | Limit | Applied to | |------|-------|------------| -| `api_auth` | 40 req/min | Credential-bearing routes: register, login, refresh, email verification, password reset, 2FA completion, device and authorization code token routes, re-authentication and email change | -| `api_general` | 600 req/min | Every other route, logout, probes and the JWKS included | +| `api_auth` | 40 req/min | The requests the API holds to its strict bucket, by method and path (the `$auth_limit_key` map): every `/auth/*` route but logout, every `/oauth/*` route but token, introspection and revocation, and every `/users/me` route taking a password or a one-time code. A test checks the map against the API's routers | +| `api_general` | 600 req/min | Every request, the strict ones included, probes and discovery documents | -The route patterns are anchored on both ends, so `/auth/login-anything` is not -a login route. +Methods other than `GET`, `HEAD`, `POST`, `PUT`, `PATCH`, `DELETE` and +`OPTIONS` answer `405`. Hidden files (`/.env`, `/.git`...) are denied, except +the discovery documents under `/.well-known/`. The zones allow twice `RATE_LIMIT_RPM` and `RATE_LIMIT_AUTH_RPM` of `config.prod.env`: they only absorb floods, and a client over its limit gets the @@ -113,14 +114,23 @@ to the upstream. |----------|--------------:|-----| | Credential routes and everything else | 35 s | Above the API's 30-second request timeout: a sign-in queued behind Argon2 during a storm completes instead of ending in a 504 the client retries | | `/live`, `/ready`, `/health` | 5 s | Probes; not logged | -| `/.well-known/jwks.json` | 10 s | | +| `/.well-known/*` (JWKS, OAuth and OpenID Connect metadata) | 10 s | Discovery documents, `GET` only | ### Logs `/var/log/nginx/auth-api.access.log` has one JSON line per request with its `request_id`, which nginx passes to the API as `X-Request-Id`: the API keeps it -in every log line of that request. The nginx package's logrotate configuration -already covers `/var/log/nginx/*.log`. +in every log line of that request. The log holds client addresses and user +agents: keep it 14 days at most, below the 90 days after which the audit log +keeps only networks. Install the rotation of `deploy/api/logrotate-auth-api` +(its header gives the commands), and leave this file out of the package's +`/etc/logrotate.d/nginx` so it is rotated once: + +```bash +sudo install -m 644 deploy/api/logrotate-auth-api /etc/logrotate.d/auth-api +sudo sed -i 's|^/var/log/nginx/\*.log|/var/log/nginx/access.log /var/log/nginx/error.log|' /etc/logrotate.d/nginx +sudo logrotate --debug /etc/logrotate.d/auth-api +``` ### Trusted proxy diff --git a/docs/deploy/api/secrets.md b/docs/deploy/api/secrets.md index ec41716..b0201d9 100644 --- a/docs/deploy/api/secrets.md +++ b/docs/deploy/api/secrets.md @@ -2,7 +2,17 @@ [Index](../README.md) | Next: [Database Deployment](../database/deployment.md) -All secrets are stored in `pass` on the API VPS and exported as environment variables before deployment. +All secrets are stored in `pass` on the API VPS. Before a deployment +`scripts/write-secrets.sh` reads each of them from pass, never through an +exported variable, and writes it to +`/etc/auth-api/secrets`: a directory only root enters, one file per secret +readable only by the image's user (UID 65532). `docker-compose.api.yml` mounts +them as compose secrets and passes each variable `X` as `X_FILE`, so the values +appear neither in `docker inspect` nor in the environment of the process. The +files stay across reboots, so Docker can restart the instances on its own. + +Any other orchestrator that mounts secrets as files (Docker Swarm, Kubernetes, +systemd credentials) can pass them as `X_FILE` the same way. ## Setup @@ -148,3 +158,15 @@ List all inserted secrets: pass prod/auth-api ``` +--- + +### Metrics Token + +Bearer token of the internal listener (`/metrics` and the detailed `/ready`), +shared with Prometheus (`/etc/prometheus/secrets/auth-api-metrics-token` on +the monitoring host, see the [monitoring guide](../guides/monitoring.md)). + +```bash +pass insert prod/auth-api/metrics-token +# Generate with: openssl rand -hex 32 +``` diff --git a/docs/deploy/database/deployment.md b/docs/deploy/database/deployment.md index 1c316af..50cfe51 100644 --- a/docs/deploy/database/deployment.md +++ b/docs/deploy/database/deployment.md @@ -129,13 +129,24 @@ sudo apt install -y postgresql postgresql-contrib sudo -u postgres psql ``` +Two roles: `auth_api_owner` owns the schema and runs the migrations; +`auth_api`, the role the API connects with, reads and writes data and nothing +else. A compromised API, or an SQL injection, can then neither alter the +schema nor rewrite the audit log or the permission catalog. + ```sql -CREATE USER auth_api WITH PASSWORD 'your-strong-password'; -CREATE DATABASE auth_api OWNER auth_api; -GRANT ALL PRIVILEGES ON DATABASE auth_api TO auth_api; +CREATE ROLE auth_api_owner LOGIN PASSWORD 'owner-strong-password'; +CREATE ROLE auth_api LOGIN PASSWORD 'your-strong-password'; +CREATE DATABASE auth_api OWNER auth_api_owner; +\c auth_api +ALTER SCHEMA public OWNER TO auth_api_owner; \q ``` +Store both URLs: `prod/auth-api/database-owner-url` for migrations and restores +(`postgres://auth_api_owner:...@10.0.0.2/auth_api`), and +`prod/auth-api/database-url` for the API (`postgres://auth_api:...@10.0.0.2/auth_api`). + --- ### 2.2 Configure PostgreSQL @@ -149,12 +160,25 @@ profile M; the comments give the values of the other profiles (see sudo cp deploy/db/postgresql.auth-api.conf /etc/postgresql/17/main/conf.d/auth-api.conf ``` -Edit `/etc/postgresql/17/main/pg_hba.conf` - allow the API VPS via its VPN IP only: +Append `deploy/db/pg_hba.auth-api.conf` to `/etc/postgresql/17/main/pg_hba.conf` +and check nothing above it grants more (no `trust`, no wider network): the API +VPS via its VPN IP only, with a password. + +```bash +sudo tee -a /etc/postgresql/17/main/pg_hba.conf < deploy/db/pg_hba.auth-api.conf > /dev/null +``` ```conf -host auth_api auth_api 10.0.0.1/32 scram-sha-256 +host auth_api auth_api 10.0.0.1/32 scram-sha-256 +host auth_api auth_api_owner 10.0.0.1/32 scram-sha-256 ``` +Traffic between the VPS crosses the WireGuard tunnel, which encrypts it; the +API connects without TLS. Where the tunnel is not the trust boundary (another +network in between, a managed database), turn on `ssl` in PostgreSQL, write +`hostssl` instead of `host` above, and add `sslmode=verify-full` with the CA +(`sslrootcert=`) to both URLs. + Restart PostgreSQL, then give the role its session limits (statements and lock waits stop before the API's 30-second request timeout; a connection idle inside a transaction is closed): @@ -193,12 +217,26 @@ Migrations ship in every release bundle (see [Deploying a New Release](../guides **On the API VPS**, with `sqlx-cli` installed (see [API Deployment](../api/deployment.md#12-install-docker-and-sqlx-cli)): ```bash -DATABASE_URL="$(pass prod/auth-api/database-url)?options=-c%20statement_timeout%3D0" \ +DATABASE_URL="$(pass prod/auth-api/database-owner-url)" \ sqlx migrate run --source /srv/auth-api/releases/auth-api-X.Y.Z/migrations ``` -The `options` parameter lifts the role's 25-second statement timeout for the -migration session only: a migration on a large table may run longer. +Migrations run as `auth_api_owner`, which carries no statement timeout: a +migration on a large table may run longer than the API's 25 seconds. + +After the **first** migration run, give the API role its privileges, **on the +DB VPS**: + +```bash +sudo -u postgres psql -d auth_api -f deploy/db/auth-api-grants.sql +``` + +Later migrations need nothing more: the default privileges the script sets +cover the tables they create. The API role cannot create, alter, drop or +truncate a table, disable a trigger, update or delete audit rows, change the +permission catalog or the migration history; the few maintenance functions that +need more (audit partitions, address coarsening, account erasure, purge of +unverified accounts) run with the owner's privileges. --- @@ -253,8 +291,13 @@ FROM pg_stat_database WHERE datname = 'auth_api'; ```bash sudo apt update sudo apt install -y redis-server +redis-server --version # 7.0 or later ``` +Redis 7 or later is required: the ACL below lets the API run Lua scripts +(`EVAL`, for the attempt budgets), and only from Redis 7 on do the commands a +script calls stay subject to the user's ACL. Debian 12 and later ship it. + --- ### 3.1 Configure Redis @@ -275,7 +318,7 @@ every command but the administrative and dangerous ones (`FLUSHALL`, `CONFIG`, ```bash REDIS_PASSWORD_SHA=$(pass prod/auth-api/redis-password | tr -d '\n' | sha256sum | cut -d' ' -f1) -printf 'user default off\nuser auth_api on #%s ~* &* +@all -@dangerous -@admin\n' "$REDIS_PASSWORD_SHA" \ +grep -v '^#' deploy/db/users.acl.template | sed "s/REDIS_PASSWORD_SHA256/$REDIS_PASSWORD_SHA/" \ | sudo tee /etc/redis/users.acl > /dev/null sudo chown redis:redis /etc/redis/users.acl && sudo chmod 600 /etc/redis/users.acl sudo systemctl restart redis-server @@ -284,6 +327,14 @@ sudo systemctl restart redis-server The API connects as that user: store `redis://auth_api:@10.0.0.2:6379` as `prod/auth-api/redis-url`. +PostgreSQL and Redis speak without TLS: WireGuard is the boundary that +encrypts and authenticates their traffic. Check that neither listens anywhere +else - only the VPN address may appear: + +```bash +sudo ss -ltnp | grep -E ':(5432|6379)\b' # 10.0.0.2 only, never 0.0.0.0 or a public address +``` + --- ### 3.2 Open the firewall @@ -439,17 +490,21 @@ Backups run nightly at 2:00 AM and are retained for 7 days (`RETAIN_DAYS`). ### 4.6 Restore a backup -Always through `restore-db.sh`, connected as `auth_api`, into a **fresh** -database: a bare `| psql` does not stop at the first error. The script restores -in a single transaction, so a failure leaves the target untouched. +Always through `restore-db.sh`, connected as the owner role `auth_api_owner` +(or `auth_api` in a single-role deployment), into a **fresh** database: a bare +`| psql` does not stop at the first error. The script restores in a single +transaction, so a failure leaves the target untouched. The dump carries the +privileges of the API role: nothing is left to grant after a restore. ```bash -sudo -u postgres psql -c "CREATE DATABASE auth_api_restore OWNER auth_api" -scripts/restore-db.sh -i backup.key -f auth_api_YYYYMMDD_HHMMSS.sql.gz.age \ - -d "postgres://auth_api:@10.0.0.2/auth_api_restore" +sudo -u postgres psql -c "CREATE DATABASE auth_api_restore OWNER auth_api_owner" +RESTORE_DATABASE_URL="$(pass prod/auth-api/database-owner-url | sed 's|/auth_api$|/auth_api_restore|')" \ + scripts/restore-db.sh -i backup.key -f auth_api_YYYYMMDD_HHMMSS.sql.gz.age ``` -Check the restored data, then point `DATABASE_URL` at it (or rename the +The URL never appears on a command line (`ps`, shell history): the script +reads it from `RESTORE_DATABASE_URL` or from a file (`-D`). Check the restored +data, then point `DATABASE_URL` at it (or rename the databases while the API is stopped). `--force` restores over an existing database after emptying it. @@ -471,7 +526,7 @@ so any moment of about the last two weeks can be restored. ```bash sudo apt install -y pgbackrest sudo install -d -o postgres -g postgres -m 750 /var/lib/pgbackrest /var/spool/pgbackrest /var/log/pgbackrest -sudo install -o postgres -g postgres -m 640 deploy/db/pgbackrest.conf /etc/pgbackrest/pgbackrest.conf +sudo install -o postgres -g postgres -m 600 deploy/db/pgbackrest.conf /etc/pgbackrest/pgbackrest.conf sudo sed -i "s|^repo1-cipher-pass=.*|repo1-cipher-pass=$(pass prod/auth-api/pgbackrest-cipher-pass)|" \ /etc/pgbackrest/pgbackrest.conf sudo cp deploy/db/postgresql.pitr.conf /etc/postgresql/17/main/conf.d/auth-api-pitr.conf diff --git a/docs/deploy/guides/monitoring.md b/docs/deploy/guides/monitoring.md index d411744..3d46277 100644 --- a/docs/deploy/guides/monitoring.md +++ b/docs/deploy/guides/monitoring.md @@ -17,7 +17,7 @@ Monitoring host (10.0.0.3) -- WireGuard -- API VPS (10.0.0.1): API instances, NA | Target | Address | Exporter | |--------|---------|----------| -| API instances and their containers | `10.0.0.1:9465`, `10.0.0.1:9466` | the API's metrics listener, which also publishes its container's memory, memory limit, CPU throttling and start time, read from its own cgroup | +| API instances and their containers | `10.0.0.1:9465`, `10.0.0.1:9466` | the API's internal listener (`/metrics`, and `/ready` with the state of each dependency; `Authorization: Bearer `), which also publishes its container's memory, memory limit, CPU throttling and start time, read from its own cgroup | | NATS | `10.0.0.1:7777` | `prometheus-nats-exporter`, in `docker-compose.api.yml` | | Hosts | `10.0.0.1:9100`, `10.0.0.2:9100` | node_exporter (textfile collector on the DB VPS: backup metrics) | | PostgreSQL | `10.0.0.2:9187` | postgres_exporter | @@ -140,6 +140,15 @@ cp releases/auth-api-X.Y.Z/docs/deploy/guides/prometheus-alerts.yml rules/auth-a sed -i 's/api.example.com/your-actual-domain.com/' prometheus.yml ``` +The API's internal listener requires its `METRICS_TOKEN`: write it where +Prometheus reads it (Prometheus runs as `nobody`, UID 65534): + +```bash +mkdir -p secrets +pass prod/auth-api/metrics-token | tr -d '\n' > secrets/auth-api-metrics-token +sudo chown 65534 secrets/auth-api-metrics-token && sudo chmod 400 secrets/auth-api-metrics-token +``` + Edit `alertmanager.yml` (SMTP relay, addresses, webhooks) and store the SMTP password in `alertmanager/smtp_password`, then start: diff --git a/docs/deploy/guides/operations.md b/docs/deploy/guides/operations.md index 5f20814..86e0c86 100644 --- a/docs/deploy/guides/operations.md +++ b/docs/deploy/guides/operations.md @@ -31,7 +31,6 @@ openssl ec -in jwt-private-new.pem -pubout -out jwt-public-new.pem ```bash pass insert -m prod/auth-api/jwt-next-private-key < jwt-private-new.pem pass insert -m prod/auth-api/jwt-next-public-key < jwt-public-new.pem - export JWT_NEXT_PUBLIC_KEY=$(pass show prod/auth-api/jwt-next-public-key) ``` Redeploy ([Deploying a New Release](update.md#4-start-the-new-version)). @@ -45,7 +44,6 @@ openssl ec -in jwt-private-new.pem -pubout -out jwt-public-new.pem pass show prod/auth-api/jwt-next-private-key | pass insert -m -f prod/auth-api/jwt-private-key pass show prod/auth-api/jwt-next-public-key | pass insert -m -f prod/auth-api/jwt-public-key pass rm prod/auth-api/jwt-next-private-key prod/auth-api/jwt-next-public-key - unset JWT_NEXT_PUBLIC_KEY ``` Redeploy. New tokens carry the new `kid`; tokens signed with the old key @@ -63,9 +61,10 @@ Refresh tokens are opaque (not JWT) and are unaffected by this rotation. ## 2. TOTP Encryption Key Rotation (AES-256-GCM) -TOTP secrets are encrypted at rest. Each ciphertext names its key -(`v1:{key id}:...`) and the service reads with `ENCRYPTION_KEY` and, when set, -`PREVIOUS_ENCRYPTION_KEY`. A rotation needs no downtime and can be interrupted +TOTP secrets and webhook signing secrets are encrypted at rest. Each +ciphertext names its key and is bound to its row (`v2:{key id}:...`); the +service reads with `ENCRYPTION_KEY` and, when set, `PREVIOUS_ENCRYPTION_KEY`, +and refuses to start while a secret is under neither. A rotation needs no downtime and can be interrupted and resumed. **On the API VPS:** @@ -97,7 +96,9 @@ and resumed. `pass rm prod/auth-api/previous-encryption-key`, then the update guide's exports again (the previous key is now unset). -Keep the old key in `pass` history until the run reported no failures. +Keep the old key in `pass` history until the run reported no failures. An +instance refuses to start while a secret names a key it does not hold: if the +previous key was removed too early, put it back and finish step 3. ## 3. Backup and Restore @@ -114,9 +115,8 @@ on the DB VPS die with it - configure this for production. **Restore** (DB VPS, or any machine with `psql` access): ```bash -scripts/restore-db.sh -i /path/to/backup.key \ - -f auth_api_YYYYMMDD_HHMMSS.sql.gz.age \ - -d postgres://auth_api:...@10.0.0.2:5432/auth_api +RESTORE_DATABASE_URL="$(pass prod/auth-api/database-owner-url)" \ + scripts/restore-db.sh -i /path/to/backup.key -f auth_api_YYYYMMDD_HHMMSS.sql.gz.age ``` The script refuses to restore into a non-empty database unless `--force` is @@ -151,8 +151,9 @@ deliberate. | Pre-auth (2FA challenge) tokens | Stored in Redis: in-flight 2FA logins fail; users retry after recovery | | CAPTCHA / lockout counters | Various counters degrade fail-open; account lockout (DB-based) still works | -**Response:** restart/restore Redis, then verify `curl -f 127.0.0.1:3001/ready` -and `curl -f 127.0.0.1:3002/ready` on the API VPS and watch `auth_logins_total` on the metrics endpoint resume. No application +**Response:** restart/restore Redis, then verify +`curl -f -H "Authorization: Bearer $(pass prod/auth-api/metrics-token)" 10.0.0.1:9465/ready` +(and `9466`) on the API VPS (each dependency listed) and watch `auth_logins_total` on the metrics endpoint resume. No application restart is needed - pools reconnect automatically. **Redis full.** Redis runs with `maxmemory-policy noeviction`: evicting a @@ -203,12 +204,13 @@ UPDATE users SET status = 'suspended' WHERE email = 'user@example.com'; Prometheus metrics are exposed on an internal listener (`10.0.0.1:9465/metrics` and `10.0.0.1:9466/metrics` on the API VPS - WireGuard -only, never behind nginx). Key series: +only, never behind nginx), with `Authorization: Bearer `. Key series: - `auth_logins_total{outcome=...}` - success / invalid_credentials / locked / two_factor_required - `auth_lockouts_total`, `auth_session_replays_total`, `auth_2fa_failures_total{method=...}` +- `auth_lockout_failures_total{step=count|lock|audit}` - a lockout that could not be applied, logged as `lockout could not be applied` (`AuthApiLockoutFailing`) - `argon2_queue_available_permits` - **0 while login latency climbs = login storm**; capacity is `ARGON2_MAX_CONCURRENCY` (defaults to CPU cores) -- `axum_http_requests_duration_seconds` - per-route latency histograms +- `axum_http_requests_duration_seconds` - per-route latency histograms; requests no route matched share the endpoint `` - `auth_db_pool_connections{state=max|open|idle|in_use}`, `auth_redis_pool_connections{state=max|open|available}`, `auth_redis_pool_waiting` - pool saturation, refreshed every 10 s; `in_use` at `max` with requests timing out = pool too small or a slow query - `auth_redis_errors_total{operation=budget|rate_limit|token_state}` - Redis failures, each refused with a 503 (fail closed) - `auth_outbox_pending`, `auth_outbox_oldest_pending_age_seconds` - domain events recorded but not yet stored by JetStream; `auth_events_published_total`, `auth_events_publish_failures_total{reason=error|timeout|stream}` - relay publications and failed attempts (each retried) @@ -336,7 +338,10 @@ accounts. | Per CPU | 13.7 | 12.5 | 12.0 | 11.2 | | Peak memory with 64 sign-ins at once | 78 / 384 MiB | 141 / 512 MiB | 209 / 512 MiB | 274 / 512 MiB | -No error and no out-of-memory kill, including with 64 sign-ins at once. With the +No error and no out-of-memory kill, including with 64 sign-ins at once. The +limits have since been raised (M: 640 MiB, L and XL: 768 MiB) so that every +concurrent hash can check a stored hash at twice the configured cost, the +highest the service accepts, beside 256 MiB for the rest of the process. With the mixed traffic of profile M at 1 million accounts (291 requests per second): - **Redis** used 9 MiB for 50 000 keys, almost all of them rate-limit buckets, @@ -430,7 +435,7 @@ them, so it never restarts the instances because of them. | Path | Without NATS | |------|--------------| | Every event, `user.deleted` included | Recorded with its change in `event_outbox`; the relay publishes it once the broker is back, in order. The request succeeds; the backlog shows in `auth_outbox_pending` (`AuthApiEventsStalled` past 5 minutes) | -| `/ready` | 503 with `"nats": "down"` (`NatsDown` alerts) | +| `/ready` | 503; the internal listener's `/ready` (`10.0.0.1:9465/ready`) shows `"nats": "down"` (`NatsDown` alerts) | | Instance start | Starts and connects in the background; only a wrong token stops the start | **Response:** restart the broker (`docker compose -f docker-compose.api.yml diff --git a/docs/deploy/guides/prometheus-alerts.yml b/docs/deploy/guides/prometheus-alerts.yml index 0ad3381..2630542 100644 --- a/docs/deploy/guides/prometheus-alerts.yml +++ b/docs/deploy/guides/prometheus-alerts.yml @@ -77,6 +77,23 @@ groups: Sustained slow responses; check DB pool saturation, Redis and the WireGuard link to the DB VPS. + - alert: AuthApiPwnedPasswordsUnavailable + # The breached-password check fails open (PWNED_PASSWORDS_FAIL_OPEN): + # while the range API is unreachable, breached passwords are accepted. + expr: >- + sum(rate(auth_pwned_password_checks_total{outcome="unavailable"}[15m])) + / clamp_min(sum(rate(auth_pwned_password_checks_total[15m])), 0.001) > 0.5 + for: 15m + labels: + severity: warning + annotations: + summary: "the breached-password check has been unavailable for 15 minutes" + description: >- + Most checks against PWNED_PASSWORDS_URL failed over 15 minutes, so + passwords found in breaches are accepted at registration, change + and reset. Check outbound HTTPS from the API VPS and the service's + status. + - alert: AuthBackupMissing # node_exporter's textfile collector on the DB VPS reads the metrics that # backup-db.sh writes after a complete backup only, offsite copy diff --git a/docs/deploy/guides/update.md b/docs/deploy/guides/update.md index 640ef6c..5a101e3 100644 --- a/docs/deploy/guides/update.md +++ b/docs/deploy/guides/update.md @@ -40,7 +40,7 @@ R=releases/auth-api-X.Y.Z diff config.prod.env $R/config.prod.env diff profile.env $R/deploy/profiles/m.env # the profile this server uses for f in docker-compose.api.yml nats.conf; do diff "$f" "$R/$f"; done -cp $R/docker-compose.api.yml $R/nats.conf $R/scripts/rolling-update.sh . +cp $R/docker-compose.api.yml $R/nats.conf $R/scripts/rolling-update.sh $R/scripts/write-secrets.sh . # Profile L only: cp $R/docker-compose.api.l.yml . ``` @@ -52,7 +52,7 @@ the update; the instances reconnect, and events published meanwhile are dropped ## 3. Run the migrations ```bash -DATABASE_URL=$(pass prod/auth-api/database-url) \ +DATABASE_URL=$(pass prod/auth-api/database-owner-url) \ sqlx migrate run --source /srv/auth-api/releases/auth-api-X.Y.Z/migrations ``` @@ -61,24 +61,21 @@ DATABASE_URL=$(pass prod/auth-api/database-url) \ ```bash cd /srv/auth-api export AUTH_API_VERSION=X.Y.Z -export DATABASE_URL=$(pass prod/auth-api/database-url) -export REDIS_URL=$(pass prod/auth-api/redis-url) -export JWT_PRIVATE_KEY=$(pass prod/auth-api/jwt-private-key) -export JWT_PUBLIC_KEY=$(pass prod/auth-api/jwt-public-key) -export ENCRYPTION_KEY=$(pass prod/auth-api/encryption-key) -# Only while a key rotation is in progress (see the operations runbook); unset -# otherwise. An empty value is treated as unset. -export JWT_PREVIOUS_PUBLIC_KEY=$(pass prod/auth-api/jwt-previous-public-key 2>/dev/null) -export JWT_NEXT_PUBLIC_KEY=$(pass prod/auth-api/jwt-next-public-key 2>/dev/null) -export PREVIOUS_ENCRYPTION_KEY=$(pass prod/auth-api/previous-encryption-key 2>/dev/null) -export SMTP_USERNAME=$(pass prod/auth-api/smtp-username) -export SMTP_PASSWORD=$(pass prod/auth-api/smtp-password) -export CAPTCHA_SECRET=$(pass prod/auth-api/captcha-secret) -export NATS_URL=$(pass prod/auth-api/nats-url) - -./rolling-update.sh +./write-secrets.sh +sudo -E ./rolling-update.sh ``` +`write-secrets.sh` reads each secret from pass (`prod/auth-api/`, +`PASS_PREFIX` to change it) straight into its file, never through an exported +variable; the rotation secrets (`jwt-previous-public-key`, +`jwt-next-public-key`, `previous-encryption-key`) are written empty when pass +holds none. It writes the values to `/etc/auth-api/secrets` (a directory +only root enters, files only the container's user reads), which compose mounts +as secrets: the instances read each variable `X` from the file named by +`X_FILE`, so the values appear neither in `docker inspect` nor in the process +environment. The files survive a reboot, so Docker restarts the instances; run +the script again whenever a secret changes. + `rolling-update.sh` recreates the instances one at a time and moves on only once the new one is healthy and answers `/ready`. The instance being replaced finishes its requests (up to 32 seconds) while nginx sends new ones to the diff --git a/docs/dev/api/openapi.yaml b/docs/dev/api/openapi.yaml index a460165..27971db 100644 --- a/docs/dev/api/openapi.yaml +++ b/docs/dev/api/openapi.yaml @@ -4,7 +4,7 @@ info: description: 'Authentication API: accounts, sessions, second factors and client applications. Issues ES256 access tokens and rotating refresh tokens, and publishes the JWKS resource servers verify them with.' license: name: MIT - version: 2.0.1 + version: 2.1.0 servers: - url: http://localhost:3000 description: Local development @@ -133,7 +133,7 @@ paths: schema: $ref: '#/components/schemas/ErrorBody' '403': - description: Missing `audit:read`, or no second factor enrolled + description: Missing `audit:read`, or no second factor proven by the session content: application/json: schema: @@ -179,7 +179,7 @@ paths: schema: $ref: '#/components/schemas/ErrorBody' '403': - description: Missing `clients:manage`, or no second factor enrolled + description: Missing `clients:manage`, or no second factor proven by the session content: application/json: schema: @@ -248,7 +248,7 @@ paths: schema: $ref: '#/components/schemas/ErrorBody' '409': - description: '`primary_client_exists`' + description: '`primary_client_managed_by_command_line`: the primary client changes only through `auth-api --register-client`' content: application/json: schema: @@ -312,7 +312,7 @@ paths: schema: $ref: '#/components/schemas/ErrorBody' '403': - description: Missing `clients:manage`, or no second factor enrolled + description: Missing `clients:manage`, or no second factor proven by the session content: application/json: schema: @@ -323,6 +323,12 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorBody' + '409': + description: '`primary_client_managed_by_command_line`' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' '422': description: Invalid input content: @@ -386,6 +392,12 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorBody' + '409': + description: '`primary_client_managed_by_command_line`' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' '422': description: Invalid input content: @@ -433,7 +445,7 @@ paths: schema: $ref: '#/components/schemas/ErrorBody' '403': - description: Missing `clients:manage`, or no second factor enrolled + description: Missing `clients:manage`, or no second factor proven by the session, or re-authentication required content: application/json: schema: @@ -444,6 +456,12 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorBody' + '409': + description: '`primary_client_managed_by_command_line`' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' '422': description: Invalid input content: @@ -485,7 +503,7 @@ paths: schema: $ref: '#/components/schemas/ErrorBody' '403': - description: Missing `roles:manage`, or no second factor enrolled + description: Missing `roles:manage`, or no second factor proven by the session content: application/json: schema: @@ -525,7 +543,7 @@ paths: schema: $ref: '#/components/schemas/ErrorBody' '403': - description: Missing `roles:manage`, or no second factor enrolled + description: Missing `roles:manage`, or no second factor proven by the session content: application/json: schema: @@ -645,7 +663,7 @@ paths: schema: $ref: '#/components/schemas/ErrorBody' '403': - description: Missing `roles:manage`, or no second factor enrolled + description: Missing `roles:manage`, or no second factor proven by the session content: application/json: schema: @@ -720,7 +738,7 @@ paths: schema: $ref: '#/components/schemas/ErrorBody' '403': - description: Missing `roles:manage`, no second factor, or re-authentication required + description: Missing `roles:manage`, no second factor, re-authentication required, or adding to a role the administrator holds a permission they lack content: application/json: schema: @@ -732,7 +750,7 @@ paths: schema: $ref: '#/components/schemas/ErrorBody' '409': - description: '`last_administrator`: nobody would keep `roles:manage`' + description: '`last_administrator`: nobody would keep `roles:manage`; `default_role_administration`: the default role never grants an administrative permission' content: application/json: schema: @@ -820,7 +838,7 @@ paths: schema: $ref: '#/components/schemas/ErrorBody' '403': - description: Missing `users:read`, or no second factor enrolled + description: Missing `users:read`, or no second factor proven by the session content: application/json: schema: @@ -878,7 +896,7 @@ paths: schema: $ref: '#/components/schemas/ErrorBody' '403': - description: Missing `users:read`, or no second factor enrolled + description: Missing `users:read`, or no second factor proven by the session content: application/json: schema: @@ -921,13 +939,6 @@ paths: schema: type: string format: uuid - requestBody: - content: - application/json: - schema: - oneOf: - - type: 'null' - - $ref: '#/components/schemas/CurrentPasswordRequest' responses: '204': description: Account deleted and `user.deleted` announced @@ -938,7 +949,7 @@ paths: schema: $ref: '#/components/schemas/ErrorBody' '401': - description: Missing, invalid or revoked access token, or wrong password + description: Missing, invalid or revoked access token content: application/json: schema: @@ -955,14 +966,74 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorBody' - '413': - description: Body larger than 64 KB + '409': + description: '`last_administrator`: the account is the last active one able to manage roles' content: application/json: schema: $ref: '#/components/schemas/ErrorBody' - '415': - description: Body is not JSON + '422': + description: Invalid input + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' + '429': + description: Rate limited; see Retry-After + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' + '503': + description: A dependency is unavailable or the request timed out + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' + security: + - bearer: [] + /admin/users/{id}/access-factors: + delete: + tags: + - admin + operationId: remove_access_factors + parameters: + - name: id + in: path + description: Account id + required: true + schema: + type: string + format: uuid + responses: + '204': + description: Second factors, recovery codes, passkeys, external identities and personal access tokens removed; the owner is mailed + '400': + description: Malformed request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' + '401': + description: Missing, invalid or revoked access token + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' + '403': + description: Missing `users:manage`, no second factor proven by the session, the administrator's own account, or re-authentication required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' + '404': + description: No such account + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' + '409': + description: '`administrator_needs_second_factor`: the account holds administrative permissions' content: application/json: schema: @@ -1000,6 +1071,13 @@ paths: schema: type: string format: uuid + requestBody: + content: + application/json: + schema: + oneOf: + - type: 'null' + - $ref: '#/components/schemas/ForcePasswordResetRequest' responses: '204': description: Signed out everywhere and a reset link mailed to the owner @@ -1016,7 +1094,7 @@ paths: schema: $ref: '#/components/schemas/ErrorBody' '403': - description: Missing `users:manage`, no second factor enrolled, or the administrator's own account + description: Missing `users:manage`, no second factor proven by the session, or the administrator's own account, or re-authentication required content: application/json: schema: @@ -1027,6 +1105,24 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorBody' + '409': + description: '`administrator_needs_second_factor`: `revoke_access_factors` on an account holding administrative permissions' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' + '413': + description: Body larger than 64 KB + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' + '415': + description: Body is not JSON + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' '422': description: Invalid input content: @@ -1076,7 +1172,7 @@ paths: schema: $ref: '#/components/schemas/ErrorBody' '403': - description: Missing `users:manage`, or no second factor enrolled + description: Missing `users:manage`, or no second factor proven by the session content: application/json: schema: @@ -1142,7 +1238,7 @@ paths: schema: $ref: '#/components/schemas/ErrorBody' '403': - description: Missing `roles:manage`, no second factor, or re-authentication required + description: Missing `roles:manage`, no second factor, re-authentication required, or the administrator's own account content: application/json: schema: @@ -1153,6 +1249,12 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorBody' + '409': + description: '`administrator_without_second_factor`: the role grants administration and the account is not active or has no second factor' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' '413': description: Body larger than 64 KB content: @@ -1220,7 +1322,7 @@ paths: schema: $ref: '#/components/schemas/ErrorBody' '403': - description: Missing `roles:manage`, or no second factor enrolled + description: Missing `roles:manage`, or no second factor proven by the session content: application/json: schema: @@ -1290,7 +1392,7 @@ paths: schema: $ref: '#/components/schemas/ErrorBody' '403': - description: Missing `users:manage`, no second factor enrolled, or the administrator's own account + description: Missing `users:manage`, no second factor proven by the session, or the administrator's own account content: application/json: schema: @@ -1350,7 +1452,7 @@ paths: schema: $ref: '#/components/schemas/ErrorBody' '403': - description: Missing `users:manage`, no second factor enrolled, or the administrator's own account + description: Missing `users:manage`, no second factor proven by the session, or the administrator's own account, or re-authentication required content: application/json: schema: @@ -1361,6 +1463,12 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorBody' + '409': + description: '`last_administrator`: the account is the last active one able to manage roles' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' '422': description: The account was never verified content: @@ -1410,7 +1518,7 @@ paths: schema: $ref: '#/components/schemas/ErrorBody' '403': - description: Missing `users:manage`, or no second factor enrolled + description: Missing `users:manage`, or no second factor proven by the session content: application/json: schema: @@ -1462,7 +1570,7 @@ paths: schema: $ref: '#/components/schemas/ErrorBody' '403': - description: Missing `webhooks:manage`, or no second factor enrolled + description: Missing `webhooks:manage`, or no second factor proven by the session content: application/json: schema: @@ -1511,7 +1619,7 @@ paths: schema: $ref: '#/components/schemas/ErrorBody' '403': - description: Missing `webhooks:manage`, or no second factor enrolled + description: Missing `webhooks:manage`, or no second factor proven by the session, or re-authentication required content: application/json: schema: @@ -1569,11 +1677,11 @@ paths: required: true responses: '200': - description: Endpoint updated; the secret is unchanged + description: Endpoint updated; a new secret is returned when it points at another host, otherwise the secret is unchanged content: application/json: schema: - $ref: '#/components/schemas/WebhookResponse' + $ref: '#/components/schemas/UpdatedWebhookResponse' '400': description: Malformed request content: @@ -1587,7 +1695,7 @@ paths: schema: $ref: '#/components/schemas/ErrorBody' '403': - description: Missing `webhooks:manage`, or no second factor enrolled + description: Missing `webhooks:manage`, or no second factor proven by the session, or re-authentication required content: application/json: schema: @@ -1658,7 +1766,7 @@ paths: schema: $ref: '#/components/schemas/ErrorBody' '403': - description: Missing `webhooks:manage`, or no second factor enrolled + description: Missing `webhooks:manage`, or no second factor proven by the session content: application/json: schema: @@ -1724,7 +1832,13 @@ paths: schema: $ref: '#/components/schemas/ErrorBody' '403': - description: Missing `webhooks:manage`, or no second factor enrolled + description: Missing `webhooks:manage`, or no second factor proven by the session + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' + '404': + description: No such webhook content: application/json: schema: @@ -1785,7 +1899,7 @@ paths: schema: $ref: '#/components/schemas/ErrorBody' '403': - description: Missing `webhooks:manage`, or no second factor enrolled + description: Missing `webhooks:manage`, or no second factor proven by the session content: application/json: schema: @@ -1849,7 +1963,7 @@ paths: schema: $ref: '#/components/schemas/ErrorBody' '403': - description: Missing `webhooks:manage`, or no second factor enrolled + description: Missing `webhooks:manage`, or no second factor proven by the session, or re-authentication required content: application/json: schema: @@ -2153,7 +2267,7 @@ paths: schema: $ref: '#/components/schemas/ErrorBody' '403': - description: Account locked, suspended or not verified + description: Account suspended or inactive; a locked password and an account whose address is not verified answer 401 like a wrong password content: application/json: schema: @@ -2303,7 +2417,7 @@ paths: schema: $ref: '#/components/schemas/ErrorBody' '403': - description: Account locked, suspended or inactive + description: Account suspended or inactive content: application/json: schema: @@ -2713,7 +2827,7 @@ paths: schema: $ref: '#/components/schemas/ErrorBody' '403': - description: Account suspended or locked since the challenge + description: Account suspended or inactive since the challenge content: application/json: schema: @@ -2779,7 +2893,7 @@ paths: schema: $ref: '#/components/schemas/ErrorBody' '403': - description: Account suspended or locked since the challenge + description: Account suspended or inactive since the challenge content: application/json: schema: @@ -2901,7 +3015,7 @@ paths: schema: $ref: '#/components/schemas/ErrorBody' '403': - description: Account suspended or locked since the challenge + description: Account suspended or inactive since the challenge content: application/json: schema: @@ -2957,7 +3071,7 @@ paths: schema: $ref: '#/components/schemas/ErrorBody' '401': - description: Invalid or expired token + description: Invalid or expired token (`token_invalid`, `token_expired`), or not the password of the registration that sent this link (`invalid_credentials`) content: application/json: schema: @@ -3124,6 +3238,12 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorBody' + '403': + description: '`first_party_session_required`: a token delegated to a client or issued for a personal access token' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' '404': description: Unknown, decided or expired request content: @@ -3263,6 +3383,12 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorBody' + '403': + description: '`first_party_session_required`: a token delegated to a client or issued for a personal access token' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' '404': description: Unknown, decided or expired request content: @@ -3397,7 +3523,13 @@ paths: schema: $ref: '#/components/schemas/ErrorBody' '401': - description: Missing, invalid or revoked access token + description: Missing, invalid or revoked access token, or wrong password + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' + '403': + description: Re-authentication required to approve a client other than the instance's own application, or a delegated token content: application/json: schema: @@ -3477,6 +3609,12 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorBody' + '403': + description: '`first_party_session_required`: a token delegated to a client or issued for a personal access token' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' '404': description: Unknown or expired code content: @@ -3805,7 +3943,7 @@ paths: schema: $ref: '#/components/schemas/ErrorBody' '503': - description: A dependency did not answer + description: A dependency did not answer; the internal listener says which content: application/json: schema: @@ -3828,6 +3966,12 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorBody' + '403': + description: '`first_party_session_required`: a token delegated to a client or issued for a personal access token' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' '429': description: Rate limited; see Retry-After content: @@ -3874,6 +4018,12 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorBody' + '409': + description: '`last_administrator`: the account is the last active one able to manage roles' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' '413': description: Body larger than 64 KB content: @@ -3945,6 +4095,12 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorBody' + '403': + description: '`first_party_session_required`: a token delegated to a client or issued for a personal access token' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' '422': description: Invalid cursor content: @@ -3991,6 +4147,12 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorBody' + '403': + description: '`first_party_session_required`: a token delegated to a client or issued for a personal access token' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' '409': description: Address taken content: @@ -4111,7 +4273,7 @@ paths: required: true responses: '204': - description: Code sent to the new address + description: 'Code sent to the new address, unless it belongs to another account: the answer is the same either way' '400': description: Malformed request content: @@ -4124,8 +4286,8 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorBody' - '409': - description: Address taken + '403': + description: '`first_party_session_required`: a token delegated to a client or issued for a personal access token' content: application/json: schema: @@ -4188,6 +4350,12 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorBody' + '403': + description: '`first_party_session_required`: a token delegated to a client or issued for a personal access token' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' '413': description: Body larger than 64 KB content: @@ -4278,6 +4446,12 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorBody' + '403': + description: '`first_party_session_required`: a token delegated to a client or issued for a personal access token' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' '429': description: Rate limited; see Retry-After content: @@ -4322,6 +4496,12 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorBody' + '403': + description: '`first_party_session_required`: a token delegated to a client or issued for a personal access token' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' '409': description: '`external_identity_already_linked`' content: @@ -4528,6 +4708,12 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorBody' + '403': + description: '`first_party_session_required`: a token delegated to a client or issued for a personal access token' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' '413': description: Body larger than 64 KB content: @@ -4580,6 +4766,12 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorBody' + '403': + description: '`first_party_session_required`: a token delegated to a client or issued for a personal access token' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' '429': description: Rate limited; see Retry-After content: @@ -4623,6 +4815,12 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorBody' + '403': + description: '`first_party_session_required`: a token delegated to a client or issued for a personal access token' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' '409': description: '`passkey_already_registered`' content: @@ -4752,6 +4950,12 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorBody' + '409': + description: '`administrator_needs_second_factor`: the account holds administrative permissions and this is its last second factor' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' '413': description: Body larger than 64 KB content: @@ -4932,6 +5136,12 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorBody' + '403': + description: '`first_party_session_required`: a token delegated to a client or issued for a personal access token' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' '429': description: Rate limited; see Retry-After content: @@ -5109,6 +5319,12 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorBody' + '403': + description: '`first_party_session_required`: a token delegated to a client or issued for a personal access token' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' '429': description: Rate limited; see Retry-After content: @@ -5224,6 +5440,12 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorBody' + '403': + description: '`first_party_session_required`: a token delegated to a client or issued for a personal access token' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' '404': description: No such token for this account content: @@ -5273,6 +5495,12 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorBody' + '403': + description: '`first_party_session_required`: a token delegated to a client or issued for a personal access token' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' '429': description: Rate limited; see Retry-After content: @@ -5301,6 +5529,18 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorBody' + '403': + description: '`first_party_session_required`: a token delegated to a client or issued for a personal access token' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' + '404': + description: No e-mail method waiting for confirmation + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' '429': description: Rate limited; see Retry-After content: @@ -5437,6 +5677,12 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorBody' + '409': + description: '`administrator_needs_second_factor`: the account holds administrative permissions and this is its last second factor' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' '413': description: Body larger than 64 KB content: @@ -5507,6 +5753,12 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorBody' + '403': + description: '`first_party_session_required`: a token delegated to a client or issued for a personal access token' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' '404': description: No such method content: @@ -5613,64 +5865,6 @@ paths: $ref: '#/components/schemas/ErrorBody' security: - bearer: [] - /users/me/two-factor/recovery-codes/use: - post: - tags: - - two-factor - operationId: use_recovery_code - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/UseRecoveryCodeRequest' - required: true - responses: - '204': - description: Code consumed - '400': - description: Malformed request - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorBody' - '401': - description: Missing, invalid or revoked access token - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorBody' - '413': - description: Body larger than 64 KB - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorBody' - '415': - description: Body is not JSON - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorBody' - '422': - description: Invalid input - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorBody' - '429': - description: Rate limited; see Retry-After - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorBody' - '503': - description: A dependency is unavailable or the request timed out - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorBody' - security: - - bearer: [] /users/me/two-factor/totp/setup: post: tags: @@ -5793,6 +5987,12 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorBody' + '409': + description: '`administrator_needs_second_factor`: the account holds administrative permissions and this is its last second factor' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' '413': description: Body larger than 64 KB content: @@ -5863,6 +6063,12 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorBody' + '403': + description: '`first_party_session_required`: a token delegated to a client or issued for a personal access token' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' '404': description: No such method content: @@ -6233,11 +6439,15 @@ components: - default_max_sessions - confidential - allows_client_credentials + - allows_introspection - created_at properties: allows_client_credentials: type: boolean description: May obtain tokens for itself with the client credentials grant. + allows_introspection: + type: boolean + description: 'A resource server: may introspect the access tokens of others.' allows_loopback_redirect: type: boolean client_id: @@ -6492,6 +6702,11 @@ components: required: - user_code - created_at + - reauthentication_required + - scopes + - unrestricted + - unavailable_scopes + - sessions_used properties: client_id: type: @@ -6504,10 +6719,40 @@ components: created_at: type: integer format: int64 + reauthentication_required: + type: boolean + description: |- + Approving needs `current_password` (or a recent `POST /users/me/reauth`). + Every device approval does: typing a code someone handed over is how a + device flow is phished. requested_from_ip: type: - string - 'null' + scopes: + type: array + items: + type: string + description: |- + Permissions the approval would grant: the scopes asked for, intersected + with the user's. Empty with `unrestricted` when none were asked of a + client without scopes, in which case the device acts as the account. + sessions_allowed: + type: + - integer + - 'null' + format: int64 + description: '`None`: no session limit applies to this user and client.' + sessions_used: + type: integer + format: int64 + unavailable_scopes: + type: array + items: + type: string + description: Scopes asked for that this user does not hold. + unrestricted: + type: boolean user_agent: type: - string @@ -6521,6 +6766,13 @@ components: properties: approve: type: boolean + current_password: + type: + - string + - 'null' + description: |- + Approving a device of a client other than the instance's own + application needs this, or a recent `POST /users/me/reauth`. user_code: type: string DisableEmailOtpRequest: @@ -6616,6 +6868,15 @@ components: properties: flow_token: type: string + ForcePasswordResetRequest: + type: object + properties: + revoke_access_factors: + type: boolean + description: |- + Also remove the account's second factors, passkeys, external + identities and personal access tokens: what someone who knew the + password may have added. ForgotPasswordRequest: type: object required: @@ -6678,6 +6939,13 @@ components: type: - string - 'null' + session_type: + type: + - string + - 'null' + description: |- + For a user's token, the session it comes from: `web`, `device`, or + `personal_access_token` for a script. sub: type: - string @@ -6687,7 +6955,7 @@ components: type: - string - 'null' - description: '`access_token`, `refresh_token` or `personal_access_token`.' + description: '`access_token` or `refresh_token`.' LoginRequest: type: object required: @@ -6970,20 +7238,10 @@ components: type: string ReadyResponse: type: object - description: What `/ready` found for each dependency. + description: Whether this instance can serve traffic, as the public `/ready` says it. required: - status - - database - - redis - - nats properties: - database: - type: string - description: '`up` or `down`.' - nats: - type: string - redis: - type: string status: type: string description: '`ready` when every dependency answered, `unavailable` otherwise.' @@ -7181,6 +7439,13 @@ components: description: |- Allow the client credentials grant; needs a secret and scopes. Omitted: unchanged (false for a new client). + allows_introspection: + type: + - boolean + - 'null' + description: |- + Make the client a resource server, allowed to introspect the access + tokens of others. Omitted: unchanged (false for a new client). allows_loopback_redirect: type: boolean default_max_sessions: @@ -7193,6 +7458,9 @@ components: type: string is_primary: type: boolean + description: |- + Refused when true: the primary client is designated from the command + line only (`auth-api --register-client --primary`). redirect_uris: type: array items: @@ -7201,6 +7469,12 @@ components: type: array items: type: string + description: Permissions its tokens may carry. Empty needs `unrestricted`. + unrestricted: + type: boolean + description: |- + Required to register or keep a client without scopes: its tokens then + carry every permission of the user who approves it (never the roles). SessionResponse: type: object required: @@ -7359,13 +7633,19 @@ components: description: |- Unused and unexpired. Zero next to a verified method is worth showing: losing the device would then lock the account. - UseRecoveryCodeRequest: - type: object - required: - - code - properties: - code: - type: string + UpdatedWebhookResponse: + allOf: + - $ref: '#/components/schemas/WebhookResponse' + - type: object + properties: + secret: + type: + - string + - 'null' + description: |- + A new signing secret (`whsec_...`), shown once: present when the + endpoint now points at another host, whose previous secret stopped + signing. UserResponse: type: object required: @@ -7421,7 +7701,13 @@ components: type: object required: - token + - password properties: + password: + type: string + description: |- + The password chosen at the registration that sent the link: the link + proves the mailbox, the password proves which registration it activates. token: type: string VerifyTotpSetupRequest: diff --git a/docs/dev/api/routes.md b/docs/dev/api/routes.md index 2d38b9f..0432259 100644 --- a/docs/dev/api/routes.md +++ b/docs/dev/api/routes.md @@ -9,16 +9,22 @@ the overview. |------|---------| | - | No authentication | | JWT | Access token in `Authorization: Bearer` | -| Admin | Access token carrying the named permission, still granted in the database, from an account with a second factor | +| JWT (account) | Access token of a session acting for the account itself: a sign-in, or the instance's own application without scopes. A token delegated to another client, restricted to scopes, or obtained from a personal access token gets `403 first_party_session_required`. Every `/users/me`, `/admin` and approval route requires it | +| Admin | Access token carrying the named permission, still granted in the database, from a session whose sign-in proved a second factor (TOTP, email code, recovery code or passkey) | | JWT + reauth | Access token, and a recent re-authentication: `POST /users/me/reauth` within `SENSITIVE_ACTION_REAUTH_SECS`, or `current_password` in the body. A fresh sign-in does not count | | Rate limit | Meaning | |------------|---------| | General | `RATE_LIMIT_RPM` per client per minute | | Strict | Counts against the general budget **and** `RATE_LIMIT_AUTH_RPM` | +| General, per client | The general budget per address, plus 1 200 requests per minute per authenticated client and 20 wrong client secrets per address per 15 minutes | Each request passes one limiter, which checks all of its buckets in a single Redis call; a refused request consumes nothing. A `429` carries `Retry-After`. +Every route accepting `current_password` is strict, like `/users/me/reauth`. +Wrong passwords given to re-authenticate are also counted per session +(`LOCKOUT_THRESHOLD` per hour) and per account (three times as many): a stolen +session locks itself, not the owner's sessions. Timestamps are Unix seconds. Errors are `{"code": "...", "message": "..."}` with a stable `code`. @@ -28,7 +34,7 @@ with a stable `code`. | Method | Route | Auth | Rate limit | |--------|-------|------|------------| | GET | `/health`, `/live` | - | None (liveness) | -| GET | `/ready` | - | None (readiness: database, Redis, NATS) | +| GET | `/ready` | - | None (readiness: `ready` or `unavailable`; the internal listener details database, Redis and NATS) | | GET | `/.well-known/jwks.json` | - | General | The JWKS lists the current signing key, and the previous one during a @@ -42,7 +48,7 @@ loopback only. | Method | Route | Auth | Rate limit | |--------|-------|------|------------| | POST | `/auth/register` | - | Strict | -| POST | `/auth/verify-email` | - | Strict | +| POST | `/auth/verify-email` | - (`token` and the registration's `password`) | Strict | | POST | `/auth/verify-email/resend` | - | Strict | | POST | `/auth/login` | - | Strict | | POST | `/auth/two-factor/complete` | pre-auth token | Strict | @@ -62,7 +68,7 @@ loopback only. - `login` answers tokens, or `{ "two_factor_required": ..., "pre_auth_token", "method" }`. Each pre-auth token is bound to the method it was issued for. - `refresh` rotates the refresh token. Presenting a rotated token again revokes - the whole session family, except within 2 seconds of the rotation (two tabs, + the whole session family, except within 1 second of the rotation (two tabs, a retried request). - Logout stays outside the strict bucket so an exhausted budget never prevents ending a session. @@ -87,12 +93,12 @@ and 8628 (device authorization). Token and device authorization requests are | GET | `/oauth/authorization-requests/{id}` | JWT | Strict | | POST | `/oauth/authorization-requests/{id}/approve` | JWT (+ reauth for non-primary clients) | Strict | | POST | `/oauth/authorization-requests/{id}/deny` | JWT | Strict | -| POST | `/oauth/token` | client | Strict | +| POST | `/oauth/token` | client | General, per client | | POST | `/oauth/device_authorization` | client | Strict | -| POST | `/oauth/introspect` | confidential client | Strict | -| POST | `/oauth/revoke` | client | Strict | +| POST | `/oauth/introspect` | confidential client | General, per client | +| POST | `/oauth/revoke` | client | General, per client | | GET | `/oauth/device/{user_code}` | JWT | Strict | -| POST | `/oauth/device/verify` | JWT | Strict | +| POST | `/oauth/device/verify` | JWT (account) (+ reauth for non-primary clients) | Strict | **Client authentication.** A public client sends `client_id`. A confidential client (one given a secret with `POST /admin/clients/{client_id}/secret`) @@ -112,20 +118,23 @@ a failure answers `401 invalid_client`. `OAUTH_CONSENT_URI?request_id=...`. The consent page reads it with `GET /oauth/authorization-requests/{id}` (client, scopes, session limits, whether a re-authentication is needed) and approves or denies it; the answer - holds `redirect_to`, the client redirect carrying `code` and `state`, or - `error=access_denied`. A request is decided once. + holds `redirect_to`, the client redirect carrying `code`, `state` and `iss` + (RFC 9207), or `error=access_denied`. A request is decided once, and only + while its redirect URI is still registered. - The redirect URI must be registered exactly, or be a loopback `http://127.0.0.1:{port}/path` / `http://[::1]:{port}/path` for a registered path when the client allows it. `localhost` is refused. - The client redeems the code at `POST /oauth/token` with `grant_type=authorization_code`, `code`, `code_verifier` and `redirect_uri`. A code is single use: a failed redemption burns it, and a replayed code - revokes the session it produced. + revokes the session it produced, whoever presents it: keep codes out of logs + and Referer headers. **Scopes.** `scope` lists permissions. A client registered with scopes may ask for a subset of them; without `scope`, its registered scopes apply. Tokens carry -the consented scopes the user holds, on issue and on every refresh, and no -roles. The token response echoes `scope` when the session is restricted. +the consented scopes the user holds and the client may still ask for, on +issue and on every refresh, and no roles. `scopes_supported` in the metadata +lists the scopes some registered client may ask for. The token response echoes `scope` when the session is restricted. **Refresh.** A client refreshes its sessions at `POST /oauth/token` with `grant_type=refresh_token`; `/auth/refresh` refuses them. The session must @@ -150,11 +159,15 @@ included, and `GET /oauth/userinfo` for their access tokens. `profile` releases `email_verified`. Not supported: implicit and hybrid flows, request objects, `prompt`, `max_age`, dynamic registration. -**Introspection (RFC 7662).** A confidential client (a resource server) -posts `token` and learns `active`, and for an active token its `token_type` -(`access_token`, `refresh_token`, `personal_access_token`), `scope`, -`client_id`, `sub`, `exp`, `iat` and, for access tokens, `iss`, `aud` and `jti`. -Anything unknown, expired or revoked is `{ "active": false }`. +**Introspection (RFC 7662).** A confidential client posts `token` and learns +`active`, and for an active token its `token_type` (`access_token`, +`refresh_token`), `session_type`, `scope`, `client_id`, `sub`, `exp`, `iat` +and, for access tokens, `iss`, `aud` and `jti`. Only a resource server +(`allows_introspection`, set with `PUT /admin/clients/{client_id}`) learns +about the access tokens of others, and one registered with scopes sees only +those in `scope`; any other client, only about its own tokens. A refresh token +is described only to its own client, and a personal access token never. Anything unknown, +expired, revoked or not the caller's is `{ "active": false }`. **Revocation (RFC 7009).** A client posts one of its tokens. A refresh token ends its session and every access token of it; an access token stops working @@ -167,8 +180,12 @@ and are left alone. `POST /oauth/token` with `grant_type=urn:ietf:params:oauth:grant-type:device_code`: `authorization_pending`, `slow_down` when polling faster than the interval, `access_denied`, `expired_token`, or tokens. The signed-in user previews the -request (`GET /oauth/device/{user_code}`) and approves or denies it -(`POST /oauth/device/verify`). An approval is collected once, by the client that +request (`GET /oauth/device/{user_code}`: the client, where it asked from, +the `scopes` it would get, `unavailable_scopes`, `unrestricted` when it would +act as the account, and the session allowance) and approves or denies it +(`POST /oauth/device/verify`; every approval, the instance's own application +included, needs `current_password` or a recent re-authentication, which +`reauthentication_required` in the preview announces). An approval is collected once, by the client that started the flow; account status and the client's session limit are checked when tokens are issued (`invalid_grant` otherwise). @@ -183,10 +200,10 @@ when tokens are issued (`invalid_grant` otherwise). | GET | `/users/me/tokens` | JWT | General | | POST | `/users/me/tokens` | JWT + reauth (recent only) | General | | DELETE | `/users/me/tokens/{id}` | JWT | General | -| PATCH | `/users/me/username` | JWT + reauth | General | -| PATCH | `/users/me/password` | JWT + reauth | General | +| PATCH | `/users/me/username` | JWT + reauth | Strict | +| PATCH | `/users/me/password` | JWT + reauth | Strict | | PATCH | `/users/me/locale` | JWT | General | -| DELETE | `/users/me` | JWT + reauth | General | +| DELETE | `/users/me` | JWT + reauth | Strict | `/users/me/audit?limit=&cursor=` returns the caller's own security history, newest first: `{ "entries": [...], "next_cursor" }`. Pass `next_cursor` back as @@ -213,16 +230,24 @@ security history. No password hash, secret or token digest is included. As a | POST | `/users/me/email/submit` | JWT | Strict | | POST | `/users/me/email/confirm` | JWT | Strict | -A code is sent to the current address, then to the new one. Confirming revokes -every other session and notifies the previous address. +A code is sent to the current address, then to the new one. Submitting an +address that belongs to another account answers `204` like any other, but sends +no code: the flow cannot tell who has an account. Submissions are limited to 3 +an hour per account and per target address. Confirming revokes every other +session, the reset and sign-in links already mailed, and notifies the previous +address. ## Sessions | Method | Route | Auth | Rate limit | |--------|-------|------|------------| | GET | `/users/me/sessions` | JWT | General | -| DELETE | `/users/me/sessions` | JWT + reauth | General | -| DELETE | `/users/me/sessions/{id}` | JWT + reauth | General | +| DELETE | `/users/me/sessions` | JWT + reauth | Strict | +| DELETE | `/users/me/sessions/{id}` | JWT + reauth | Strict | + +`last_used_at` of a session is the moment it was issued or last refreshed: +a refresh rotates the session, so an active session was used at most one +refresh lifetime ago. Access tokens do not update it. ## External identities @@ -235,7 +260,7 @@ every other session and notifies the previous address. | GET | `/users/me/external-identities` | JWT | General | | POST | `/users/me/external-identities/{provider}/start` | JWT + reauth (recent only) | General | | POST | `/users/me/external-identities/complete` | JWT | General | -| DELETE | `/users/me/external-identities/{id}` | JWT + reauth | General | +| DELETE | `/users/me/external-identities/{id}` | JWT + reauth | Strict | Providers (`IDENTITY_PROVIDERS`): Google, GitHub, or any OpenID Connect issuer. @@ -264,7 +289,7 @@ account links one identity per provider | GET | `/users/me/passkeys` | JWT | General | | POST | `/users/me/passkeys/options` | JWT + reauth (recent only) | General | | POST | `/users/me/passkeys` | JWT | General | -| DELETE | `/users/me/passkeys/{id}` | JWT + reauth | General | +| DELETE | `/users/me/passkeys/{id}` | JWT + reauth | Strict | | POST | `/auth/passkeys/options` | - | Strict | | POST | `/auth/passkeys/sign-in` | passkey | Strict | @@ -287,15 +312,14 @@ second factor `/admin` requires. | Method | Route | Auth | Rate limit | |--------|-------|------|------------| | GET | `/users/me/two-factor` | JWT | General | -| POST | `/users/me/two-factor/totp/setup` | JWT + reauth | General | +| POST | `/users/me/two-factor/totp/setup` | JWT + reauth | Strict | | POST | `/users/me/two-factor/totp/{id}/verify` | JWT | General | -| DELETE | `/users/me/two-factor/totp/{id}` | JWT + reauth | General | -| POST | `/users/me/two-factor/email/setup` | JWT + reauth | General | +| DELETE | `/users/me/two-factor/totp/{id}` | JWT + reauth | Strict | +| POST | `/users/me/two-factor/email/setup` | JWT + reauth | Strict | | POST | `/users/me/two-factor/email/send` | JWT | General | | POST | `/users/me/two-factor/email/{id}/verify` | JWT | General | -| DELETE | `/users/me/two-factor/email/{id}` | JWT + reauth | General | -| POST | `/users/me/two-factor/recovery-codes` | JWT + reauth | General | -| POST | `/users/me/two-factor/recovery-codes/use` | JWT | General | +| DELETE | `/users/me/two-factor/email/{id}` | JWT + reauth | Strict | +| POST | `/users/me/two-factor/recovery-codes` | JWT + reauth | Strict | `GET /users/me/two-factor` lists the configured methods (with the ids the other routes need) and `recovery_codes_remaining`, the unused and unexpired codes. @@ -308,38 +332,66 @@ are regenerated. |--------|-------|------|------------| | GET | `/admin/users` | Admin `users:read` | General | | GET | `/admin/users/{id}` | Admin `users:read` | General | -| POST | `/admin/users/{id}/suspend` | Admin `users:manage` | General | -| POST | `/admin/users/{id}/reactivate` | Admin `users:manage` | General | -| POST | `/admin/users/{id}/unlock` | Admin `users:manage` | General | -| DELETE | `/admin/users/{id}/sessions` | Admin `users:manage` | General | -| POST | `/admin/users/{id}/password-reset` | Admin `users:manage` | General | +| POST | `/admin/users/{id}/suspend` | Admin `users:manage` + reauth | General | +| POST | `/admin/users/{id}/reactivate` | Admin `users:manage` + reauth | General | +| POST | `/admin/users/{id}/unlock` | Admin `users:manage` + reauth | General | +| DELETE | `/admin/users/{id}/access-factors` | Admin `users:manage` + reauth | General | +| DELETE | `/admin/users/{id}/sessions` | Admin `users:manage` + reauth | General | +| POST | `/admin/users/{id}/password-reset` | Admin `users:manage` + reauth | General | | DELETE | `/admin/users/{id}` | Admin `users:manage` + reauth | General | | POST | `/admin/users/{id}/roles` | Admin `roles:manage` + reauth | General | -| DELETE | `/admin/users/{id}/roles/{name}` | Admin `roles:manage` | General | +| DELETE | `/admin/users/{id}/roles/{name}` | Admin `roles:manage` + reauth | General | | GET | `/admin/permissions` | Admin `roles:manage` | General | | GET | `/admin/roles` | Admin `roles:manage` | General | | POST | `/admin/roles` | Admin `roles:manage` + reauth | General | | PUT | `/admin/roles/{name}/permissions` | Admin `roles:manage` + reauth | General | -| DELETE | `/admin/roles/{name}` | Admin `roles:manage` | General | +| DELETE | `/admin/roles/{name}` | Admin `roles:manage` + reauth | General | | GET | `/admin/clients` | Admin `clients:manage` | General | | PUT | `/admin/clients/{client_id}` | Admin `clients:manage` + reauth | General | -| DELETE | `/admin/clients/{client_id}` | Admin `clients:manage` | General | +| DELETE | `/admin/clients/{client_id}` | Admin `clients:manage` + reauth | General | | POST | `/admin/clients/{client_id}/secret` | Admin `clients:manage` + reauth | General | -| DELETE | `/admin/clients/{client_id}/secret` | Admin `clients:manage` | General | +| DELETE | `/admin/clients/{client_id}/secret` | Admin `clients:manage` + reauth | General | | GET | `/admin/audit` | Admin `audit:read` | General | +Every `/admin` route needs a first-party session that proved a second factor +at sign-in (`403 two_factor_required` otherwise). + +Granting, withdrawing, emptying or deleting a role needs every permission it +grants (`403` otherwise). `PUT /admin/clients/{client_id}` refuses a client +without scopes unless the body sets `"unrestricted": true`, redirect URIs that +are not `https`, loopback `http` or a private-use `reverse.domain:` scheme, and +any change to the primary client or `"is_primary": true` +(`409 primary_client_managed_by_command_line`); deleting the primary client or +changing its secret is refused the same way: the primary client is managed +with `auth-api --register-client`. Adding an administrative permission to a +role needs every holder active with a second factor +(`409 holders_without_second_factor`). Searching accounts, reading an account +and reading the audit log are recorded in the administrator's history +(`admin_data_read`). `PUT /admin/webhooks/{id}` returns a new `secret` when the +endpoint now points at another host. + `GET /admin/users` takes `query` (start of the address or username), `status`, `limit` and `cursor`, and pages newest first. Administrators cannot suspend, -sign out, reset or delete their own account here; they use `/users/me`. +sign out, unlock, reset or delete their own account here; they use +`/users/me`. "+ reauth" means a recent `POST /users/me/reauth` +(`403 reauthentication_required` otherwise); no administration route takes a +password in its body. The owner of an account is mailed when an administrator +suspends or reactivates it, changes its roles or signs it out. A change to roles that would leave no account with `roles:manage` answers `409 last_administrator`; the default role cannot be deleted -(`409 default_role`). Access tokens carry the permissions of their issuance +(`409 default_role`) nor grant an administrative permission +(`409 default_role_administration`), and nobody adds to a role they hold a +permission they lack (`403`). An account holding administrative permissions +keeps at least one second factor (`409 administrator_needs_second_factor` on +removing the last). Access tokens carry the permissions of their issuance until refreshed; `/admin` routes read them from the database on every request. Webhooks (`webhooks:manage`): `GET`/`POST /admin/webhooks`, `PUT`/`DELETE /admin/webhooks/{id}`, `POST /admin/webhooks/{id}/secret`, `GET /admin/webhooks/{id}/deliveries` and -`POST /admin/webhooks/{id}/deliveries/{delivery_id}/retry`. See the +`POST /admin/webhooks/{id}/deliveries/{delivery_id}/retry`. Every change to a +webhook, deleting it and retrying a delivery included, needs a recent +re-authentication. See the [webhook guide](../guides/webhooks.md). `GET /admin/audit` takes `user_id`, `action`, `limit` and `cursor`. diff --git a/docs/dev/database/schema.md b/docs/dev/database/schema.md index 13bbaea..b8a69e8 100644 --- a/docs/dev/database/schema.md +++ b/docs/dev/database/schema.md @@ -1,7 +1,10 @@ # Database Schema -Migrations live in `migrations/` and are never edited once released: every -change is a new file. Tokens, codes and refresh tokens are stored as SHA-256 +Migrations live in `migrations/`, one per table or feature, each defining its +objects whole. No deployment exists yet, so they are edited in place: a new +column or enum value goes into the migration that creates its table, never +into an `ALTER` of a later file. Once a database is in production they are +frozen (`migrations/SHA256SUMS`) and every change becomes a new file. Tokens, codes and refresh tokens are stored as SHA-256 digests (32 bytes), never in clear. ## Accounts @@ -16,7 +19,7 @@ digests (32 bytes), never in clear. | `email_verified_at` | TIMESTAMPTZ | Yes | | | `last_login_at` | TIMESTAMPTZ | Yes | Last completed sign-in | | `locked_until` | TIMESTAMPTZ | Yes | Lockout expiry after repeated wrong passwords | -| `lockout_cleared_at` | TIMESTAMPTZ | Yes | Last unlock by an administrator; earlier failures no longer count toward a lockout | +| `lockout_cleared_at` | TIMESTAMPTZ | Yes | Last completed sign-in, administrator unlock or password reset; earlier failures no longer count toward a lockout | | `status` | user_status | No | `pending_verification`, `active`, `inactive`, `suspended` | | `preferred_locale` | VARCHAR(10) | No | `en`, `fr`, ... | | `username` | VARCHAR(50) | No | Unique, case-insensitive | @@ -67,7 +70,20 @@ address), `first_seen_at`, `last_seen_at`. Forgotten after ### email_verification_tokens, password_reset_tokens, magic_link_tokens Single-use tokens (`token_hash`, `expires_at`, `used_at`). At most one active -token per user. `magic_link_tokens` hold sign-in links (15 minutes). +reset or sign-in link per user; a pending account may hold several +verification links, each carrying the `password_hash`, `username` and +`preferred_locale` of the registration that sent it (all three or none), +until one of them verifies the account. `magic_link_tokens` hold sign-in links +(15 minutes). + +### username_reservations + +A username asked for by a registration on an address that already has an +account (`username`, unique whatever its case; `expires_at`, when that +registration's link would have expired; `reserved_for`, the account of that +address, which holds one reservation at a time). Registrations and renames treat it as +taken, so a taken username never tells whether an address is registered. +Expired rows go with `cleanup_expired_email_verification_tokens`. ### webhook_endpoints, webhook_deliveries @@ -152,8 +168,9 @@ Per-user override of a client's session limit: `user_id`, `client_id`, ### used_totp_codes -Replay guard: `(user_id, code_hash)` primary key, `used_at`. A TOTP code is -accepted once within its validity window. +Replay guard: `(user_id, code_hash)` primary key, `used_at` written from the +application's clock. A consumed code stays refused 120 seconds: every step it +can be accepted in (skew 1) and a step of margin. ### email_2fa_codes @@ -193,7 +210,7 @@ a client address. | `created_at` | TIMESTAMPTZ | No | Partition key | | `user_id` | UUID | Yes | FK -> users | | `request_id` | UUID | Yes | `x-request-id` of the request | -| `action` | audit_action | No | `login`, `password_changed`, `session_replay_detected`, `encryption_key_rotated`, ... | +| `action` | audit_action | No | `login`, `password_changed`, `session_replay_detected`, `client_authorized`, `device_approved`, `device_denied`, `admin_data_read`, `encryption_key_rotated`, ... | | `ip_address` | INET | Yes | Only the network (/24, /48) after `AUDIT_IP_RETENTION_DAYS`; removed when the account is deleted | | `metadata` | JSONB | No | Action details, without personal data such as addresses | diff --git a/docs/dev/guides/commands.md b/docs/dev/guides/commands.md index ab5f834..5231f28 100644 --- a/docs/dev/guides/commands.md +++ b/docs/dev/guides/commands.md @@ -91,7 +91,7 @@ server. In production, run them in a one-off container: | `--healthcheck` | Call the local `/live` and exit 0 or 1 (the image's health check) | | `--grant-role --user ` | Grant a role to an account, audited; appoints the first administrator (`--grant-role admin`) | | `--register-client --name [options]` | Create or update a registered client (needs only `DATABASE_URL`) | -| `--rotate-totp-keys` | Re-encrypt TOTP secrets and webhook signing secrets under `ENCRYPTION_KEY` (see the [operations runbook](../../deploy/guides/operations.md)) | +| `--rotate-totp-keys` | Re-encrypt TOTP secrets and webhook signing secrets under `ENCRYPTION_KEY`, bound to their rows (see the [operations runbook](../../deploy/guides/operations.md)) | `--register-client` options: diff --git a/docs/dev/guides/configuration.md b/docs/dev/guides/configuration.md index 86fd088..82e4106 100644 --- a/docs/dev/guides/configuration.md +++ b/docs/dev/guides/configuration.md @@ -5,6 +5,11 @@ that is present but does not parse is an error, never a silent fallback to its default (`LOCKOUT_THRESHOLD=1O` refuses to start). A blank value counts as unset. +Any variable `X` can instead be given as `X_FILE`, the path of a file holding +its value (a trailing newline is dropped): a Docker or systemd secret, kept out +of the environment that `docker inspect` and `/proc` show. Setting both `X` and +`X_FILE` refuses to start; a file that does not exist counts as unset. + In `APP_ENV=production` the configuration is validated before the server accepts traffic; the checks are listed under [Production checks](#production-checks). @@ -123,11 +128,15 @@ and `occurred_at`. The broker ships in the compose files. | `LOCKOUT_DURATION_SECS` | `1800` | Lockout duration | | `SENSITIVE_ACTION_REAUTH_SECS` | `600` | How long a re-authentication (`POST /users/me/reauth`) covers sensitive actions | | `MAGIC_LINK_ENABLED` | `false` | Offer sign-in links by email (`/auth/magic-link`): whoever reads the mailbox can sign in without the password, the second factor still applies. Off, the routes answer `404` | +| `RESET_REVOKES_FACTORS_ADDED_HOURS` | `72` | A password reset removes the second factors, passkeys and external identities added this many hours before it was asked for, and lists them in its mail; `0` keeps them | +| `REGISTRATIONS_PER_IP_PER_HOUR` | `20` | Registrations accepted per client address (IPv6 /64) per hour, `429` past it; `0` removes the budget. Usernames are reserved from registration, so this bounds how fast they can be squatted | | `NEW_DEVICE_ALERTS_ENABLED` | `true` | E-mail the owner when an account signs in from a browser and system family it never used (devices are recorded either way) | | `CAPTCHA_SECRET` | unset | hCaptcha secret; unset disables the check, which production refuses | | `CAPTCHA_VERIFY_URL` | `https://hcaptcha.com/siteverify` | Verification endpoint | | `CAPTCHA_TIMEOUT_SECS` | `5` | Verification timeout | | `CAPTCHA_FAIL_OPEN` | `true` outside production | Accept the request when the provider cannot be reached | +| `CAPTCHA_SITE_KEY` | unset | Site key of the widget, sent with each verification: a token solved for another site key is refused | +| `CAPTCHA_EXPECTED_HOSTNAMES` | host of `FRONTEND_URL` | Comma-separated hostnames a challenge may be solved on | | `PWNED_PASSWORDS_ENABLED` | `true` | Refuse passwords found in known data breaches, at registration, change and reset (`422 password_compromised`) | | `PWNED_PASSWORDS_URL` | `https://api.pwnedpasswords.com` | Range API; production requires HTTPS. Only the first five characters of the password's SHA-1 are sent | | `PWNED_PASSWORDS_TIMEOUT_MS` | `1500` | Range query timeout | @@ -213,7 +222,7 @@ the audit log partitions. | `CLEANUP_RECOVERY_CODES_GRACE_DAYS` | `7` | Kept after expiry | | `CLEANUP_WEBHOOK_DELIVERY_DAYS` | `7` | Delivered and given-up webhook deliveries are deleted after this many days | | `CLEANUP_KNOWN_DEVICE_DAYS` | `90` | Devices unused for this many days are forgotten; a later sign-in from one alerts again | -| `CLEANUP_UNVERIFIED_ACCOUNT_DAYS` | `7` | Accounts whose address was never verified are deleted after this many days (audited, `user.deleted` published); `0` keeps them | +| `CLEANUP_UNVERIFIED_ACCOUNT_DAYS` | `2` | Accounts whose address was never verified are deleted after this many days (audited, `user.deleted` published); `0` keeps them | | `AUDIT_LOG_RETENTION_MONTHS` | `12` | Monthly audit partitions kept; `0` keeps every partition | | `AUDIT_IP_RETENTION_DAYS` | `90` | Client addresses of older audit entries keep only their network (/24, /48); `0` keeps full addresses | @@ -227,6 +236,7 @@ session it produced) and TOTP replay records 90 seconds; neither is configurable | `LOG_LEVEL` | `info` | `error`, `warn`, `info`, `debug`, `trace` | | `LOG_FORMAT` | `pretty` | `json` in production | | `METRICS_ENABLED` | `true` | Serve Prometheus metrics on a separate listener | +| `METRICS_HOST` | `SERVER_HOST` | Address of the internal listener (`/metrics`, detailed `/ready`); `127.0.0.1` outside a container | | `METRICS_PORT` | `9464` | Metrics listener port; publish on loopback only | | `OTEL_EXPORTER_OTLP_ENDPOINT` | unset | OTLP/HTTP collector base URL (`/v1/traces` is appended); unset, no trace is exported | | `OTEL_SERVICE_NAME` | `auth-api` | `service.name` of the traces | @@ -236,8 +246,11 @@ session it produced) and TOTP replay records 90 seconds; neither is configurable With `APP_ENV=production` the service refuses to start when: -- `APP_PUBLIC_URL`, `FRONTEND_URL`, `OAUTH_CONSENT_URI` or `CAPTCHA_VERIFY_URL` is not HTTPS; -- `TRUSTED_PROXY_CIDRS` is empty (every client would share the proxy's address); +- `APP_PUBLIC_URL`, `FRONTEND_URL`, `OAUTH_CONSENT_URI`, + `DEVICE_AUTH_VERIFICATION_URI` or `CAPTCHA_VERIFY_URL` is not HTTPS; +- `TRUSTED_PROXY_CIDRS` is empty (every client would share the proxy's + address), or holds a network rather than addresses (`/32` in IPv4, `/128` + in IPv6): any other host of that network could forge `X-Forwarded-For`; - the JWT keys do not form a pair, or are the committed development pair; - `ENCRYPTION_KEY` is not 32 bytes, is a committed development key, or is an arithmetic sequence; @@ -249,4 +262,27 @@ With `APP_ENV=production` the service refuses to start when: - `WEBHOOK_ALLOW_HTTP` or `WEBHOOK_ALLOW_PRIVATE_NETWORKS` is `true`; - `WEBAUTHN_ORIGINS` is empty, or lists an origin that is not HTTPS or not on `WEBAUTHN_RP_ID`; -- `SENSITIVE_ACTION_REAUTH_SECS` or `ARGON2_MAX_CONCURRENCY` is `0`. +- `ARGON2_MEMORY_KIB` is under `19456` or `ARGON2_ITERATIONS` under `2`; +- `DATABASE_URL`, `DATABASE_READ_URL` or `REDIS_URL` carries no password; +- `LOCKOUT_THRESHOLD` is above `50`, `RATE_LIMIT_RPM` or `RATE_LIMIT_AUTH_RPM` + above `10000`, `JWT_REFRESH_EXPIRY_SECS` or `JWT_MAX_SESSION_LIFETIME_SECS` + above a year, `REGISTRATIONS_PER_IP_PER_HOUR` is `0`, or + `PWNED_PASSWORDS_ENABLED` is `false`. + +In every environment, the service also refuses to start when: + +- `SENSITIVE_ACTION_REAUTH_SECS` is `0` or above `900`, or + `ARGON2_MAX_CONCURRENCY` is `0`; +- `TOTP_SKEW` is above `1` (used codes are remembered one step each side); +- `JWT_ACCESS_EXPIRY_SECS` is outside `60`-`3600`, + `JWT_SHORT_SESSION_EXPIRY_SECS` is `0`, or `JWT_REFRESH_EXPIRY_SECS` is below + `JWT_SHORT_SESSION_EXPIRY_SECS`; +- `RATE_LIMIT_RPM` or `RATE_LIMIT_AUTH_RPM` is `0`; +- `LOCKOUT_DURATION_SECS` is under `60`, or `DEVICE_AUTH_TTL_SECS` is `0` or + above `1800`; +- a required variable is blank (it counts as missing). + +`PWNED_PASSWORDS_FAIL_OPEN=true` stays allowed in production, unlike the other +fail-open switches: failing closed would make registration, password changes +and resets depend on the breached-password service. The +`AuthApiPwnedPasswordsUnavailable` alert shows when checks are skipped. diff --git a/docs/dev/guides/integration.md b/docs/dev/guides/integration.md index a39cbbf..2ff855c 100644 --- a/docs/dev/guides/integration.md +++ b/docs/dev/guides/integration.md @@ -42,8 +42,11 @@ secret once. ``` auth-api's frontend signs the user in if needed, shows the consent screen, - and the browser comes back to the redirect URI with `code` and `state`, or - with `error`. Check that `state` is the one you sent. + and the browser comes back to the redirect URI with `code`, `state` and + `iss`, or with `error`. Check that `state` is the one you sent and that + `iss` is auth-api's issuer (RFC 9207): a client registered with several + servers then cannot be fed another server's code. Keep the code out of logs + and Referer headers: a code presented twice ends the session it produced. 3. Exchange the code, from your server for a confidential client: ```bash @@ -64,7 +67,9 @@ secret once. Each refresh returns a new refresh token; keep only the latest. Presenting a replaced one revokes the whole session (two requests within two seconds are - treated as the same client retrying). + treated as the same client retrying). A `scope` parameter asks for fewer + permissions in that access token (never more: `invalid_scope`); a + refreshed ID token carries no `nonce`. Native applications register a loopback redirect such as `http://127.0.0.1/callback` and listen on any free port: the redirect @@ -110,6 +115,12 @@ curl https://auth.example.com/auth/personal-access-tokens/exchange \ -H 'content-type: application/json' -d "{\"token\": \"$AAPAT\"}" ``` +The access token is delegated, like the tokens of a client application other +than the instance's own: it works on your resource servers and on +`/oauth/userinfo`, but the auth-api account routes (`/users/me/*`), the +approval routes and `/admin/*` answer `403 first_party_session_required`. +Managing the account stays with the user, signed in. + ### OpenID Connect Add `openid` (and `profile`, `email` for the matching claims) to the code flow @@ -123,7 +134,8 @@ claims. Libraries configure themselves from Every resource server: -1. Reads `Authorization: Bearer `. +1. Reads `Authorization: Bearer `. Access tokens carry the JOSE header + `typ: at+jwt` (RFC 9068); an ID token (`typ: JWT`) is not an access token. 2. Verifies the ES256 signature with the key of the token's `kid` from `https://auth.example.com/.well-known/jwks.json`. Cache the key set; fetch it again when a `kid` is unknown, at most once a minute. Accept `ES256` only. @@ -132,7 +144,8 @@ Every resource server: `nbf` with a small leeway. 4. Authorizes on `permissions` (`resource:action` names). `roles` are absent from tokens restricted to scopes (a client registered with scopes, or a - request naming them): authorize on permissions. + request naming them) and from every token of a third-party client: + authorize on permissions. Ready-made: the Rust crate [`crates/verifier`](../../../crates/verifier/README.md) (with an axum extractor) and the npm package @@ -153,9 +166,11 @@ A token's claims: | `sid` | Session id; nil for client credentials | | `jti` | Token id, for revocation and introspection caches | | `iss`, `aud`, `iat`, `nbf`, `exp` | Standard | -| `roles` | Role names; absent from scoped and client credentials tokens | +| `roles` | Role names; only in tokens of the account itself or of the instance's own (primary) application, never in those of a third-party client, scoped or not | | `permissions` | Permission names, intersected with the consented scopes | -| `client_id` | For client credentials tokens | +| `client_id` | The client the token was issued to: client credentials, and sessions of client applications | +| `sub_type` | `user`, or `client` for client credentials: check it before authorizing on `sub` | +| `session_type` | `web`, `device`, or `personal_access_token` for a script | ## 4. Following account changes diff --git a/docs/dev/guides/release.md b/docs/dev/guides/release.md index afc7a41..09ef2cb 100644 --- a/docs/dev/guides/release.md +++ b/docs/dev/guides/release.md @@ -59,6 +59,9 @@ HIGH or CRITICAL vulnerability with a fix stops the release. | `docker-compose.api.yml`, `config.prod.env` | Deployment files of this version | | `nginx/nginx.conf` | Reverse proxy configuration | | `scripts/backup-db.sh`, `scripts/restore-db.sh`, `scripts/backup-drill.sh` | Database backup, restore and drill (DB VPS) | +| `scripts/rolling-update.sh`, `scripts/write-secrets.sh` | Rolling update and the secret files it mounts (API VPS) | +| `deploy/api/nftables-auth-api.conf` | The rule letting only nginx and root reach the instances (API VPS) | +| `deploy/api/logrotate-auth-api` | The 14-day rotation of the access log, which holds client addresses (API VPS) | | `docs/deploy/guides/prometheus-alerts.yml` | Alert rules for the monitoring host | | `SHA256SUMS` | Checksums of every file above | | `SHA256SUMS.sig` | Signature of `SHA256SUMS` by the release key | diff --git a/docs/dev/privacy.md b/docs/dev/privacy.md index f6cde2b..8c08d81 100644 --- a/docs/dev/privacy.md +++ b/docs/dev/privacy.md @@ -13,7 +13,7 @@ processing, not a legal notice. | Data | Where | Purpose | Kept | When the account is deleted | |------|-------|---------|------|-----------------------------| | Email address, username, password hash (Argon2id), locale, status, timestamps | `users` | Account and sign-in | Until the account is deleted | Deleted | -| Accounts whose address was never verified | `users` | Letting the owner finish signing up | 7 days (`CLEANUP_UNVERIFIED_ACCOUNT_DAYS`), then deleted and announced like a deletion | - | +| Accounts whose address was never verified | `users` | Letting the owner finish signing up | 2 days (`CLEANUP_UNVERIFIED_ACCOUNT_DAYS`), then deleted and announced like a deletion | - | | Sessions: client address, user agent, device name | `sessions` | Signed-in devices, revocation, replay detection | Until expiry or revocation, plus 7 days (`CLEANUP_SESSIONS_GRACE_DAYS`) | Deleted | | Devices signed in from: browser and system families, hashed | `known_devices` | Telling the owner about a sign-in from a new device | 90 days unused (`CLEANUP_KNOWN_DEVICE_DAYS`) | Deleted | | Sign-in attempts: identifier typed, client address, user agent of failures | `login_attempts` | Brute-force protection, lockout, security history | 90 days (`CLEANUP_LOGIN_ATTEMPTS_RETENTION_DAYS`) | Deleted, including failed attempts typed with the account's address or username before it existed | @@ -32,8 +32,9 @@ processing, not a legal notice. | First five characters of a new password's SHA-1 | Pwned Passwords range API | Refusing breached passwords (k-anonymity: the password and its full hash never leave) | Not stored by auth-api | - | The application logs record the route, status, latency and request id of each -request, not the client address. nginx records client addresses in its access -log, rotated by the system's logrotate. Database backups are encrypted and kept +request, not the client address. nginx records client addresses and user +agents in its access log, rotated daily and kept 14 days +(`deploy/api/logrotate-auth-api`). Database backups are encrypted and kept 7 days on the server and 30 days offsite (`RETAIN_DAYS`, `OFFSITE_RETAIN_DAYS`); pgBackRest keeps the last two full backups and the WAL they need (`repo1-retention-full=2`). A deleted account disappears from backups when the @@ -58,7 +59,7 @@ erase their own data about that user id. | Right | How | |-------|-----| -| Access and portability | `GET /users/me/export`: one JSON document with everything listed above that auth-api stores about the account, secrets excepted; the other `GET /users/me/*` routes show each part | +| Access and portability | `GET /users/me/export`: one JSON document with everything listed above that auth-api stores about the account (passkeys, personal access tokens, linked identities and where each mailed link was asked from included), secrets excepted; the other `GET /users/me/*` routes show each part. Failed sign-ins typed for the account are part of its security history, including those of other people: for them, and for the requests of mailed links, only the network (/24, /48) is shown. A change made by an administrator names neither the administrator nor their address | | Rectification | `PATCH /users/me/username`, `PATCH /users/me/locale`, the email change flow (`/users/me/email/*`) | | Erasure | `DELETE /users/me`; for an account the user cannot reach, an administrator deletes it (`DELETE /admin/users/{id}`) | | Restriction | An administrator suspends the account (`POST /admin/users/{id}/suspend`) | @@ -73,3 +74,10 @@ erase their own data about that user id. account is gone. - Client addresses of the audit log lose their host part after 90 days. - Failed sign-ins keep the user agent, successful ones do not. + +## Sign-in attempts + +A failed sign-in records the identifier typed only when it has the shape of an +email address or a username; anything else (a password typed into the wrong +field, for instance) is recorded as ``, so it never sits in +`login_attempts` for the retention period. diff --git a/docs/dev/security-model.md b/docs/dev/security-model.md index eee73d7..1944661 100644 --- a/docs/dev/security-model.md +++ b/docs/dev/security-model.md @@ -22,30 +22,103 @@ the database together, is out of scope. ## Credentials -- **Argon2id**, 64 MiB and 3 iterations by default; hashes run on a bounded - pool (`ARGON2_MAX_CONCURRENCY`) so a login storm queues instead of exhausting - memory. +- **Argon2id**, 64 MiB and 3 iterations by default, never under 19 MiB and 2 + iterations in production; hashes run on a bounded pool + (`ARGON2_MAX_CONCURRENCY`) so a login storm queues instead of exhausting + memory. A stored hash weaker than the configured parameters is replaced after + the next successful sign-in; one asking for far more than the configuration + (four times, and at least 256 MiB) is refused before any work, so a hash + planted in the database cannot exhaust an instance's memory. - **No account oracle.** An unknown identifier still pays a full hash against a - decoy. A locked account answers the same whatever the password. Registration - answers `202` identically whether or not the address is taken (the owner is - emailed instead; a pending one gets its verification again). Forgot-password - and verification resends take a constant minimum time and answer identically. -- **Addresses cannot be squatted.** A password reset proves ownership of the - address: it verifies a pending account with the password its owner chose, so - an account someone else registered with the address is taken back. Accounts - never verified are deleted after `CLEANUP_UNVERIFIED_ACCOUNT_DAYS`. + decoy. A locked password answers like a wrong one (`401 invalid_credentials`) + and is recorded like one, so budgets read the same too. Registration + answers `202` identically, in a constant minimum time, whether or not the + address is taken (the owner is emailed instead, at most three times an hour; + a pending one gets a verification link of its own). Forgot-password and + verification resends take a constant minimum time and answer identically. An + email change to an address that belongs to another account answers like any + other and sends nothing; new addresses are budgeted per account and per + target. +- **Links mailed earlier stop working** when the password changes, is reset, + or the address changes: reset and sign-in links go with the old secret or the + old mailbox. +- **CAPTCHA** verifications carry the client's address and the site key, and a + challenge solved on another hostname is refused. +- **Addresses cannot be squatted.** A pending account belongs to nobody yet: a + registration on its address gets its own verification link, carrying the + password, username and locale that registration chose. A link verifies the + account only with the password of the registration that sent it (the + account's own password for a resend), and a wrong password leaves it unused: + a registration, first or second, cannot activate the account with a password + its owner did not choose. Links of a pending account coexist until one + verifies it. + A password reset also proves ownership of the address: it verifies a pending + account with the password its owner chose. Accounts never verified are + deleted after `CLEANUP_UNVERIFIED_ACCOUNT_DAYS`. - **Breached passwords are refused** at registration, change and reset, through the Pwned Passwords range API: only the first five characters of the SHA-1 leave the service, answers are padded, and the check runs before the address is looked up so it costs the same whether the address is taken. -- **Lockout** after `LOCKOUT_THRESHOLD` consecutive wrong passwords. Failed - second factors never count: whoever fails a second factor already holds the - password, and letting them lock the account would let them shut the owner out. +- **Access that outlives the password is visible.** Passkeys, personal access + tokens and external identities survive a password reset, and anyone holding + the password can add one. Each addition mails the owner; every password + change or reset mails the list of what still opens the account. A reset + removes the second factors, passkeys and external identities added in the + `RESET_REVOKES_FACTORS_ADDED_HOURS` (72) before it was asked for, and lists + them. A pending account taken back by a reset loses all of them. An + administrator can remove them all (`DELETE /admin/users/{id}/access-factors`, + or `revoke_access_factors` on a forced reset), never an administrator's last + second factor. +- **Lockout** after `LOCKOUT_THRESHOLD` consecutive wrong passwords within a + day. It locks the password only: passkeys, sign-in links, external + identities and personal access tokens still open the account. Any completed + sign-in, an administrator's unlock, a password reset and the end of a lock + restart the count, so guessing cannot keep an account locked for good. The + owner is mailed when a lock starts. Failed second factors never count: + whoever fails a second factor already holds the password, and letting them + lock the account would let them shut the owner out. +- **Recovery cannot be spent by others.** Reset and sign-in links coexist + until one is used, and their budget counts per client address before the + account's: someone asking again and again neither revokes the owner's link + nor spends the owner's share. Second-factor failure budgets count per + address too, with a ceiling three times higher for the account (30 TOTP + codes an hour); spending it mails the owner, and a reset restarts them. + Recovery codes are budgeted per hour. A resent e-mail code leaves the + previous one usable (both end when one is used), and a challenge resends at + most twice. A password change ends the challenges opened with the old one, + as a reset does. - **Brute force** is bounded per identifier and per address (database counters), - across identifiers from one address (HyperLogLog), and per submitted token. + across identifiers from one address (HyperLogLog), and per submitted token. A + password attempt is also reserved atomically in Redis, against the account + and the address, before the hash is computed, so a burst sent at once cannot + outrun the budget. With a CAPTCHA required, the identifier's budget no longer + refuses (every attempt costs a solved challenge, and guessing must not keep + the owner out); the lockout still bounds the guesses. An unknown identifier + costs the same database work as a wrong password. At most five second-factor + challenges stay open per account. Budgets are consumed atomically in Redis before the guarded check runs, so parallel requests cannot all pass; they fail closed when Redis is down. +- **Usernames reveal no address:** a registration on an address that already + has an account reserves its username (`username_reservations`) until the + link it would have sent expires, so asking for that username again, or + renaming to it, is refused exactly as if a new account held it. +- **Budgets are not turned against the owner:** a session that exhausted its + re-authentication budget stops counting against the account's; an + administrator's unlock, a password reset or a sign-in forgives earlier + failures of the identifier; the budgets of mailed links last as long as the + links, so the owner keeps a valid link from the requests that spent them. + The budgets guarding a link, a device code or an e-mail change target fail + closed when Redis cannot count them. + +- **Unverified accounts read as unknown:** a password sign-in to an account + whose address is not verified answers like a wrong password and mails a new + link: registering someone's address, then signing in with one's own + password, tells nothing of whether it had an account. +- **Challenges resist a Redis read:** sign-in challenges and e-mail change + flows are stored under their token's digest, and a sign-in e-mail code + completes only the challenge it was sent for. + ## Sessions and tokens - **New devices** are announced: a sign-in from a browser and operating system @@ -55,15 +128,34 @@ the database together, is out of scope. - **Access tokens** are ES256 JWTs (15 minutes) carrying `iss`, `aud`, `sid` and `jti`. Every authenticated request checks, in one Redis round trip, that the `jti` was not revoked by a logout and that the session is still active. A - Redis failure refuses the request: revocation cannot be proven. + Redis failure refuses the request: revocation cannot be proven. A revocation + writes an "ended" marker into the validity cache, which a check racing it + cannot overwrite with a stale "active". - **Refresh tokens** are opaque, stored as SHA-256 digests, and rotated on every - use. Presenting a rotated token again revokes the whole session family; - within 2 seconds of the rotation it is treated as a concurrent refresh from - the same client (two tabs) and refused without revocation. + use. Presenting a rotated token again revokes the whole session family and + ends the cached validity of its access tokens at once. Within 1 second of + the rotation, and only from the network and user agent that rotated it, it is + treated as a concurrent refresh (two tabs), refused without revocation and + counted (`auth_refresh_concurrent_total`); from anywhere else it is a replay. Refused refreshes (unknown token, another + client's session, a replay, another address) count against the address. - **Absolute lifetime.** A sign-in ends after `JWT_MAX_SESSION_LIFETIME_SECS` however often it is refreshed, and no rotation dates a session past that moment. `JWT_STRICT_SESSION_BINDING` refuses a refresh from another address. +- **Delegated tokens act within their grant.** A session issued to a client + other than the instance's own application, a session restricted to consented + scopes, and the session of a personal access token are delegated: their + tokens reach resource servers, `/oauth/userinfo` and logout, but the account + routes (`/users/me/*`), the approval routes (`/oauth/authorization-requests/*`, + `/oauth/device/*`) and the administration answer + `403 first_party_session_required`. Otherwise a delegated token could approve + on its own a flow of the instance's application and obtain an unrestricted + session. The kind is read from the session, cached with its validity. +- **Re-authentication resists a stolen session.** Every route that accepts the + current password shares the strict bucket of `/users/me/reauth`, and wrong + passwords are counted per session and per account (three times the session + budget): a stolen session guessing the password locks itself out, while the + owner's sessions still re-authenticate, revoke it and change the password. - **Sensitive actions require a recent re-authentication**: changing the password, username or email, deleting the account, revoking sessions, and adding or removing a second factor. A fresh sign-in does not count - a stolen @@ -76,14 +168,27 @@ the database together, is out of scope. - TOTP codes are accepted once (durable replay table), with per-challenge and per-account failure budgets. Confirming a new TOTP method consumes its code in the same table, under its own budget. Email codes have their own budgets; - recovery codes share one per-account budget between the sign-in challenge and - the authenticated route. + recovery codes have a per-challenge and per-account budget at sign-in, and a + per-session one on the authenticated route, so a stolen token cannot keep the + owner from signing in with a recovery code. - A second factor answers an account status exactly as the password sign-in does. - Adding or removing a method notifies the account's address. Removing the last method deletes the recovery codes; removing a primary method promotes another. - TOTP secrets are encrypted with AES-256-GCM. Ciphertexts name their key, so - the key can be rotated without downtime and the rotation can be resumed. + the key can be rotated without downtime and the rotation can be resumed, and + each is bound to its account (webhook secrets to their endpoint) as + associated data: a ciphertext copied onto another row does not decrypt. The + key identifier written in ciphertexts is derived by HKDF, not a hash that + would check a guessed key. Only + that format is read, and the service refuses to start while a secret names a + key it no longer holds or is in another format. +- Email codes (sign-in and email change) are stored as HMAC-SHA256 digests + under a key derived from `ENCRYPTION_KEY` (HKDF), bound to the flow and the + account: a copy of the database or of Redis does not give live codes away, + where a bare hash of six digits falls in milliseconds. Every secret is + compared in constant time, and every random value comes from the operating + system's generator. ## External identities @@ -96,7 +201,10 @@ the database together, is out of scope. party, expiry and nonce against the flow. - The callback's outcome is redeemed once, within two minutes, only with the binding secret the starting browser kept: a callback URL forwarded to a victim - cannot sign the victim in to the attacker's account. + cannot sign the victim in to the attacker's account. The callback writes + nothing: a link is made only when that browser completes the flow, so an + attacker sending a victim the provider URL of a link they started does not + get the victim's identity linked to their account. - The identity stands for the password: a second factor enrolled on the account is still required. @@ -121,18 +229,36 @@ the database together, is out of scope. compared in constant time. - **Authorization endpoint:** an unknown client or an unregistered redirect URI is answered directly, never redirected; the stored request lives ten minutes - and is decided once. Consenting to a third-party client requires a - re-authentication, checked before the request is used up. + and is decided once. Consenting to any client, the instance's own + application included, requires a re-authentication, checked before the + request is used up: an access token alone cannot mint a long-lived session. - **Device flow (RFC 8628):** user codes are reserved atomically, polling is paced, an approval is collected exactly once and only by the client that started the flow, and account status and session limits are rechecked when - tokens are issued. + tokens are issued. Approving any device, the instance's own application + included, requires a re-authentication: a code handed over by someone else + is how a device flow is phished. The approval screen shows the scopes asked + for, those the user lacks, and whether the device would act as the account. + Unknown codes are budgeted per address and per signed-in user. - **Authorization code with PKCE:** S256 only, exact redirect URIs (loopback on any port only for a registered path, never `localhost`), single-use codes - consumed atomically, a replayed code revokes its session. + consumed atomically, a replayed code revokes its session (whoever presents + it: a code seen in a log or a Referer is dead either way). The redemption + holds the code until its session is linked, so even a replay racing it finds + the session to revoke, when its own client presents it (a replay under + another client's id is logged and revokes nothing). The redirect URI is + checked against the client as registered at the approval and at the + redemption too, and the redirect carries `iss` (RFC 9207). A request belongs + to the first signed-in user who looks at it. `prompt=none`, request objects + and response modes other than `query` are refused; `max_age` and + `prompt=login` ask for the password again, and the ID token's `auth_time` is + when it was proved. A device's consent is intersected with what the user + holds at the approval. - **Scopes:** a request may narrow the client's registered scopes, never widen them; a client's tokens carry only the consented permissions, re-derived from - the user's current permissions on every refresh, and no roles. + the user's current permissions and the client's current scopes on every + refresh, and no roles. Access tokens are typed `at+jwt` and name their + client; the metadata lists only the scopes some client may ask for. - **Refresh:** a client's session is refreshed only by that client at the token endpoint, with its authentication; the first-party route refuses it. - **OpenID Connect:** identity scopes grant no permission and release only @@ -142,23 +268,86 @@ the database together, is out of scope. grant turned on obtains tokens for itself; they carry no user, so account routes refuse them, and they stop being active when the grant is turned off. - **Introspection and revocation:** only confidential clients introspect, and - an inactive token reveals nothing but `active: false`. A client revokes its + an inactive token reveals nothing but `active: false`. Only a resource + server (`allows_introspection`) introspects the access tokens of others, and + one registered with scopes learns only those; any other client introspects + its own tokens. A refresh token is introspected only by its own client, and + personal access tokens never are. Access tokens carry `sub_type` (`user` or + `client`) and `session_type`, which introspection reports too. + Failures of client authentication all read `client authentication failed`, + and a public client's request budget is split by address. A client revokes its own tokens only; any other token gets the same answer and is left alone. +- **Safe by default:** a third-party client never carries the account's roles, + and one without scopes exists only with `unrestricted: true`. Redirect URIs + are `https`, loopback `http` or a private-use scheme in reverse domain form. + Wrong secrets are budgeted per address and claimed client id, so one + address guessing one client does not shut the endpoints for its neighbours. + A code replayed under a public client's id revokes its session only with + the verifier. Refusals say `invalid_grant` alike whatever the account's + state. The instance's own application is counted in the device flow and, + for an account with a second factor, approved only from a session that + proved one. +- **Delegation is visible:** every consent and device approval is audited with + its client, scopes and the requesting device's address, and handing the + session over counts as a sign-in: in the owner's history, with the + new-device alert. + ## Administration -- `/admin` routes require an administrative permission in the token, a second - factor enrolled on the administrator's account, and the permission of the - action still granted in the database: revoking a role takes effect on the - next request, not when the token expires. +- `/admin` routes require a first-party token carrying an administrative + permission, from a session whose sign-in proved a second factor (TOTP, email + code, recovery code, or a passkey with user verification), and the + permission of the action still granted in the database: revoking a role + takes effect on the next request, not when the token expires. An enrolled + factor is not enough: a password or a sign-in link alone never opens it. +- An administrative role goes only to an active account that has a verified + second factor or a passkey, over HTTP and from the command line, checked + under the account's lock; such an account cannot remove its last factor. + Nobody grants a role to their own account, nor grants any role a + permission they do not hold themselves, and the default role never grants + administration. - Administrators cannot suspend, sign out, reset or delete their own account from `/admin`, and deleting an account needs their recent re-authentication. - Every change is audited on the account it changed, with the administrator's - id, so owners see it in their own history; changes to roles and clients are + id, so owners see it in their own history (their export leaves the + administrator's id and address out); changes to roles and clients are audited in the administrator's own history. -- Granting a role, changing what a role grants and saving a client need a - recent re-authentication. No change may leave the deployment without an - account holding `roles:manage`. +- Every change to roles (granting, withdrawing, creating, changing, deleting), + saving, deleting or re-keying a client, every change to a webhook (deleting + and redelivering included), and suspending, reactivating, unlocking or + deleting an account or forcing its reset need a recent re-authentication: a + stolen administrator token alone can neither push the other administrators + out, send account events elsewhere, reopen an account nor shut owners out. + The owner is mailed when an administrator suspends or reactivates the + account, changes its roles or signs it out; deleting a role records the + withdrawal in each holder's history. The command line audits its client + registrations and role grants in the transaction of the change. +- Every change is audited in the same transaction, and so are a rename, a + session revoked by its owner and a re-authentication; a webhook's audit keeps + the host it points to (never the path or query), a client's the settings + that changed (redirect hosts, scopes, primary, grants), and redeliveries are + audited. A failed webhook delivery records a fixed message, never the URL. + A reset forced by an administrator records no address in its link. +- No change may leave the deployment without an active account holding + `roles:manage`: not a change of roles, not suspending or deleting that + account, by an administrator or by its owner. These checks take a shared + lock, so two concurrent withdrawals cannot both pass. + +- Granting, withdrawing, emptying or deleting a role needs every permission it + grants, read under the role's lock: nobody delegates or strips what they do + not hold. The primary client, whose sessions are first-party, is designated + and changed from the command line only; command-line changes record the + operator and host. +- Removing an account's access factors revokes every session in the same + transaction; a forced reset asking for it is one change, refused whole. + +- A role gaining administration makes administrators of its holders: each must + be active with a second factor, and each is audited and told. A reset keeps + the last second factor of an account holding administration. +- Administrative reads are audited in the administrator's history, without + what was searched. The runtime role cannot delete outgoing events or webhook + deliveries, and executes the owner-privileged functions by name only. ## Webhooks @@ -168,7 +357,8 @@ the database together, is out of scope. timestamp and body); secrets are encrypted with the keyring and shown once. - Before each delivery the host is resolved and every address checked: loopback, private, link-local, shared, documentation, multicast and reserved ranges, - and IPv6 forms embedding them, are refused. The connection is pinned to the + the former 6to4 relay range, local-use NAT64, and IPv6 forms embedding them, + are refused. The connection is pinned to the checked address and redirects are not followed, so a DNS answer or a redirect cannot turn a webhook against the internal network. @@ -176,34 +366,68 @@ the database together, is out of scope. - Every token, code and refresh token is stored as a digest. - The audit log is append-only (enforced by a trigger) and holds no personal - data such as addresses in its metadata. Its client addresses keep only their - network after 90 days and are removed when the account is deleted, with its - sign-in attempts ([personal data](privacy.md)). Users read their own history - through `GET /users/me/audit`. + data such as addresses in its metadata (a replay records only whether the + two addresses share a network). Its client addresses keep only their network + after 90 days and are removed when the account is deleted, with its sign-in + attempts ([personal data](privacy.md)). Users read their own history through + `GET /users/me/audit`, and their export, where a change made by an + administrator names neither the administrator nor the address they acted + from. - An email change is confirmed on both addresses, revokes the other sessions, and notifies the previous address. -- Account deletion records `user.deleted` in the event outbox in the same - transaction as the deletion, so downstream erasure cannot be lost and is +- Account deletion, by the owner, an administrator or the purge of accounts + never verified, records `user.deleted` in the event outbox and in the webhook + deliveries in the same transaction as the deletion, so downstream erasure cannot be lost and is never announced for an account that still exists; the relay delivers it to JetStream, waiting for the broker when it is down. +- **Least privilege in the database.** The schema belongs to `auth_api_owner`, + which runs the migrations; the API connects as `auth_api`, limited to + reading and writing data (`deploy/db/auth-api-grants.sql`). The audit log is + append-only for it, the permission catalog and migration history read-only; + creating and dropping audit partitions, coarsening addresses, erasing an + account's traces and purging unverified accounts run in functions holding + the owner's privileges, which keep minimums only the owner can lower + (`maintenance_floors`: six months of audit partitions, 30 days before an + address is coarsened, a day before a pending account is purged), so the + runtime role cannot use them to erase the audit trail. Partitions created + later lose `UPDATE` and `DELETE` for the runtime role, partitions are created + two years ahead at most, and the function erasing an account's traces deletes + the account with them: it cannot rewrite the trail of an account that + stays. PostgreSQL logs slow + statements without their bound values. In production the database and Redis + URLs must carry a password; `deploy/db` holds the `pg_hba.conf` rules and the + Redis ACL they are installed from. ## Network edge - Client addresses come from forwarding headers only when the direct peer is a trusted proxy (`TRUSTED_PROXY_CIDRS`); IPv6 clients are limited per `/64`. - Rate limits: a sliding-window estimate per client, one script per request, - fail closed in production. + fail closed in production. Paths no route matches spend the general budget. - Request bodies are capped at 64 KB and handlers at 30 seconds. Responses carry HSTS, CSP `default-src 'none'`, `nosniff`, `DENY` framing and `no-store`. -- Logs record route templates, never raw paths carrying codes. Metrics are served - on a loopback-only listener. +- Logs record route templates, never raw paths carrying codes; metrics label + unmatched paths ``. Metrics and the readiness of each dependency + are served on an internal listener that requires `METRICS_TOKEN`; the public + `/ready` says only whether the instance is ready, and reuses its answer for + a second. nginx limits strictly the same requests as the API, by method and + path, which a test checks against the routers, and serves the discovery + documents and the administration's `PUT` routes; its access log masks the + codes carried by URLs. The production compose file hands the secrets to the + instances as files, out of `docker inspect` and the process environment. An + nftables rule lets only nginx and root reach the published ports, so no + other local process can forge `X-Forwarded-For`. The internal listener + refuses bodies and slow requests. The image is built from the committed + lockfile (`--locked`). ## Configuration Production refuses to start with a configuration that disables a control: HTTP public or frontend URLs, no trusted proxy, committed development keys, a wildcard CORS origin, fail-open rate limiting or CAPTCHA, and more. The full -list is in [Configuration](guides/configuration.md#production-checks). +list is in [Configuration](guides/configuration.md#production-checks). Any +variable can be read from a file (`X_FILE`), so an orchestrator's secrets stay +out of the process environment. ## Known limits @@ -213,7 +437,7 @@ list is in [Configuration](guides/configuration.md#production-checks). lifetime, not their entropy. - Outside production, rate limiting and CAPTCHA fail open by default. - Anyone holding both `ENCRYPTION_KEY` and a database dump can read TOTP secrets. -- A logout racing a refresh of the same session, more than 2 seconds after +- A logout racing a refresh of the same session, more than 1 second after its rotation, reads as a replay: the family is revoked and a replay audited. Kept on purpose, since the audit signal outweighs this rare race. - Webhook signing secrets, like TOTP secrets, are readable by anyone holding @@ -228,6 +452,20 @@ list is in [Configuration](guides/configuration.md#production-checks). anyone who knows the identifier; cutting the owner's live sessions would turn it into a way to sign them out. Suspending the account does end them. +- Someone holding the password can spend the account's second-factor budget + for an hour, from a few addresses: the owner cannot finish a TOTP or e-mail + code sign-in meanwhile, and is warned by e-mail. A passkey still signs in. +- A second factor by e-mail code is only as strong as the mailbox: whoever + controls it can reset the password and receive the code. A reset tells the + owner what still opens the account; TOTP or a passkey do not share this + limit. +- PostgreSQL and Redis speak without TLS: WireGuard encrypts and authenticates + their traffic, and both listen on the VPN address only (checked by + `scripts/infra-check.sh` and the database guide). +- nginx groups IPv6 clients by /64 for the address forms most clients use; + rare forms keep a key per address. The application's own limits group every + form. + ## Control catalog Each control names the tests that pin it; `tests/security/catalog.rs` fails @@ -245,7 +483,7 @@ when a cited test no longer exists. | SEC-08 | Absolute session lifetime and optional address binding | `session_lifetime_counts_from_the_first_sign_in`, `a_rotation_inherits_the_family_start`, `refresh_rejects_mismatched_ip_with_strict_binding`, `rotations_never_outlive_the_absolute_lifetime`, `a_rotated_session_is_never_dated_past_its_absolute_lifetime` | | SEC-09 | Sensitive actions need a recent re-authentication; signing in does not count | `signing_in_does_not_grant_sensitive_actions`, `revoke_session_requires_recent_reauth`, `delete_account_without_password_and_no_recent_reauth_rejected`, `a_device_session_cannot_change_the_password_without_reauthentication`, `enrolling_a_second_factor_requires_reauthentication` | | SEC-10 | A pre-auth token completes only the method it was issued for | `totp_challenge_cannot_be_completed_with_an_email_code`, `a_pre_auth_state_without_a_method_cannot_complete_with_a_recovery_code`, `seeds_and_regressions_hold` | -| SEC-11 | Second-factor codes are single-use and budgeted per challenge and per account | `totp_replay_within_window_rejected`, `totp_replay_rejected_even_after_redis_key_loss`, `concurrent_totp_guesses_never_exceed_the_token_budget`, `account_budget_blocks_fresh_pre_auth_tokens`, `recovery_challenge_rate_limited_after_max_failures`, `email_2fa_lockout_after_max_failures`, `recovery_login_replay_rejected`, `a_code_confirming_a_new_method_cannot_complete_a_sign_in`, `confirming_a_new_method_has_an_attempt_budget`, `recovery_code_guesses_share_one_budget_across_routes`, `a_challenge_owns_its_state_and_every_failure_budget` | +| SEC-11 | Second-factor codes are single-use and budgeted per challenge and per account | `totp_replay_within_window_rejected`, `totp_replay_rejected_even_after_redis_key_loss`, `concurrent_totp_guesses_never_exceed_the_token_budget`, `account_budget_blocks_fresh_pre_auth_tokens`, `recovery_challenge_rate_limited_after_max_failures`, `email_2fa_lockout_after_max_failures`, `recovery_login_replay_rejected`, `a_code_confirming_a_new_method_cannot_complete_a_sign_in`, `confirming_a_new_method_has_an_attempt_budget`, `a_challenge_owns_its_state_and_every_failure_budget` | | SEC-12 | Changes to second factors are notified and keep a usable configuration | `removing_the_last_method_drops_recovery_codes`, `removing_the_primary_method_promotes_the_remaining_one`, `disable_totp_sends_two_factor_disabled_email` | | SEC-13 | TOTP secrets are encrypted with named keys; rotation is resumable | `keyring_writes_versioned_ciphertexts_it_can_read`, `keyring_refuses_a_key_it_does_not_hold`, `encrypt_produces_different_output_each_call`, `rotate_is_idempotent_when_run_twice`, `rotate_re_encrypts_totp_secret_with_new_key` | | SEC-14 | Only registered clients obtain sessions through client flows | `a_flow_needs_a_registered_client` | @@ -267,7 +505,7 @@ when a cited test no longer exists. | SEC-30 | A sign-in from a new device is announced to the owner | `a_sign_in_from_a_new_device_alerts_the_owner`, `the_first_sign_in_and_a_browser_update_raise_no_alert`, `versions_do_not_make_a_new_device` | | SEC-31 | Administration needs the permission in the token and in the database, and a second factor | `an_account_without_administrative_permission_is_refused`, `an_administrator_without_a_second_factor_is_refused`, `a_permission_revoked_in_the_database_stops_working_before_the_token_expires`, `each_action_requires_its_own_permission`, `an_administrator_cannot_suspend_their_own_account_or_a_pending_one`, `deleting_an_account_needs_a_recent_reauthentication_and_announces_it`, `granting_a_role_needs_a_recent_reauthentication`, `nobody_can_remove_the_last_way_to_manage_roles_or_the_default_role` | | SEC-32 | The data export needs a recent re-authentication and holds no secret and no other account | `the_export_holds_the_account_its_history_and_no_secret`, `exporting_needs_a_recent_reauthentication` | -| SEC-33 | Sign-in links are single-use, short-lived, replaced by the next one, off by default, and never skip the second factor | `a_link_signs_in_once`, `a_new_link_replaces_the_previous_one_and_an_old_link_expires`, `a_second_factor_is_still_required`, `unknown_pending_and_suspended_addresses_answer_alike_and_get_nothing`, `links_are_capped_per_account_and_off_unless_enabled` | +| SEC-33 | Sign-in links are single-use, short-lived, end together when one is used, off by default, and never skip the second factor | `a_link_signs_in_once`, `links_coexist_until_one_is_used_and_an_old_link_expires`, `a_second_factor_is_still_required`, `unknown_pending_and_suspended_addresses_answer_alike_and_get_nothing`, `links_are_capped_per_account_and_off_unless_enabled` | | SEC-34 | Personal access tokens are stored as digests, shown once, scoped to permissions the account holds, and end with their session or account | `a_token_is_exchanged_for_access_tokens_carrying_its_scopes_only`, `a_revoked_token_and_its_access_tokens_stop_working`, `tokens_expire_and_follow_the_account_status`, `creation_is_checked`, `scopes_are_limited_to_the_permissions_held` | | SEC-35 | Webhooks are signed, never reach internal addresses or follow redirects, and deliver exactly the committed events | `a_subscribed_endpoint_receives_signed_events`, `internal_addresses_are_never_called`, `internal_addresses_are_refused`, `only_plain_https_urls_are_registered`, `endpoints_are_checked_updated_rotated_and_removed`, `validate_rejects_production_webhooks_to_http_or_internal_addresses` | | SEC-36 | Confidential clients authenticate at every token request, and client sessions are refreshed only by their client | `a_confidential_client_must_authenticate_with_its_secret`, `a_public_client_has_no_secret_to_present`, `a_device_code_works_for_its_client_only`, `tokens_carry_only_the_consented_scopes_even_after_refresh`, `basic_credentials_are_form_decoded`, `a_request_asks_for_a_subset_of_the_client_scopes` | @@ -275,4 +513,50 @@ when a cited test no longer exists. | SEC-38 | The client credentials grant is limited to confidential, scoped clients that enable it, and its tokens never act as a user | `a_client_obtains_a_token_for_itself_with_its_scopes`, `the_grant_is_reserved_to_confidential_clients_that_enable_it`, `a_client_token_is_introspected_and_revoked`, `a_client_subject_is_stable_and_never_a_user_id` | | SEC-39 | ID tokens are bound to their client, nonce and access token, and identity scopes release only their claims | `an_openid_request_gets_an_id_token_bound_to_its_nonce_and_access_token`, `userinfo_releases_the_claims_of_the_granted_scopes`, `scopes_release_their_claims_only` | | SEC-40 | Passkeys: registration re-authenticated and verified, sign-in challenges single use, signatures verified, cloned counters refused | `a_registration_is_verified_before_it_is_stored`, `forged_replayed_or_cloned_assertions_are_refused`, `a_passkey_signs_in_without_password_or_second_factor`, `a_removed_passkey_no_longer_signs_in`, `assertions_verify_against_the_stored_key_only`, `client_data_answers_the_challenge_from_an_allowed_origin`, `validate_rejects_production_passkey_origins_outside_the_relying_party` | -| SEC-41 | External identities sign in only once linked by the owner, bound to the starting browser, with verified ID tokens | `a_linked_identity_signs_in_and_an_unlinked_one_never_does`, `an_outcome_is_used_once_by_the_browser_that_started_it`, `an_id_token_that_does_not_verify_identifies_nobody`, `a_token_for_something_else_is_refused` | +| SEC-41 | External identities sign in only once linked by the owner, bound to the starting browser, with verified ID tokens | `a_linked_identity_signs_in_and_an_unlinked_one_never_does`, `an_outcome_is_used_once_by_the_browser_that_started_it`, `an_id_token_that_does_not_verify_identifies_nobody`, `a_token_for_something_else_is_refused`, `a_callback_alone_links_nothing` | +| SEC-42 | Delegated tokens (third-party clients, scoped sessions, personal access tokens) never act as the account: refused on account, approval and administration routes; approving another client's device needs a re-authentication | `delegated_tokens_are_refused_on_every_account_approval_and_admin_route`, `a_delegated_token_cannot_approve_itself_an_unrestricted_session`, `the_instance_application_without_scopes_acts_as_the_account`, `approving_another_client_needs_a_recent_reauthentication`, `only_sign_ins_and_the_primary_application_act_as_the_account` | +| SEC-43 | A verification link activates an account only with the password of the registration that sent it: whoever registers a pending address, first or second, cannot activate it with a password its owner did not choose | `an_attacker_registering_first_cannot_pick_the_owners_password`, `an_attacker_registering_second_cannot_slip_their_password_into_a_link`, `a_resend_never_carries_another_registrations_password`, `resent_links_coexist_until_one_verifies_the_account`, `email_verification_tokens_carry_complete_credentials_or_none` | +| SEC-44 | Ways into an account that outlive its password are announced: adding a passkey, a personal access token or an external identity mails the owner, a password change or reset lists what still opens the account, and a pending account taken back by a reset keeps none | `adding_a_passkey_or_a_token_is_announced_to_the_owner`, `a_reset_lists_what_still_opens_the_account`, `a_pending_account_taken_back_by_a_reset_keeps_no_other_access` | +| SEC-45 | The administration requires a session whose sign-in proved a second factor, not merely an enrolled one; an administrative role goes only to an active account with a second factor, never to oneself | `an_administrator_whose_sign_in_skipped_the_second_factor_is_refused`, `only_a_sign_in_with_a_second_factor_marks_its_session`, `an_administrative_role_goes_only_to_an_active_account_with_a_second_factor`, `an_administrator_never_grants_a_role_to_their_own_account`, `granting_a_role_assigns_it_once_and_audits_it` | +| SEC-46 | Administrative actions that redirect events or lock owners out need a recent re-authentication (webhooks, suspension, forced reset, client secrets), and every change is audited in its own transaction, redeliveries and webhook hosts included | `pointing_a_webhook_somewhere_needs_a_reauthentication_and_is_traced`, `a_redelivery_is_audited`, `suspending_or_forcing_a_reset_needs_a_recent_reauthentication` | +| SEC-47 | No change leaves the deployment without an active account able to manage roles: role changes, suspension and deletion (by an administrator or by the owner) are refused, and concurrent withdrawals are serialized | `nobody_can_remove_the_last_way_to_manage_roles_or_the_default_role`, `the_last_role_manager_is_neither_suspended_nor_deleted`, `concurrent_withdrawals_never_leave_nobody_managing_roles` | +| SEC-48 | The API connects with a role that reads and writes data only: it cannot alter the schema, truncate or rewrite the audit log, or change the permission catalog and migration history; maintenance needing more runs in owner-privileged functions | `the_runtime_role_cannot_erase_the_audit_trail_or_alter_the_schema`, `the_runtime_role_does_everything_the_service_needs` | +| SEC-49 | Secrets at rest resist a database copy: email codes are keyed digests, ciphertexts are bound to their row, secrets are compared in constant time, and a secret under a removed key stops the start-up | `otp_digests_are_keyed_bound_and_survive_a_rotation`, `a_ciphertext_moved_to_another_row_no_longer_decrypts`, `constant_time_equality_compares_contents_and_lengths`, `secrets_under_a_removed_key_are_detected`, `email_code_lookup_is_scoped_to_the_challenged_user`, `debug_output_never_shows_the_password_hash` | +| SEC-50 | Settings that would weaken a control are refused at start-up (TOTP skew beyond the replay window, Argon2 under the OWASP floor in production, lifetimes and windows out of range, a zero rate limit), and weaker stored hashes are replaced as accounts sign in | `validate_bounds_the_totp_skew_to_what_the_replay_table_covers`, `validate_refuses_weak_argon2_parameters_in_production_only`, `validate_bounds_lifetimes_windows_and_limits`, `a_hash_weaker_than_the_configuration_is_rehashed`, `a_weaker_password_hash_is_replaced_after_sign_in` | +| SEC-51 | Password guesses with a stolen token are bounded without locking the owner out: every route taking the current password is strict, re-authentication failures count per session, no authenticated route burns recovery codes, and client endpoints are bounded per client rather than per address | `routes_taking_the_current_password_count_against_the_strict_bucket`, `a_stolen_session_guessing_the_password_does_not_lock_the_owner_out`, `recovery_codes_are_only_spent_signing_in`, `client_endpoints_are_bounded_per_client_and_per_wrong_secret` | +| SEC-52 | Revocations take effect at once: a revoked family loses its cached validity, a racing check cannot restore it, a pre-auth token is consumed before its session is issued, refused refreshes are counted, and token responses forbid every cache | `a_revoked_family_loses_its_cached_validity_at_once`, `replayed_refresh_tokens_count_against_the_address`, `token_responses_forbid_every_cache`, `a_cached_session_reads_back_as_it_was_stored` | +| SEC-53 | Checks and the actions they guard cannot be raced apart: a replayed authorization code waits for its redemption and revokes what it produced, cooldowns are claimed before acting, and the token quota is counted under a lock | `a_code_redeemed_twice_at_once_leaves_no_session_alive`, `concurrent_recovery_code_regenerations_run_once`, `concurrent_creations_respect_the_token_limit` | +| SEC-54 | Email flows reveal nothing and flood no one: registration is padded and budgets its notices, an email change to a taken address answers like any other, new-address codes are budgeted, earlier links die with the password or address, and CAPTCHA tokens are bound to the site | `registration_takes_a_constant_minimum_time`, `registering_a_taken_address_repeatedly_notifies_its_owner_a_few_times`, `email_change_submit_taken_email_answers_like_a_free_one`, `email_change_submissions_are_budgeted`, `a_password_change_ends_the_links_already_mailed`, `a_token_solved_on_another_site_is_refused` | +| SEC-55 | Identifiers are unambiguous and not over-collected: usernames are unique whatever their case, and a failed sign-in records the identifier only when it is an address or a username | `usernames_differing_only_in_case_cannot_coexist`, `an_unrecognized_identifier_is_not_recorded`, `only_addresses_and_usernames_are_recorded` | +| SEC-56 | The public surface discloses no operational detail: requests no route matches spend the general budget and share one metric label, the public readiness probe names no dependency, and an account's export names no administrator nor the address they acted from | `unknown_paths_spend_the_general_budget`, `metrics_recorder_renders_business_counters_and_folds_unmatched_paths`, `public_readiness_says_ready_without_naming_dependencies`, `the_export_names_no_administrator_nor_their_address` | +| SEC-57 | Secrets can stay out of the process environment: each variable can be read from the file named by `X_FILE`, and a variable set both ways refuses to start | `a_variable_can_come_from_a_file`, `a_variable_and_its_file_together_are_refused`, `an_unreadable_secret_file_stops_the_start` | +| SEC-58 | Guessing a password cannot keep its owner out: the lock is bounded in time, restarted by any sign-in, a reset or its own end, limited to the password, announced to the owner, and recovery links and second-factor budgets are counted per address | `a_locked_password_answers_like_a_wrong_one_and_tells_the_owner`, `a_lock_does_not_outlive_itself`, `any_completed_sign_in_restarts_the_count`, `old_failures_do_not_add_up_with_new_ones`, `the_other_ways_in_stay_open_while_the_password_is_locked`, `a_reset_lifts_the_lock`, `someone_asking_for_links_neither_spends_nor_revokes_the_owners`, `guessing_codes_from_one_address_does_not_block_the_owner`, `signing_in_again_within_the_email_code_cooldown_still_challenges`, `a_second_factor_sign_in_without_redis_is_unavailable_not_broken` | +| SEC-59 | No administrator grants themselves permissions, pushes the others out or acts unnoticed: held roles cannot gain what their holder lacks, the default role never administers, withdrawals and destructive actions need a re-authentication, owners are told, and administrators keep a second factor | `nobody_adds_to_a_role_they_hold_a_permission_they_lack`, `the_default_role_never_grants_administration`, `actions_that_push_out_or_reopen_need_a_recent_reauthentication`, `an_administrator_cannot_unlock_their_own_account`, `the_owner_hears_of_what_an_administrator_changed`, `deleting_a_role_leaves_a_trace_in_each_holders_history`, `an_administrator_keeps_a_second_factor` | +| SEC-60 | The owner's view of their data is complete and names no administrator, the audit metadata holds no address, and every deletion reaches the webhooks | `the_history_names_no_administrator_nor_their_address`, `the_export_names_no_administrator_nor_their_address`, `a_replay_is_audited_without_addresses_in_its_metadata`, `addresses_compare_by_network`, `purging_a_never_verified_account_reaches_the_webhooks`, `the_export_holds_every_way_in_and_where_links_were_asked_from` | +| SEC-61 | A rotated refresh token is forgiven only to the client that rotated it, and registrations are budgeted per address | `a_rotated_token_reused_from_another_client_revokes_the_family`, `concurrent_refreshes_keep_the_family_alive`, `registrations_from_one_address_are_budgeted` | +| SEC-62 | Delegation stays visible and current: every device approval needs a re-authentication and shows its scope, codes and tokens follow the client as registered now, introspection reveals no refresh token of another client nor any personal token, and a public client's budget cannot be spent from a few addresses | `the_device_approval_screen_shows_what_it_grants`, `approving_another_client_needs_a_recent_reauthentication`, `a_redirect_removed_from_the_client_receives_nothing`, `a_scope_taken_from_the_client_leaves_its_sessions`, `a_client_access_token_is_typed_and_names_its_client`, `introspection_reveals_no_personal_or_foreign_refresh_token`, `a_public_clients_budget_is_split_by_address`, `a_registered_redirect_keeps_its_query` | +| SEC-63 | The runtime role cannot turn the maintenance functions against the data, secrets are read only bound to their row, and production connections need a password | `the_maintenance_functions_keep_the_owners_floors`, `the_runtime_role_cannot_erase_the_audit_trail_or_alter_the_schema`, `keyring_reads_the_previous_key_and_refuses_unbound_formats`, `secrets_in_an_unbound_format_stop_the_start`, `a_blank_variable_defers_to_its_file`, `validate_rejects_production_connections_without_a_password` | +| SEC-64 | Settings that would undo a control stop the start: a lock under a minute, a device code living hours, a trusted proxy network any peer belongs to, an HTTP verification page, a blank required secret | `validate_rejects_settings_that_undo_their_control`, `a_blank_required_variable_is_missing` | +| SEC-65 | The edge matches the API: nginx limits strictly what the API does, the internal listener needs its token, and the public readiness probe costs the dependencies at most one check per second | `nginx_limits_strictly_what_the_api_does`, `the_internal_listener_needs_its_token`, `public_readiness_says_ready_without_naming_dependencies` | +| SEC-66 | No new session without the password: every OAuth consent needs a re-authentication, the instance's own application included, and so does signing an account out from the administration | `a_primary_client_signs_in_end_to_end`, `approving_another_client_needs_a_recent_reauthentication`, `actions_that_push_out_or_reopen_need_a_recent_reauthentication` | +| SEC-67 | Password guesses cannot outrun their budget nor keep the owner out: attempts are reserved atomically before the hash, the CAPTCHA replaces the identifier budget, and challenges are capped per account | `a_burst_of_guesses_cannot_outrun_the_budget`, `the_captcha_replaces_the_identifier_budget`, `open_challenges_are_capped_per_account` | +| SEC-68 | Someone holding the password cannot search the second factor nor wear out the owner: tight account budgets that mail the owner, resends that neither flood nor kill the owner's code, challenges ended by a password change, and regeneration refused without Redis | `a_spent_second_factor_budget_warns_the_owner`, `a_resend_keeps_the_previous_code_and_is_budgeted`, `a_new_code_keeps_only_the_previous_one`, `a_password_change_ends_open_challenges`, `a_strict_cooldown_fails_closed` | +| SEC-69 | Ways in planted by someone who held the password do not survive its recovery: a reset removes those added just before it, and an administrator can remove them all | `a_reset_removes_the_ways_in_added_just_before_it`, `an_administrator_removes_the_ways_in_of_a_compromised_account` | +| SEC-70 | Administration delegates only what it holds and leaves traces of the change, not of the administrator nor of endpoint secrets; strangers show in exports by network only | `nobody_grants_a_permission_they_lack`, `administrative_traces_describe_the_change_not_the_administrator`, `a_failed_delivery_never_records_the_endpoint_url`, `the_export_shows_only_the_network_of_strangers` | +| SEC-71 | The database and stored secrets resist a compromised service: new audit partitions stay append-only for it, trace erasure only goes with the account, key ids check no key, and planted hashes cannot exhaust memory | `the_audit_trail_stays_out_of_the_runtime_roles_reach`, `the_key_id_is_not_a_hash_of_the_key`, `a_hash_costing_far_more_than_configured_is_refused` | +| SEC-72 | OAuth honours what it claims: only resource servers introspect others' tokens, tokens say who and when, OIDC parameters are refused or honoured, requests belong to their viewer, foreign replays revoke nothing, device consents are frozen | `only_resource_servers_introspect_the_tokens_of_others`, `unsupported_oidc_parameters_are_refused_and_max_age_is_honoured`, `a_request_is_decided_by_its_viewer_and_a_foreign_replay_revokes_nothing`, `tokens_say_when_and_who`, `a_device_consent_is_frozen_at_the_approval` | +| SEC-73 | Production refuses settings past their ceiling and proxy networks wider than a /24, and the internal listener refuses large bodies | `validate_rejects_production_settings_past_their_ceiling`, `validate_rejects_settings_that_undo_their_control`, `the_internal_listener_refuses_large_bodies` | +| SEC-74 | Granting, withdrawing, emptying or deleting a role needs every permission it grants, checked under the role's lock | `nobody_grants_a_permission_they_lack`, `nobody_grants_or_withdraws_a_role_holding_more_than_they_have` | +| SEC-75 | Removing an account's ways in revokes every session in the same transaction, a refused forced reset revokes nothing, the primary client is the command line's, and command-line changes name their operator | `an_administrator_removes_the_ways_in_of_a_compromised_account`, `a_refused_forced_reset_revokes_nothing`, `invalid_client_settings_are_refused`, `a_command_line_change_names_its_operator_and_host` | +| SEC-76 | Budgets cannot be turned against the owner: a locked session stops filling the account's re-authentication budget, an unlock or reset forgives earlier failures, e-mail change codes are budgeted per account, and budgets guarding a token or code fail closed | `a_stolen_session_guessing_the_password_does_not_lock_the_owner_out`, `failures_before_an_unlock_no_longer_count_against_the_identifier`, `email_change_codes_are_budgeted_across_flows`, `email_verification_waits_for_redis`, `password_reset_submit_waits_for_redis` | +| SEC-77 | Whether a username is taken reveals nothing of an address: a registration on a registered address reserves its username until its link would expire, for registrations and renames alike | `a_username_answers_the_same_whether_its_address_was_registered`, `a_taken_username_is_reported_with_its_code` | +| SEC-78 | Delegation is visible to the owner: every consent and device approval is audited with its client, scopes and requesting device, and handing a session over counts as a sign-in (history, new-device alert, `auth_time` of the proof) | `a_delegated_session_leaves_a_trace_in_the_owners_history`, `tokens_say_when_and_who` | +| SEC-79 | Clients are safe by default: no scopes only on purpose and never the roles for a third party, only safe redirect schemes, wrong secrets budgeted per address and client, leaked public codes revoke nothing without their verifier, account states undisclosed, IdP metadata of its own issuer only, no OIDC scopes without a user, requests claimed atomically, and the primary device flow counted and behind a second factor | `invalid_client_settings_are_refused`, `only_safe_redirect_schemes_are_registered`, `third_parties_carry_no_roles_and_the_primary_device_flow_needs_a_second_factor`, `a_leaked_code_replayed_without_its_verifier_revokes_nothing`, `a_client_obtains_a_token_for_itself_with_its_scopes`, `metadata_of_another_issuer_is_not_followed`, `account_states_read_the_same_to_a_client` | +| SEC-80 | A consumed TOTP code stays refused for every step it could be accepted in plus a margin, on the application's clock, and no account route burns recovery codes | `a_consumed_totp_code_stays_refused_for_its_whole_window`, `recovery_codes_are_only_spent_signing_in` | +| SEC-81 | Configuration mistakes fail at startup: a production key that is text, CORS entries that are not origins, two JWT keys sharing a key id; stored hashes cannot cost more than twice the configuration | `validate_rejects_a_production_key_that_is_text`, `validate_rejects_cors_entries_that_are_not_origins`, `a_kid_is_sixteen_lowercase_hex_digits_stable_per_key`, `a_hash_costing_far_more_than_configured_is_refused` | +| SEC-82 | Every invariant holds on every route: a role gains administration only for holders with a second factor, audited and told; the primary client is changed from the command line only, removal and secrets included; a reset keeps an administrator's last second factor; the primary client needs a second-factor session in both flows; changing the password ends a lockout | `a_role_grants_administration_only_to_holders_with_a_second_factor`, `the_primary_client_is_neither_removed_nor_rekeyed_over_http`, `a_reset_keeps_the_last_second_factor_of_an_administrator`, `the_primary_client_needs_a_second_factor_session_in_the_code_flow`, `changing_the_password_ends_a_lockout` | +| SEC-83 | Registration reveals nothing: an unverified account answers a sign-in like a wrong password and gets a new link, verification links are budgeted per address first, a registered address holds one username reservation at a time | `login_unverified_email`, `an_address_holds_one_username_reservation_at_a_time` | +| SEC-84 | Sign-in state resists a Redis read and a shared mailbox: challenges and e-mail change flows are keyed by digest, a sign-in code completes its own challenge only, setup codes go out only during a setup, a lockout is applied and announced once, and no route hashes a password longer than 256 bytes | `a_sign_in_code_belongs_to_its_challenge`, `a_password_longer_than_any_accepted_is_wrong` | +| SEC-85 | Data leaves traces, not identities: administrative reads are audited without their search, the owner never sees an operator or host, strangers' user agents are reduced to their family, and outgoing events and deliveries are deleted only by owner functions after a floor | `administrative_reads_leave_a_trace`, `the_export_shows_only_the_network_of_strangers`, `the_runtime_role_cannot_erase_the_audit_trail_or_alter_the_schema`, `endpoints_are_checked_updated_rotated_and_removed` | +| SEC-86 | OAuth details hold: IdP endpoints never over a weaker transport than the issuer, redirect URIs without fragment or credentials, client credentials tokens read against the client's current scopes, a refresh narrows but never widens, and device refusals are traced | `endpoints_over_another_transport_are_not_followed`, `invalid_client_settings_are_refused`, `a_client_token_is_introspected_and_revoked`, `a_refresh_narrows_its_scope_but_never_widens_it`, `a_device_refusal_is_traced` | +| SEC-87 | Configuration and deployment tighten: trusted proxies named one address at a time, IPv4-mapped peers compared as IPv4, recovery codes bounded, weakened settings logged, AAD-less encryption out of the API, owner-privileged functions granted by name | `validate_rejects_production_settings_past_their_ceiling`, `an_ipv4_mapped_proxy_is_trusted_as_its_ipv4_address`, `production_bounds_recovery_codes_and_names_weakened_settings`, `owner_privileged_functions_are_granted_by_name` | diff --git a/docs/dev/threat-model.md b/docs/dev/threat-model.md index 4596696..c805c4a 100644 --- a/docs/dev/threat-model.md +++ b/docs/dev/threat-model.md @@ -50,16 +50,17 @@ at least once a year. | Threat | Mitigation | Residual | |--------|------------|----------| | Credential stuffing and password guessing | Per-address and per-identifier budgets, lockout, backoff, CAPTCHA, breached password refusal (SEC-03, SEC-04, SEC-23, SEC-29) | A slow, distributed attack below every budget; watch `auth_logins_total{outcome="invalid_credentials"}` | -| Account enumeration | Identical answers and timing for unknown and known identifiers (SEC-02) | Timing measured on one machine; network jitter helps, co-located attackers are out of scope | +| Account enumeration | Identical answers and timing for unknown and known identifiers (SEC-02) | Timing measured on one machine; network jitter helps, co-located attackers are out of scope. Usernames are public by design: registration and profile changes say when one is taken (accepted risk, section 5) | | Stolen access token | 15-minute lifetime, revocation checked per request, session binding option (SEC-05, SEC-08) | Resource servers verifying offline accept it until expiry unless they introspect | -| Stolen refresh token | Rotation with replay detection revoking the family (SEC-07) | The thief wins if they refresh first and the owner never does again | +| Stolen refresh token | Rotation with replay detection revoking the family; the grace window forgives only the client that rotated (SEC-07, SEC-61) | The thief wins if they refresh first and the owner never does again | | Forged tokens | ES256 only, `kid` pinned to its key, issuer and audience checked (SEC-05) | Theft of `JWT_PRIVATE_KEY`: rotate the key (runbook section 1) | | Second factor bypass | Pre-auth token bound to its method, single-use codes, budgets (SEC-10, SEC-11) | Email codes are as strong as the mailbox | | Phishing of sign-in links | Off by default, short-lived, single-use, never skip the second factor (SEC-33) | Enabled, the mailbox is a first factor | | Passkey cloning or forged assertions | Signature, origin, relying party, user verification, counters (SEC-40) | Attestation not verified: an authenticator's make is not trusted nor checked | | Login CSRF with an external identity | Browser binding secret on the outcome, `state`, `nonce` (SEC-41) | A compromised identity provider signs in whoever it vouches for, for linked accounts | +| Account pre-hijacking (registering the victim's address first or second) | A verification link activates the account only with the password of the registration that sent it; a reset takes a pending account back without its factors (SEC-43, SEC-44) | - | | Account takeover through a provider's email | Identities never matched by email; linking needs the signed-in owner (SEC-41) | - | -| Malicious OAuth client | Registered clients only, exact redirects, PKCE S256, consent re-authentication, scopes (SEC-14 to SEC-17, SEC-36) | A user consenting to a malicious registered client | +| Malicious OAuth client | Registered clients only, exact redirects checked again at approval and redemption, PKCE S256, consent and device approval re-authentication with the scope shown, scopes following the client's registration (SEC-14 to SEC-17, SEC-36, SEC-62) | A user consenting to a malicious registered client | | Client impersonation at the token endpoint | Confidential client secrets, client-bound refresh and device codes (SEC-36) | Public clients rely on PKCE and redirect registration | ### Tampering @@ -97,7 +98,7 @@ at least once a year. | Redis outage | Fail closed on budgets and revocation checks, `503` (SEC-04, SEC-23) | Sign-in unavailable while Redis is | | Broker outage | Events wait in the outbox; nothing is refused (SEC-21) | Consumers learn late | | Mailbox flooding | Per-account and per-address budgets on every email (SEC-02, SEC-33) | - | -| Lockout of a victim by guessing | Lockout ends on its own; sessions are not cut; administrators unlock (SEC-03, SEC-31) | Anyone knowing the identifier can delay a password sign-in; passkeys and external identities still work | +| Lockout of a victim by guessing | The lock covers the password only, ends on its own, is restarted by any sign-in or a reset, and is mailed to the owner; recovery links and second-factor budgets count per address (SEC-03, SEC-58) | Anyone knowing the identifier can delay a password sign-in for a lock period; passkeys, sign-in links and external identities still work | | Webhook endpoint slowing deliveries | Timeouts, leases, bounded attempts | A slow endpoint delays its own deliveries | ### Elevation of privilege @@ -106,9 +107,9 @@ at least once a year. |--------|------------|----------| | Session escalation to sensitive actions | Recent re-authentication required; sign-in does not grant it (SEC-09) | - | | Scope widening by a client | Scopes frozen at consent, re-derived at refresh (SEC-17) | - | -| Administrator account compromise | Second factor required, permission rechecked in the database, re-authentication for role grants, last administrator kept (SEC-31) | A compromised administrator with a second factor acts as one | +| Administrator account compromise | Second factor required and kept, permission rechecked in the database, re-authentication for every action that grants, withdraws, reopens or destroys, no self-granted permission, owners mailed of changes, last administrator kept (SEC-31, SEC-59) | A compromised administrator who can re-authenticate acts as one | | Client credentials used as a user | No session: account routes refuse them (SEC-38) | - | -| Personal access token overreach | Scopes limited to the holder's permissions, no sensitive action without the password (SEC-34, SEC-09) | Non-sensitive account routes accept its tokens | +| Personal access token overreach | Scopes limited to the holder's permissions; account, approval and administration routes refuse delegated tokens (SEC-34, SEC-42) | - | ## 4. Supply chain and operations @@ -130,6 +131,26 @@ at least once a year. magic links are enabled, and as its identity provider when one is linked. - Passkey attestation is not verified. - Email one-time codes have 6 digits; their budgets and lifetime make them hold. +- Traffic to PostgreSQL and Redis is protected by the WireGuard tunnel, not by + TLS; `hostssl` with `sslmode=verify-full` is documented for deployments + where the tunnel is not the trust boundary. Monitoring ports answer the + monitoring host only (firewall rules of the monitoring guide). +- A username is an identifier others can learn exists: choosing one that is + taken answers `username_taken`, whatever the case. Email addresses, the + identifier that reaches a person, are never confirmed this way (kept after + the third audit: usernames are public in this product), and a registration + on a taken address reserves the username it asked for, so the answer never + tells whether that address is registered. Budgets on + registration (`REGISTRATIONS_PER_IP_PER_HOUR`) bound how fast usernames can + be tried or squatted, and a pending account frees its username after + `CLEANUP_UNVERIFIED_ACCOUNT_DAYS` (2 by default); a deployment that treats + usernames as secret should let users sign in by email only. + +- Whoever holds the password can spend an account's second-factor budget for + an hour; the owner is warned by e-mail and a passkey still signs in (kept: + refusing the attempts is what keeps the code space from being searched). +- A second factor by e-mail code falls with the mailbox: whoever controls it + can reset the password and receive the code. TOTP and passkeys do not. ## 6. Verification diff --git a/migrations/0002_users.sql b/migrations/0002_users.sql index e6aab55..d66145c 100644 --- a/migrations/0002_users.sql +++ b/migrations/0002_users.sql @@ -14,13 +14,16 @@ CREATE TABLE users ( email_verified_at TIMESTAMPTZ, last_login_at TIMESTAMPTZ, locked_until TIMESTAMPTZ, + -- An administrator unlocking an account also forgives the failed sign-ins + -- that locked it: consecutive failures count from the later of the last + -- success and this date. + lockout_cleared_at TIMESTAMPTZ, status user_status NOT NULL DEFAULT 'pending_verification', preferred_locale VARCHAR(10) NOT NULL DEFAULT 'en', username VARCHAR(50) NOT NULL, email CITEXT NOT NULL, password_hash TEXT NOT NULL, - CONSTRAINT users_username_key UNIQUE (username), CONSTRAINT users_email_key UNIQUE (email), CONSTRAINT users_username_format CHECK (username ~ '^[a-zA-Z0-9_]{3,50}$'), CONSTRAINT users_locale_format CHECK (preferred_locale ~ '^[a-z]{2}(_[A-Z]{2})?$'), @@ -36,5 +39,18 @@ CREATE TRIGGER users_set_updated_at BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION set_updated_at(); +-- Usernames are unique whatever their case: `Alice` and `alice` never coexist, +-- one impersonating the other. The same index serves the sign-in lookup and, +-- through text_pattern_ops, administrators searching by the start of a name. +CREATE UNIQUE INDEX users_username_lower_key ON users ((lower(username::text)) text_pattern_ops); + +-- Administrators search accounts by the start of an address. +CREATE INDEX idx_users_email_prefix ON users ((lower(email::text)) text_pattern_ops); +-- Listing pages newest first. +CREATE INDEX idx_users_created ON users (created_at DESC, id DESC); +-- The purge of never-verified accounts reads them by age. +CREATE INDEX idx_users_pending_created + ON users (created_at) WHERE status = 'pending_verification'; + -- Only locked accounts are looked up by lockout expiry. CREATE INDEX idx_users_locked_until ON users (locked_until) WHERE locked_until IS NOT NULL; diff --git a/migrations/0003_rbac.sql b/migrations/0003_rbac.sql index 3dfa8e6..05da93f 100644 --- a/migrations/0003_rbac.sql +++ b/migrations/0003_rbac.sql @@ -1,6 +1,7 @@ -- Role-based access control: roles, the permission catalog, the permissions --- each role grants, the roles each user holds, and the default role granted at --- registration. +-- each role grants, the roles each user holds, the default role granted at +-- registration, and the `admin` role granting every administrative permission +-- the /admin routes require. CREATE TABLE roles ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), @@ -53,3 +54,19 @@ CREATE INDEX idx_user_roles_granted_by ON user_roles (granted_by) WHERE granted_ INSERT INTO roles (name, description, is_default) VALUES ('user', 'Default role assigned on registration', TRUE); + +INSERT INTO permissions (resource, action, description) VALUES + ('users', 'read', 'Search accounts and read their details'), + ('users', 'manage', 'Suspend, reactivate, unlock, sign out, reset and delete accounts'), + ('roles', 'manage', 'Create and delete roles, set their permissions, assign them to accounts'), + ('clients', 'manage', 'Register, update and remove client applications'), + ('audit', 'read', 'Read the audit log of every account'), + ('webhooks', 'manage', 'Manage webhook subscriptions and their deliveries'); + +INSERT INTO roles (name, description, is_default) VALUES + ('admin', 'Administrators: every administrative permission', FALSE); + +INSERT INTO role_permissions (role_id, permission_id) +SELECT roles.id, permissions.id +FROM roles CROSS JOIN permissions +WHERE roles.name = 'admin'; diff --git a/migrations/0004_registered_clients.sql b/migrations/0004_registered_clients.sql index 6fd6cf2..9bdfb68 100644 --- a/migrations/0004_registered_clients.sql +++ b/migrations/0004_registered_clients.sql @@ -10,7 +10,16 @@ -- - allows_loopback_redirect: a loopback redirect on any port is accepted for -- a registered path (RFC 8252 section 7.3); -- - default_max_sessions: concurrent device sessions per user for a --- non-primary client without a user_client_quotas row. +-- non-primary client without a user_client_quotas row; +-- - client_secret_hash: a confidential client authenticates at the token +-- endpoint (client_secret_basic or client_secret_post). The secret is 256 +-- random bits, so its SHA-256 digest is stored, not a slow hash; +-- - allows_client_credentials: the client credentials grant, where a +-- confidential client obtains tokens for itself, carrying its registered +-- scopes and no user; +-- - allows_introspection: a resource server, which may introspect the +-- access tokens of other clients and of first-party sessions (any other +-- confidential client introspects its own tokens only). -- user_client_quotas: per-user override of a client's session limit. CREATE TABLE registered_clients ( client_id VARCHAR(100) PRIMARY KEY, @@ -20,10 +29,19 @@ CREATE TABLE registered_clients ( redirect_uris TEXT[] NOT NULL DEFAULT '{}', allows_loopback_redirect BOOLEAN NOT NULL DEFAULT FALSE, default_max_sessions SMALLINT NOT NULL DEFAULT 5, + client_secret_hash BYTEA, + allows_client_credentials BOOLEAN NOT NULL DEFAULT FALSE, + allows_introspection BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT registered_clients_client_id_format CHECK (client_id ~ '^[A-Za-z0-9._-]{1,100}$'), - CONSTRAINT registered_clients_default_max_sessions_positive CHECK (default_max_sessions > 0) + CONSTRAINT registered_clients_default_max_sessions_positive CHECK (default_max_sessions > 0), + CONSTRAINT registered_clients_secret_hash_length + CHECK (client_secret_hash IS NULL OR octet_length(client_secret_hash) = 32), + CONSTRAINT registered_clients_client_credentials_confidential + CHECK (NOT allows_client_credentials OR client_secret_hash IS NOT NULL), + CONSTRAINT registered_clients_client_credentials_scoped + CHECK (NOT allows_client_credentials OR cardinality(scopes) > 0) ); -- At most one primary client. diff --git a/migrations/0005_sessions.sql b/migrations/0005_sessions.sql index 30f9aa4..4850e95 100644 --- a/migrations/0005_sessions.sql +++ b/migrations/0005_sessions.sql @@ -1,7 +1,9 @@ -- Sessions: one row per refresh token. A refresh rotates the session into a new -- row of the same family; replaying a rotated token revokes the whole family. -- token_hash is the SHA-256 of the refresh token, never the token itself. -CREATE TYPE session_type AS ENUM ('web', 'device'); +-- A personal access token owns a session of type `personal_access_token`, so +-- every revocation path ends it the same way. +CREATE TYPE session_type AS ENUM ('web', 'device', 'personal_access_token'); CREATE TYPE session_compromise_reason AS ENUM ( 'refresh_token_reuse', @@ -34,6 +36,14 @@ CREATE TABLE sessions ( user_agent TEXT, device_name VARCHAR(100), remember_me BOOLEAN NOT NULL DEFAULT FALSE, + -- Whether the sign-in that started the family proved a second factor (TOTP, + -- email code, recovery code, or a passkey with user verification). The + -- administration requires it of the session itself; rotations inherit it. + mfa BOOLEAN NOT NULL DEFAULT FALSE, + -- When the user last proved their password for the consent this session + -- came from (OpenID Connect `auth_time`); rotations inherit it. NULL for + -- sessions that did not come from a consent. + auth_time TIMESTAMPTZ, CONSTRAINT sessions_token_hash_key UNIQUE (token_hash), CONSTRAINT sessions_expires_after_creation CHECK (expires_at > created_at), @@ -74,6 +84,9 @@ CREATE UNIQUE INDEX idx_sessions_replaced_by -- Retention deletes by expiry and by revocation, whatever the row's state. CREATE INDEX idx_sessions_expires_at ON sessions (expires_at); CREATE INDEX idx_sessions_revoked_at ON sessions (revoked_at) WHERE revoked_at IS NOT NULL; +-- Sessions of a client application, revoked when the client is removed. +CREATE INDEX idx_sessions_client_active ON sessions (client_id) + WHERE client_id IS NOT NULL AND revoked_at IS NULL; CREATE OR REPLACE FUNCTION revoke_session_family( p_session_id UUID, diff --git a/migrations/0006_authorization_codes.sql b/migrations/0006_authorization_codes.sql index ce5990e..b974668 100644 --- a/migrations/0006_authorization_codes.sql +++ b/migrations/0006_authorization_codes.sql @@ -15,6 +15,10 @@ CREATE TABLE authorization_codes ( scopes TEXT[], expires_at TIMESTAMPTZ NOT NULL, consumed_at TIMESTAMPTZ, + -- OpenID Connect: the nonce of the authentication request, into the ID token. + nonce TEXT, + -- When the approving user last proved their password (`auth_time`). + auth_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- Session issued from the code, revoked if the code is ever replayed. session_id UUID REFERENCES sessions (id) ON DELETE SET NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), @@ -22,7 +26,8 @@ CREATE TABLE authorization_codes ( CONSTRAINT authorization_codes_code_hash_key UNIQUE (code_hash), CONSTRAINT authorization_codes_code_hash_length CHECK (octet_length(code_hash) = 32), CONSTRAINT authorization_codes_method_supported CHECK (code_challenge_method = 'S256'), - CONSTRAINT authorization_codes_challenge_format CHECK (code_challenge ~ '^[A-Za-z0-9_-]{43}$') + CONSTRAINT authorization_codes_challenge_format CHECK (code_challenge ~ '^[A-Za-z0-9_-]{43}$'), + CONSTRAINT authorization_codes_nonce_length CHECK (nonce IS NULL OR char_length(nonce) <= 512) ); CREATE INDEX idx_authorization_codes_expires ON authorization_codes (expires_at); diff --git a/migrations/0008_account_tokens.sql b/migrations/0008_account_tokens.sql index 6c032b2..2cec9f7 100644 --- a/migrations/0008_account_tokens.sql +++ b/migrations/0008_account_tokens.sql @@ -1,7 +1,15 @@ -- Single-use tokens sent by e-mail: address verification (at registration and -- for an e-mail change, bound to the exact address verified) and password --- reset. Stored as SHA-256 hashes; at most one unused token per user. --- Retention functions are bounded like cleanup_expired_sessions. +-- reset. Stored as SHA-256 hashes. Retention functions are bounded like +-- cleanup_expired_sessions. +-- +-- A registration on an address whose account is still pending verification +-- carries its own credentials in its verification link: whoever clicks a link +-- activates the account with the password chosen by the registration that sent +-- it, so registering someone's address first never lets an attacker decide the +-- password the owner activates. The links of a pending account therefore +-- coexist until one of them is used, which revokes the others; so do the links +-- of a password reset. CREATE TABLE email_verification_tokens ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE, @@ -12,6 +20,10 @@ CREATE TABLE email_verification_tokens ( request_ip INET, request_user_agent TEXT, target_email CITEXT NOT NULL, + -- Credentials of the registration that sent the link, all or none. + password_hash TEXT, + username VARCHAR(50), + preferred_locale VARCHAR(10), CONSTRAINT email_verification_tokens_token_hash_key UNIQUE (token_hash), CONSTRAINT email_verification_tokens_token_hash_length CHECK (octet_length(token_hash) = 32), @@ -19,6 +31,16 @@ CREATE TABLE email_verification_tokens ( CONSTRAINT email_verification_tokens_used_after_creation CHECK (used_at IS NULL OR used_at >= created_at), CONSTRAINT email_verification_tokens_target_email_format CHECK ( target_email ~* '^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$' + ), + CONSTRAINT email_verification_tokens_credentials_together CHECK ( + (password_hash IS NULL) = (username IS NULL) + AND (password_hash IS NULL) = (preferred_locale IS NULL) + ), + CONSTRAINT email_verification_tokens_username_format CHECK ( + username IS NULL OR username ~ '^[a-zA-Z0-9_]{3,50}$' + ), + CONSTRAINT email_verification_tokens_locale_format CHECK ( + preferred_locale IS NULL OR preferred_locale ~ '^[a-z]{2}(_[A-Z]{2})?$' ) ) WITH ( @@ -29,10 +51,31 @@ WITH ( ); CREATE INDEX idx_email_verification_tokens_user ON email_verification_tokens (user_id); -CREATE UNIQUE INDEX idx_email_verification_tokens_user_active - ON email_verification_tokens (user_id) WHERE used_at IS NULL; CREATE INDEX idx_email_verification_tokens_expires_at ON email_verification_tokens (expires_at); +-- A username asked for by a registration on an address that already has an +-- account stays reserved as long as the registration's link would live: the +-- next registration asking for it is refused as if an account held it, so +-- whether a username is taken never tells whether an address is registered. +-- One reservation per registered address at a time: each registration on it +-- replaces the previous one, so nobody squats usernames in bulk through an +-- address they own. +CREATE TABLE username_reservations ( + username VARCHAR(50) NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + reserved_for UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE, + + CONSTRAINT username_reservations_username_format CHECK ( + username ~ '^[a-zA-Z0-9_]{3,50}$' + ) +); + +CREATE UNIQUE INDEX username_reservations_username_lower_key + ON username_reservations (lower(username)); +CREATE UNIQUE INDEX username_reservations_reserved_for_key ON username_reservations (reserved_for); +CREATE INDEX idx_username_reservations_expires_at ON username_reservations (expires_at); + +-- Expired reservations go with the expired links they shadowed. CREATE OR REPLACE FUNCTION cleanup_expired_email_verification_tokens( grace_interval INTERVAL DEFAULT '1 day', batch_size INTEGER DEFAULT NULL @@ -47,6 +90,11 @@ BEGIN LIMIT batch_size )); GET DIAGNOSTICS deleted = ROW_COUNT; + DELETE FROM username_reservations WHERE ctid = ANY (ARRAY( + SELECT ctid FROM username_reservations + WHERE expires_at < NOW() + LIMIT batch_size + )); RETURN deleted; END; $$ LANGUAGE plpgsql; @@ -74,7 +122,8 @@ WITH ( ); CREATE INDEX idx_password_reset_tokens_user ON password_reset_tokens (user_id); -CREATE UNIQUE INDEX idx_password_reset_tokens_user_active +-- Pending links of an account, revoked together when one is used. +CREATE INDEX idx_password_reset_tokens_user_active ON password_reset_tokens (user_id) WHERE used_at IS NULL; CREATE INDEX idx_password_reset_tokens_expires_at ON password_reset_tokens (expires_at); diff --git a/migrations/0009_login_attempts.sql b/migrations/0009_login_attempts.sql index 0c4b054..d624d88 100644 --- a/migrations/0009_login_attempts.sql +++ b/migrations/0009_login_attempts.sql @@ -9,7 +9,8 @@ CREATE TYPE login_failure_reason AS ENUM ( 'account_disabled', 'two_factor_required', 'two_factor_failed', - 'rate_limited' + 'rate_limited', + 'account_locked' ); CREATE TABLE login_attempts ( diff --git a/migrations/0010_audit_log.sql b/migrations/0010_audit_log.sql index 8bb3f24..417340d 100644 --- a/migrations/0010_audit_log.sql +++ b/migrations/0010_audit_log.sql @@ -3,6 +3,12 @@ -- Partitions are created and dropped by rotate_audit_log_partitions(), which the -- application calls at startup and from its cleanup task with the configured -- retention; the application is the only scheduler. +-- +-- Functions doing what the runtime role may not do by itself, once the schema +-- belongs to a separate owner role (deploy/db/auth-api-grants.sql), run with +-- their owner's privileges and a fixed search path, and only the roles the +-- grants name may call them. A deployment where one role owns and uses the +-- schema is unchanged: the owner runs its own functions. CREATE TYPE audit_action AS ENUM ( 'login', 'login_failed', @@ -32,7 +38,34 @@ CREATE TYPE audit_action AS ENUM ( 'username_changed', 'recovery_code_used', 'email_changed', - 'encryption_key_rotated' + 'encryption_key_rotated', + 'data_exported', + 'magic_link_sent', + 'personal_access_token_created', + 'personal_access_token_revoked', + 'passkey_registered', + 'passkey_removed', + 'external_identity_linked', + 'external_identity_unlinked', + 'client_authorized', + 'device_approved', + 'device_denied', + -- Administrative changes. + 'account_unlocked', + 'password_reset_forced', + 'access_factors_removed', + 'admin_data_read', + 'role_created', + 'role_deleted', + 'role_permissions_changed', + 'client_registered', + 'client_updated', + 'client_deleted', + 'client_secret_rotated', + 'webhook_created', + 'webhook_updated', + 'webhook_deleted', + 'webhook_secret_rotated' ); CREATE TABLE audit_log ( @@ -56,10 +89,34 @@ WITH ( autovacuum_analyze_threshold = 1000 ); +-- Minimums the maintenance functions apply whatever their caller asks: the +-- runtime role calls them with the configured retention, but cannot lower +-- these (deploy/db/auth-api-grants.sql leaves it read access only), so an SQL +-- injection or a compromised service cannot use them to erase the audit trail +-- or delete accounts. The owner changes them with an UPDATE. +CREATE TABLE maintenance_floors ( + id BOOLEAN PRIMARY KEY DEFAULT TRUE, + -- Audit partitions younger than this are never dropped. + audit_retention_months INTEGER NOT NULL DEFAULT 6, + -- Audit addresses younger than this are never coarsened. + audit_address_min_age INTERVAL NOT NULL DEFAULT '30 days', + -- Accounts pending verification for less than this are never purged. + unverified_account_min_age INTERVAL NOT NULL DEFAULT '1 day', + -- Published events and finished webhook deliveries younger than this are + -- never swept. + published_event_min_age INTERVAL NOT NULL DEFAULT '1 day', + finished_delivery_min_age INTERVAL NOT NULL DEFAULT '1 day', + + CONSTRAINT maintenance_floors_single_row CHECK (id), + CONSTRAINT maintenance_floors_audit_retention_positive CHECK (audit_retention_months >= 1) +); + +INSERT INTO maintenance_floors DEFAULT VALUES; + -- Creates the monthly partitions from last month to lookahead_months ahead (at --- least one), and drops those older than retention_months; retention_months <= 0 --- keeps every partition. Concurrent callers (instances starting together) are --- serialized. +-- least one), and drops those older than retention_months, never fewer than the +-- floor; retention_months <= 0 keeps every partition. Concurrent callers +-- (instances starting together) are serialized. CREATE OR REPLACE FUNCTION rotate_audit_log_partitions( retention_months INTEGER DEFAULT 6, lookahead_months INTEGER DEFAULT 12 @@ -67,8 +124,12 @@ CREATE OR REPLACE FUNCTION rotate_audit_log_partitions( RETURNS VOID AS $$ DECLARE create_start DATE := (date_trunc('month', NOW()) - INTERVAL '1 month')::DATE; - create_end DATE := (date_trunc('month', NOW()) + make_interval(months => GREATEST(lookahead_months, 1)))::DATE; - keep_from DATE := (date_trunc('month', NOW()) - make_interval(months => GREATEST(retention_months, 0)))::DATE; + -- Two years ahead at most: a caller cannot fill the catalog with tables. + create_end DATE := (date_trunc('month', NOW()) + + make_interval(months => LEAST(GREATEST(lookahead_months, 1), 24)))::DATE; + floor_months INTEGER := (SELECT audit_retention_months FROM maintenance_floors); + keep_from DATE := (date_trunc('month', NOW()) + - make_interval(months => GREATEST(retention_months, floor_months, 0)))::DATE; month_start DATE; part_name TEXT; rel_name TEXT; @@ -85,6 +146,13 @@ BEGIN month_start, (month_start + INTERVAL '1 month')::DATE ); + -- The owner's default privileges hand every new table to the runtime + -- role with UPDATE and DELETE: a partition must not have them. + IF current_user <> 'auth_api' + AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'auth_api') THEN + EXECUTE format('REVOKE UPDATE, DELETE ON %I FROM auth_api', + 'audit_log_' || to_char(month_start, 'YYYY_MM')); + END IF; END LOOP; IF retention_months <= 0 THEN @@ -106,24 +174,34 @@ BEGIN END IF; END LOOP; END; -$$ LANGUAGE plpgsql; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public, pg_temp; + +REVOKE EXECUTE ON FUNCTION rotate_audit_log_partitions(INTEGER, INTEGER) FROM PUBLIC; SELECT rotate_audit_log_partitions(); --- Rows are never updated or deleted, except to detach a deleted user --- (user_id set to NULL by the foreign key, every other column unchanged). +-- Rows are never updated or deleted. Two narrowing updates are allowed and +-- nothing else: detaching a deleted user (user_id to NULL), and forgetting or +-- coarsening a client address (ip_address to NULL, or to a network containing +-- it). CREATE OR REPLACE FUNCTION prevent_audit_log_modification() RETURNS TRIGGER AS $$ BEGIN IF TG_OP = 'UPDATE' - AND OLD.user_id IS NOT NULL - AND NEW.user_id IS NULL AND NEW.id = OLD.id AND NEW.request_id IS NOT DISTINCT FROM OLD.request_id AND NEW.created_at = OLD.created_at AND NEW.action = OLD.action - AND NEW.ip_address IS NOT DISTINCT FROM OLD.ip_address - AND NEW.metadata = OLD.metadata THEN + AND NEW.metadata = OLD.metadata + AND (NEW.user_id IS NOT DISTINCT FROM OLD.user_id OR NEW.user_id IS NULL) + AND ( + NEW.ip_address IS NOT DISTINCT FROM OLD.ip_address + OR NEW.ip_address IS NULL + OR (masklen(NEW.ip_address) < masklen(OLD.ip_address) + AND OLD.ip_address <<= NEW.ip_address) + ) + AND (NEW.user_id IS DISTINCT FROM OLD.user_id + OR NEW.ip_address IS DISTINCT FROM OLD.ip_address) THEN RETURN NEW; END IF; @@ -137,3 +215,5 @@ CREATE TRIGGER audit_log_append_only CREATE INDEX idx_audit_log_user ON audit_log (user_id, created_at DESC) WHERE user_id IS NOT NULL; CREATE INDEX idx_audit_log_created_brin ON audit_log USING BRIN (created_at); +-- The global audit log, newest first. +CREATE INDEX idx_audit_log_created ON audit_log (created_at DESC, id DESC); diff --git a/migrations/0011_event_outbox.sql b/migrations/0011_event_outbox.sql index 1019b2d..1801f4b 100644 --- a/migrations/0011_event_outbox.sql +++ b/migrations/0011_event_outbox.sql @@ -30,6 +30,9 @@ CREATE INDEX idx_event_outbox_published_at -- Published events are kept a while for investigation, then swept by the -- application's cleanup task, in batches like the other retention functions. +-- The runtime role cannot delete from the outbox (deploy/db/auth-api-grants.sql): +-- an unpublished event, a `user.deleted` above all, cannot be made to vanish. +-- This function runs as the owner, never earlier than the floor. CREATE OR REPLACE FUNCTION cleanup_published_events( retention INTERVAL DEFAULT '7 days', batch_size INTEGER DEFAULT NULL @@ -40,10 +43,14 @@ DECLARE BEGIN DELETE FROM event_outbox WHERE ctid = ANY (ARRAY( SELECT ctid FROM event_outbox - WHERE published_at < NOW() - retention + WHERE published_at IS NOT NULL + AND published_at < NOW() - GREATEST(retention, + (SELECT published_event_min_age FROM maintenance_floors)) LIMIT batch_size )); GET DIAGNOSTICS deleted = ROW_COUNT; RETURN deleted; END; -$$ LANGUAGE plpgsql; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public, pg_temp; + +REVOKE EXECUTE ON FUNCTION cleanup_published_events(INTERVAL, INTEGER) FROM PUBLIC; diff --git a/migrations/0013_personal_data.sql b/migrations/0012_personal_data.sql similarity index 53% rename from migrations/0013_personal_data.sql rename to migrations/0012_personal_data.sql index 9fb9052..d97b41b 100644 --- a/migrations/0013_personal_data.sql +++ b/migrations/0012_personal_data.sql @@ -1,37 +1,9 @@ --- Personal data: what a deleted account leaves behind, and how long the audit --- log keeps full client addresses. +-- Personal data: what a deleted or never-verified account leaves behind, and +-- how long the audit log keeps full client addresses. These functions rewrite +-- audit rows and delete accounts, so they run with their owner's privileges +-- like rotate_audit_log_partitions. --- The audit log stays append-only. Two narrowing updates are allowed and --- nothing else: detaching a deleted user (user_id to NULL), and forgetting or --- coarsening a client address (ip_address to NULL, or to a network containing --- it). -CREATE OR REPLACE FUNCTION prevent_audit_log_modification() -RETURNS TRIGGER AS $$ -BEGIN - IF TG_OP = 'UPDATE' - AND NEW.id = OLD.id - AND NEW.request_id IS NOT DISTINCT FROM OLD.request_id - AND NEW.created_at = OLD.created_at - AND NEW.action = OLD.action - AND NEW.metadata = OLD.metadata - AND (NEW.user_id IS NOT DISTINCT FROM OLD.user_id OR NEW.user_id IS NULL) - AND ( - NEW.ip_address IS NOT DISTINCT FROM OLD.ip_address - OR NEW.ip_address IS NULL - OR (masklen(NEW.ip_address) < masklen(OLD.ip_address) - AND OLD.ip_address <<= NEW.ip_address) - ) - AND (NEW.user_id IS DISTINCT FROM OLD.user_id - OR NEW.ip_address IS DISTINCT FROM OLD.ip_address) THEN - RETURN NEW; - END IF; - - RAISE EXCEPTION 'audit_log is append-only'; -END; -$$ LANGUAGE plpgsql; - --- Forget what an account leaves outside its own rows, in the transaction that --- deletes it and before its row goes: the client addresses of its audit +-- Delete an account and forget what it leaves outside its own rows: the client addresses of its audit -- entries, and its sign-in attempts, recorded under its id or under the -- identifiers typed for it before it existed. The audit entries themselves stay, -- without identity, for the retention period. @@ -40,7 +12,10 @@ RETURNS VOID AS $$ DECLARE identity RECORD; BEGIN - SELECT email, username INTO identity FROM users WHERE id = p_user_id; + SELECT email, username INTO identity FROM users WHERE id = p_user_id FOR UPDATE; + IF NOT FOUND THEN + RAISE EXCEPTION 'account % does not exist', p_user_id; + END IF; UPDATE audit_log SET ip_address = NULL WHERE user_id = p_user_id AND ip_address IS NOT NULL; @@ -53,10 +28,20 @@ BEGIN WHERE was_successful = FALSE AND attempted_identifier IN (identity.email, identity.username::CITEXT); END IF; + + -- The account goes with its traces: the function, running with the + -- owner's privileges, cannot be used to rewrite the audit trail of an + -- account that stays. + DELETE FROM users WHERE id = p_user_id; END; -$$ LANGUAGE plpgsql; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public, pg_temp; --- Purge of never-verified accounts (0012), forgetting their traces too. +REVOKE EXECUTE ON FUNCTION forget_account_traces(UUID) FROM PUBLIC; + +-- Accounts whose address was never verified are purged after a configurable +-- age, so an address registered by mistake (or by someone else) becomes free +-- again. Each purge forgets the account's traces, is audited and announced with +-- `user.deleted`, like a deletion by the user, in the same transaction. CREATE OR REPLACE FUNCTION purge_unverified_accounts( age INTERVAL, batch_size INTEGER DEFAULT NULL @@ -69,7 +54,8 @@ BEGIN SELECT array_agg(id) INTO doomed FROM ( SELECT id FROM users - WHERE status = 'pending_verification' AND created_at < NOW() - age + WHERE status = 'pending_verification' + AND created_at < NOW() - GREATEST(age, (SELECT unverified_account_min_age FROM maintenance_floors)) ORDER BY created_at LIMIT batch_size FOR UPDATE SKIP LOCKED @@ -79,22 +65,33 @@ BEGIN RETURN 0; END IF; - PERFORM forget_account_traces(id) FROM unnest(doomed) AS id; - -- Written before the deletion: the foreign key then sets user_id to NULL. INSERT INTO audit_log (user_id, action, metadata) SELECT id, 'account_deleted', '{"reason": "never_verified"}'::JSONB FROM unnest(doomed) AS id; - INSERT INTO event_outbox (subject, payload) - SELECT 'events.auth.user.deleted', jsonb_build_object('user_id', id) - FROM unnest(doomed) AS id; + -- Announced like any deletion, webhooks included: an endpoint erasing the + -- account's data downstream hears of every deletion, not only the others. + WITH event AS ( + INSERT INTO event_outbox (subject, payload) + SELECT 'events.auth.user.deleted', jsonb_build_object('user_id', id) + FROM unnest(doomed) AS id + RETURNING id, payload, created_at + ) + INSERT INTO webhook_deliveries (endpoint_id, event_id, event_name, payload, occurred_at) + SELECT endpoint.id, event.id, 'user.deleted', event.payload, event.created_at + FROM event CROSS JOIN webhook_endpoints endpoint + WHERE endpoint.enabled + AND ('user.deleted' = ANY (endpoint.events) OR '*' = ANY (endpoint.events)); - DELETE FROM users WHERE id = ANY (doomed); - GET DIAGNOSTICS purged = ROW_COUNT; + -- Each account goes with its traces. + PERFORM forget_account_traces(id) FROM unnest(doomed) AS id; + purged := cardinality(doomed); RETURN purged; END; -$$ LANGUAGE plpgsql; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public, pg_temp; + +REVOKE EXECUTE ON FUNCTION purge_unverified_accounts(INTERVAL, INTEGER) FROM PUBLIC; -- Audit entries still holding a full client address: the coarsening job reads -- them by age, and a row leaves the index once coarsened. @@ -124,11 +121,13 @@ BEGIN SELECT created_at, id FROM audit_log WHERE ip_address IS NOT NULL AND masklen(ip_address) = CASE WHEN family(ip_address) = 4 THEN 32 ELSE 128 END - AND created_at < NOW() - age + AND created_at < NOW() - GREATEST(age, (SELECT audit_address_min_age FROM maintenance_floors)) LIMIT batch_size ) AS due WHERE entry.created_at = due.created_at AND entry.id = due.id; GET DIAGNOSTICS coarsened = ROW_COUNT; RETURN coarsened; END; -$$ LANGUAGE plpgsql; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public, pg_temp; + +REVOKE EXECUTE ON FUNCTION coarsen_audit_addresses(INTERVAL, INTEGER) FROM PUBLIC; diff --git a/migrations/0012_purge_unverified_accounts.sql b/migrations/0012_purge_unverified_accounts.sql deleted file mode 100644 index ea64aa9..0000000 --- a/migrations/0012_purge_unverified_accounts.sql +++ /dev/null @@ -1,44 +0,0 @@ --- Accounts whose address was never verified are purged after a configurable --- age, so an address registered by mistake (or by someone else) becomes free --- again. Each purge is audited and announced with `user.deleted`, like a --- deletion by the user, in the same transaction as the deletion. -CREATE OR REPLACE FUNCTION purge_unverified_accounts( - age INTERVAL, - batch_size INTEGER DEFAULT NULL -) -RETURNS INTEGER AS $$ -DECLARE - doomed UUID[]; - purged INTEGER; -BEGIN - SELECT array_agg(id) INTO doomed - FROM ( - SELECT id FROM users - WHERE status = 'pending_verification' AND created_at < NOW() - age - ORDER BY created_at - LIMIT batch_size - FOR UPDATE SKIP LOCKED - ) oldest; - - IF doomed IS NULL THEN - RETURN 0; - END IF; - - -- Written before the deletion: the foreign key then sets user_id to NULL. - INSERT INTO audit_log (user_id, action, metadata) - SELECT id, 'account_deleted', '{"reason": "never_verified"}'::JSONB - FROM unnest(doomed) AS id; - - INSERT INTO event_outbox (subject, payload) - SELECT 'events.auth.user.deleted', jsonb_build_object('user_id', id) - FROM unnest(doomed) AS id; - - DELETE FROM users WHERE id = ANY (doomed); - GET DIAGNOSTICS purged = ROW_COUNT; - RETURN purged; -END; -$$ LANGUAGE plpgsql; - --- The purge reads pending accounts by age. -CREATE INDEX idx_users_pending_created - ON users (created_at) WHERE status = 'pending_verification'; diff --git a/migrations/0014_known_devices.sql b/migrations/0013_known_devices.sql similarity index 100% rename from migrations/0014_known_devices.sql rename to migrations/0013_known_devices.sql diff --git a/migrations/0017_magic_links.sql b/migrations/0014_magic_links.sql similarity index 95% rename from migrations/0017_magic_links.sql rename to migrations/0014_magic_links.sql index b1d6502..9c3ca01 100644 --- a/migrations/0017_magic_links.sql +++ b/migrations/0014_magic_links.sql @@ -37,5 +37,3 @@ BEGIN RETURN deleted; END; $$ LANGUAGE plpgsql; - -ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'magic_link_sent'; diff --git a/migrations/0015_administration.sql b/migrations/0015_administration.sql deleted file mode 100644 index 97f7ef8..0000000 --- a/migrations/0015_administration.sql +++ /dev/null @@ -1,49 +0,0 @@ --- Administration: the permissions the /admin routes require, the `admin` role --- granting all of them, and the audit actions of administrative changes. -INSERT INTO permissions (resource, action, description) VALUES - ('users', 'read', 'Search accounts and read their details'), - ('users', 'manage', 'Suspend, reactivate, unlock, sign out, reset and delete accounts'), - ('roles', 'manage', 'Create and delete roles, set their permissions, assign them to accounts'), - ('clients', 'manage', 'Register, update and remove client applications'), - ('audit', 'read', 'Read the audit log of every account'), - ('webhooks', 'manage', 'Manage webhook subscriptions and their deliveries') -ON CONFLICT (resource, action) DO NOTHING; - -INSERT INTO roles (name, description, is_default) -VALUES ('admin', 'Administrators: every administrative permission', FALSE) -ON CONFLICT (name) DO NOTHING; - -INSERT INTO role_permissions (role_id, permission_id) -SELECT roles.id, permissions.id -FROM roles CROSS JOIN permissions -WHERE roles.name = 'admin' - AND permissions.name IN ( - 'users:read', 'users:manage', 'roles:manage', 'clients:manage', 'audit:read', 'webhooks:manage' - ) -ON CONFLICT DO NOTHING; - -ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'account_unlocked'; -ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'password_reset_forced'; -ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'role_created'; -ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'role_deleted'; -ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'role_permissions_changed'; -ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'client_registered'; -ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'client_updated'; -ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'client_deleted'; - --- An administrator unlocking an account also forgives the failed sign-ins that --- locked it: the consecutive failures count from the later of the last success --- and this date. -ALTER TABLE users ADD COLUMN lockout_cleared_at TIMESTAMPTZ; - --- Administrators search accounts by the start of an address or a username. -CREATE INDEX idx_users_email_prefix ON users ((lower(email::text)) text_pattern_ops); -CREATE INDEX idx_users_username_prefix ON users ((lower(username::text)) text_pattern_ops); --- Listing pages newest first. -CREATE INDEX idx_users_created ON users (created_at DESC, id DESC); - --- The global audit log, newest first. -CREATE INDEX idx_audit_log_created ON audit_log (created_at DESC, id DESC); --- Sessions of a client application, revoked when the client is removed. -CREATE INDEX idx_sessions_client_active ON sessions (client_id) - WHERE client_id IS NOT NULL AND revoked_at IS NULL; diff --git a/migrations/0018_personal_access_tokens.sql b/migrations/0015_personal_access_tokens.sql similarity index 84% rename from migrations/0018_personal_access_tokens.sql rename to migrations/0015_personal_access_tokens.sql index 2a3f358..114d61b 100644 --- a/migrations/0018_personal_access_tokens.sql +++ b/migrations/0015_personal_access_tokens.sql @@ -2,7 +2,6 @@ -- scripts, exchanged for short-lived access tokens. Each one owns a session of -- type `personal_access_token`, so every revocation path (the token list, the -- session list, a password change, an administrator) ends it the same way. -ALTER TYPE session_type ADD VALUE IF NOT EXISTS 'personal_access_token'; CREATE TABLE personal_access_tokens ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -23,6 +22,3 @@ CREATE TABLE personal_access_tokens ( ); CREATE INDEX idx_personal_access_tokens_user ON personal_access_tokens (user_id, created_at DESC); - -ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'personal_access_token_created'; -ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'personal_access_token_revoked'; diff --git a/migrations/0016_data_export.sql b/migrations/0016_data_export.sql deleted file mode 100644 index 5f34e83..0000000 --- a/migrations/0016_data_export.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Account owners download what the service stores about them. -ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'data_exported'; diff --git a/migrations/0019_webhooks.sql b/migrations/0016_webhooks.sql similarity index 85% rename from migrations/0019_webhooks.sql rename to migrations/0016_webhooks.sql index 410c838..f2be82a 100644 --- a/migrations/0019_webhooks.sql +++ b/migrations/0016_webhooks.sql @@ -49,7 +49,10 @@ CREATE INDEX idx_webhook_deliveries_endpoint ON webhook_deliveries (endpoint_id, CREATE INDEX idx_webhook_deliveries_finished ON webhook_deliveries (created_at) WHERE delivered_at IS NOT NULL OR failed_at IS NOT NULL; --- Finished deliveries are kept a while for inspection, then deleted. +-- Finished deliveries are kept a while for inspection, then deleted. The +-- runtime role cannot delete deliveries itself: a pending one is never +-- dropped, and a finished one only after the floor, by this function running +-- as the owner. CREATE OR REPLACE FUNCTION cleanup_finished_webhook_deliveries( retention INTERVAL, batch_size INTEGER DEFAULT NULL @@ -61,15 +64,13 @@ BEGIN DELETE FROM webhook_deliveries WHERE ctid = ANY (ARRAY( SELECT ctid FROM webhook_deliveries WHERE (delivered_at IS NOT NULL OR failed_at IS NOT NULL) - AND created_at < NOW() - retention + AND created_at < NOW() - GREATEST(retention, + (SELECT finished_delivery_min_age FROM maintenance_floors)) LIMIT batch_size )); GET DIAGNOSTICS deleted = ROW_COUNT; RETURN deleted; END; -$$ LANGUAGE plpgsql; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public, pg_temp; -ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'webhook_created'; -ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'webhook_updated'; -ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'webhook_deleted'; -ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'webhook_secret_rotated'; +REVOKE EXECUTE ON FUNCTION cleanup_finished_webhook_deliveries(INTERVAL, INTEGER) FROM PUBLIC; diff --git a/migrations/0023_passkeys.sql b/migrations/0017_passkeys.sql similarity index 89% rename from migrations/0023_passkeys.sql rename to migrations/0017_passkeys.sql index 9930c4a..20d06d4 100644 --- a/migrations/0023_passkeys.sql +++ b/migrations/0017_passkeys.sql @@ -23,6 +23,3 @@ CREATE TABLE passkeys ( ); CREATE INDEX idx_passkeys_user ON passkeys (user_id, created_at); - -ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'passkey_registered'; -ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'passkey_removed'; diff --git a/migrations/0024_external_identities.sql b/migrations/0018_external_identities.sql similarity index 84% rename from migrations/0024_external_identities.sql rename to migrations/0018_external_identities.sql index e27c7d5..b1f5e71 100644 --- a/migrations/0024_external_identities.sql +++ b/migrations/0018_external_identities.sql @@ -14,6 +14,3 @@ CREATE TABLE external_identities ( CONSTRAINT external_identities_one_per_provider UNIQUE (user_id, provider), CONSTRAINT external_identities_subject_length CHECK (char_length(subject) BETWEEN 1 AND 255) ); - -ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'external_identity_linked'; -ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'external_identity_unlinked'; diff --git a/migrations/0020_oauth.sql b/migrations/0020_oauth.sql deleted file mode 100644 index adfa298..0000000 --- a/migrations/0020_oauth.sql +++ /dev/null @@ -1,9 +0,0 @@ --- Confidential clients: a client holding a secret authenticates at the token --- endpoint (client_secret_basic or client_secret_post). The secret is 256 random --- bits, so its SHA-256 digest is stored, not a slow hash. -ALTER TABLE registered_clients - ADD COLUMN client_secret_hash BYTEA, - ADD CONSTRAINT registered_clients_secret_hash_length - CHECK (client_secret_hash IS NULL OR octet_length(client_secret_hash) = 32); - -ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'client_secret_rotated'; diff --git a/migrations/0021_client_credentials.sql b/migrations/0021_client_credentials.sql deleted file mode 100644 index e614fb6..0000000 --- a/migrations/0021_client_credentials.sql +++ /dev/null @@ -1,8 +0,0 @@ --- The client credentials grant: a confidential client obtains tokens for --- itself, carrying its registered scopes and no user. Enabled per client. -ALTER TABLE registered_clients - ADD COLUMN allows_client_credentials BOOLEAN NOT NULL DEFAULT FALSE, - ADD CONSTRAINT registered_clients_client_credentials_confidential - CHECK (NOT allows_client_credentials OR client_secret_hash IS NOT NULL), - ADD CONSTRAINT registered_clients_client_credentials_scoped - CHECK (NOT allows_client_credentials OR cardinality(scopes) > 0); diff --git a/migrations/0022_openid_connect.sql b/migrations/0022_openid_connect.sql deleted file mode 100644 index 310319b..0000000 --- a/migrations/0022_openid_connect.sql +++ /dev/null @@ -1,5 +0,0 @@ --- OpenID Connect: the nonce of an authentication request travels with its --- authorization code into the ID token. -ALTER TABLE authorization_codes - ADD COLUMN nonce TEXT, - ADD CONSTRAINT authorization_codes_nonce_length CHECK (nonce IS NULL OR char_length(nonce) <= 512); diff --git a/migrations/SHA256SUMS b/migrations/SHA256SUMS index a276c93..5b7d37b 100644 --- a/migrations/SHA256SUMS +++ b/migrations/SHA256SUMS @@ -1,24 +1,18 @@ f68dfef5383a0f730e87df31c73443ae4dc79e4dabb5301290736812c9079b3e 0001_extensions.sql -75c3f7990273d25596b976b35d23b19943194be187c5d8946e879ceb6dcbf415 0002_users.sql -8ac6b4d8d2ea3dd284bb329b09fc1a50bfb50b65670dfdef5dff18551db22740 0003_rbac.sql -437f7ea65b6248fdfd8b9c6d67e481bc6a6ae76830ec1a804cefae412a03167d 0004_registered_clients.sql -67f08436838218647411474b1f14d57d5624959040d658f72783042de61fa395 0005_sessions.sql -c543849eb6b0374192f3d2776ef18bef71c38b3e64227f65abf1e6765bcb1cfe 0006_authorization_codes.sql +e6e6e7ec30c6268bcce94c143b9afaa28a0f52afe47fa2cc05ab850c7f932f02 0002_users.sql +c6e3ddfcd4e5d5c1a0c20ed88e4ecda498f0e1693d236038a2854504380a1092 0003_rbac.sql +044c137913fb9d27d7af24063b7e7bb8e6f1e299c17c1659165b5d6326e84d84 0004_registered_clients.sql +dea82120a98c1cef8fab3064cc46edf577dd2408ff9c7a9e134a48f7ff5ca44c 0005_sessions.sql +b5015380ae092193f8d92ed035244005ad202c0fc9fdff2f9594058ce6f1cd47 0006_authorization_codes.sql 9a77f658341cc377e103c9f95687816d35e4eb8d30d04b1e79f48364ae8296c6 0007_two_factor.sql -e0eb86b8960dad4b71c3cd2b451ef248ab72cc51bc9be7a7264deeb676f7953e 0008_account_tokens.sql -6f3bb804844530fdc7e3bcf3dceefb1080c3b033a0f2a2f38c3fbcde2d943a12 0009_login_attempts.sql -93ee032de3ecb20f74e90f74961c605715c97e14eceb4965857665f2dff55a4b 0010_audit_log.sql -76b27021b2b3e978ee4222edf821fbda8d78f39693fdd446279dc4d4d61377dc 0011_event_outbox.sql -b8de15f6c36964e20011d9f1e27328ad29f4741bf4934e278fed80c082296407 0012_purge_unverified_accounts.sql -06bafbf6337f34cd2593cf3e65fb2924b81dd3ff99beb5ffdb7681c35276b054 0013_personal_data.sql -f6434d27992410fe4844cf77d826695d2c064bf9f7cc2533d6ad43982493c011 0014_known_devices.sql -cf488c3d1447968682de07e2b693d061338fb20762016bc1a13b21cd0479c68c 0015_administration.sql -532f7077a3499defe32d57f9d914df1727de127480250143abbd82b0c2196c85 0016_data_export.sql -0fa236d98e376fd23d9535187bd831f281892f2c7f99fa2d685ab760ab1f4927 0017_magic_links.sql -42a25675eca8b31f97dfa3812a213eefc6bd648384ba63bd381913afdee8a25f 0018_personal_access_tokens.sql -d5701c00b0e287b24a17103b7d6c0973918cc9c48aacf3693ad5df6d3124b0a1 0019_webhooks.sql -a1e01ce9c1cde16545191dc1f31ec725dcfc4d15393657077f057efea7f72a20 0020_oauth.sql -240080d0368c592655a65c0e1374b351258416c8967182ec8dc9c49f65abe0c3 0021_client_credentials.sql -2515d6388f54364f07f460c8e3ac97b09a039e0ec2a6c4f458c3dcb103f062f5 0022_openid_connect.sql -a3fdddb551010b532efa344548a0b467652649068b939f99de46d4a8fdaf1053 0023_passkeys.sql -075c2bb4a512689cb03c870b347cf1687944b0e9f0ccaeb8be1327b6af9279fb 0024_external_identities.sql +224ab24408d5da5c37cfc58b49a1ba0fb70f2ba9f1eec9e998b8e5845a3adff0 0008_account_tokens.sql +6c37703c71c8892899764c86bf00b86f09afd362ac17262d4b27045643c316e8 0009_login_attempts.sql +0f0d203d81b00c16ab5144829cfa9d41aaee176d1feb308e460f0a5e87f90b31 0010_audit_log.sql +aaf9a764eae5d02d782a110225db10f812e92532775cd603d6c2c136e5c1a9f4 0011_event_outbox.sql +818974e32858b33358247a7644fb1ee9bc11e3f3216b1338a3898e6fed167d24 0012_personal_data.sql +f6434d27992410fe4844cf77d826695d2c064bf9f7cc2533d6ad43982493c011 0013_known_devices.sql +b4d5cc42ee28d7ed7f91888176b18a5d9e3f195fe15f131896f4a128b34a997a 0014_magic_links.sql +84077e0347d23ace25d6cc07e19d83dabd2652fdf33794de759765730b5fa6f0 0015_personal_access_tokens.sql +e948c9a708d4ffdce97589d679a3c421c639b666ba50b0ca5d3f2909eb9e59c1 0016_webhooks.sql +5d16ddb1943b82853cafc0c56e2ced5191c63bb0c87f347f81ead2c5d8d09c7c 0017_passkeys.sql +bcaa0d3ba0a0e015dbf9162eb50a7fa1426b3c31a994534b97f6dd78271db0e3 0018_external_identities.sql diff --git a/nats.conf b/nats.conf index 5cd2a36..5d28f77 100644 --- a/nats.conf +++ b/nats.conf @@ -1,8 +1,9 @@ # NATS broker of the API VPS (docker-compose.api.yml). -# Monitoring endpoint, probed by the health check and scraped by the exporter. -# Not published on the host. -http_port: 8222 +# Monitoring endpoint, probed by the health check and scraped by the exporter, +# which shares the broker's network namespace. On loopback only: containers on +# the compose network cannot read connections, subjects or streams. +http: "127.0.0.1:8222" # Durable user events. Memory store unused by the API; the file store is capped # well above the stream's own ceiling (256 MiB, 30 days). diff --git a/nginx/nginx.conf b/nginx/nginx.conf index 2b7eed9..c55da9e 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -6,13 +6,55 @@ # (RATE_LIMIT_RPM=300, RATE_LIMIT_AUTH_RPM=20). Nginx absorbs floods before they # reach it at twice those rates, so a client under its limit never meets nginx's # 429 and an abusive one gets the application's (with its Retry-After). -limit_req_zone $binary_remote_addr zone=api_general:10m rate=600r/m; -limit_req_zone $binary_remote_addr zone=api_auth:10m rate=40r/m; +# The client network the limits count: an IPv4 address, or the /64 of an IPv6 +# one, like the application's buckets. An IPv6 client holds a whole /64 and +# could otherwise take a fresh address, and a fresh share of nginx's limits, +# at every request. Addresses whose first 64 bits are written in full, or end +# in zeros before `::`, are grouped; the rare other forms keep their own key. +map $remote_addr $client_net { + "~^(?[0-9a-f]{1,4}:[0-9a-f]{1,4}:[0-9a-f]{1,4}:[0-9a-f]{1,4}):" $net; + "~^(?(?:[0-9a-f]{1,4}:){0,3}[0-9a-f]{0,4})::(?:[0-9a-f]{1,4}(?::[0-9a-f]{1,4}){0,3})?$" "$net::"; + default $binary_remote_addr; +} + +limit_req_zone $client_net zone=api_general:10m rate=600r/m; + +# Connections held open at once per client network: slow bodies sent a byte at +# a time never reach `limit_req`, which counts requests once their headers +# are in. +limit_conn_zone $client_net zone=api_conn:10m; + +# The requests the API holds to its strict bucket (credentials, one-time codes, +# password guesses, OAuth flows): keyed by client network, every other request +# by nothing, and an empty key is not limited. Method and path both count: +# `DELETE /users/me` takes a password, `GET /users/me` does not. The first +# matching pattern wins. A test (src/openapi.rs) checks this list against the +# API's routers, so the two cannot drift apart. +map "$request_method $uri" $auth_limit_key { + default ""; + "~^[A-Z]+ /auth/logout$" ""; + "~^[A-Z]+ /auth/" $client_net; + "~^[A-Z]+ /oauth/(token|introspect|revoke)$" ""; + "~^[A-Z]+ /oauth/" $client_net; + "~^[A-Z]+ /users/me/(reauth|export|username|password)$" $client_net; + "~^[A-Z]+ /users/me/email/(start|verify-current|submit|confirm)$" $client_net; + "~^[A-Z]+ /users/me/two-factor/(totp/setup|email/setup|recovery-codes)$" $client_net; + "~^DELETE /users/me(/sessions(/[^/]+)?|/passkeys/[^/]+|/external-identities/[^/]+|/two-factor/(totp|email)/[^/]+)?$" $client_net; +} +limit_req_zone $auth_limit_key zone=api_auth:10m rate=40r/m; + +# The path as logged: codes carried by the URL (a device's user code, an +# authorization request id) are masked, as the API masks them in its own logs. +map $uri $auth_api_log_uri { + default $uri; + "~^/oauth/device/[^/]+$" "/oauth/device/{user_code}"; + "~^/oauth/authorization-requests/[^/]+" "/oauth/authorization-requests/{id}"; +} # One line per client request, correlated with the API's logs by request_id. log_format auth_api_json escape=json '{"time":"$time_iso8601","request_id":"$request_id","client":"$remote_addr",' - '"method":"$request_method","uri":"$uri","status":$status,' + '"method":"$request_method","uri":"$auth_api_log_uri","status":$status,' '"bytes":$body_bytes_sent,"duration":$request_time,' '"upstream":"$upstream_addr","upstream_status":"$upstream_status",' '"upstream_duration":"$upstream_response_time","user_agent":"$http_user_agent"}'; @@ -30,7 +72,9 @@ server { listen [::]:80; server_name _; - return 301 https://$host$request_uri; + # A fixed host: `$host` comes from the request, and a forged Host header + # would redirect to a domain of the sender's choice. + return 301 https://api.example.com$request_uri; } server { @@ -90,11 +134,16 @@ server { # ------------------------------------------------------------------------- client_max_body_size 64k; - client_body_timeout 10s; + client_body_timeout 5s; client_header_timeout 10s; - - # HEAD is served as GET by the API; uptime probes use it. - if ($request_method !~ ^(GET|HEAD|POST|PATCH|DELETE|OPTIONS)$) { + keepalive_timeout 30s; + send_timeout 10s; + limit_conn api_conn 20; + limit_conn_status 429; + + # HEAD is served as GET by the API; uptime probes use it. PUT replaces + # roles' permissions, clients and webhooks in the administration. + if ($request_method !~ ^(GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS)$) { return 405; } @@ -123,13 +172,13 @@ server { proxy_next_upstream_timeout 10s; # ------------------------------------------------------------------------- - # Public keys - must stay reachable + # Discovery documents - must stay reachable # ------------------------------------------------------------------------- - # Exact match: wins over the hidden-file rule below, which would otherwise - # deny every /.well-known path and leave resource servers unable to verify - # tokens. The application sets its own Cache-Control for this document. + # Discovery documents (JWKS, OAuth and OpenID Connect metadata): a prefix + # match that wins over the hidden-file rule below, which would otherwise + # deny every /.well-known path. The application sets their Cache-Control. - location = /.well-known/jwks.json { + location ^~ /.well-known/ { limit_req zone=api_general burst=50 nodelay; limit_req_status 429; limit_except GET { deny all; } @@ -156,32 +205,14 @@ server { } # ------------------------------------------------------------------------- - # Credential-bearing routes - strict rate limit - # ------------------------------------------------------------------------- - # Anchored on both ends: `/auth/login-anything` is not a login route. - # Logout stays in the general zone so an exhausted auth budget never - # prevents someone from ending their session. - - location ~ ^/auth/(register|login|refresh|verify-email|forgot-password|reset-password|two-factor/[a-z-]+|device|device/token|device/verify|authorize|authorize/token)$ { - limit_req zone=api_auth burst=10 nodelay; - limit_req_status 429; - - proxy_pass http://auth_api; - } - - location ~ ^/users/me/(reauth|email/(start|verify-current|submit|confirm))$ { - limit_req zone=api_auth burst=10 nodelay; - limit_req_status 429; - - proxy_pass http://auth_api; - } - - # ------------------------------------------------------------------------- - # Everything else - general rate limit + # Every other route # ------------------------------------------------------------------------- + # Every request spends the general zone; the strict ones spend the strict + # zone too ($auth_limit_key above). location / { limit_req zone=api_general burst=50 nodelay; + limit_req zone=api_auth burst=10 nodelay; limit_req_status 429; proxy_pass http://auth_api; diff --git a/scripts/backup-drill.sh b/scripts/backup-drill.sh index 4e5c72a..d0699d2 100755 --- a/scripts/backup-drill.sh +++ b/scripts/backup-drill.sh @@ -153,11 +153,11 @@ create_app_database "$DST" DST_URL=$(pg_url "$DST" auth_api "$APP_PASS" auth_api) log "restoring with scripts/restore-db.sh as auth_api" -"$ROOT_DIR/scripts/restore-db.sh" -i "$WORK_DIR/backup.key" -f "$BACKUP_FILE" -d "$DST_URL" +RESTORE_DATABASE_URL="$DST_URL" "$ROOT_DIR/scripts/restore-db.sh" -i "$WORK_DIR/backup.key" -f "$BACKUP_FILE" verify_counts "restore" "$DST_URL" log "a second restore without --force must be refused" -if "$ROOT_DIR/scripts/restore-db.sh" -i "$WORK_DIR/backup.key" -f "$BACKUP_FILE" -d "$DST_URL" >/dev/null 2>&1; then +if RESTORE_DATABASE_URL="$DST_URL" "$ROOT_DIR/scripts/restore-db.sh" -i "$WORK_DIR/backup.key" -f "$BACKUP_FILE" >/dev/null 2>&1; then verify "restore over a database without --force" "refused" "accepted" fi diff --git a/scripts/infra-check.sh b/scripts/infra-check.sh index 33c0340..682b0ab 100755 --- a/scripts/infra-check.sh +++ b/scripts/infra-check.sh @@ -72,10 +72,10 @@ fi # --- static ----------------------------------------------------------------- if selected static; then - # Placeholders for the variables the compose files require. + # Placeholders for the variables the compose files require; the secrets are + # files (scripts/write-secrets.sh), not variables. compose_env() { - env AUTH_API_VERSION=check DATABASE_URL=x REDIS_URL=x JWT_PRIVATE_KEY=x JWT_PUBLIC_KEY=x \ - ENCRYPTION_KEY=x SMTP_USERNAME=x SMTP_PASSWORD=x CAPTCHA_SECRET=x NATS_URL=x "$@" + env AUTH_API_VERSION=check "$@" } for profile in s m l xl; do @@ -111,6 +111,15 @@ if selected static; then run "amtool: alertmanager.yml" docker run --rm --entrypoint amtool -v "$ROOT/deploy/monitoring:/m:ro" \ "$ALERTMANAGER_IMAGE" check-config /m/alertmanager.yml + # PostgreSQL and Redis speak without TLS: WireGuard is their boundary, so + # the shipped settings must bind them to the VPN address only. + vpn_only() { + grep -Eq "^listen_addresses = '10\.0\.0\.[0-9]+'$" deploy/db/postgresql.auth-api.conf && + grep -Eq '^bind 10\.0\.0\.[0-9]+$' deploy/db/redis.auth-api.conf && + ! grep -Eq '^bind .*(0\.0\.0\.0|\*)' deploy/db/redis.auth-api.conf + } + run "database listeners: VPN address only" vpn_only + mapfile -t scripts < <(find scripts perf deploy -name '*.sh' -type f | sort) run "shellcheck: ${#scripts[@]} scripts" \ docker run --rm -v "$ROOT:/mnt:ro" -w /mnt "$SHELLCHECK_IMAGE" -x "${scripts[@]}" diff --git a/scripts/restore-db.sh b/scripts/restore-db.sh index d6d0d38..fc7ef98 100755 --- a/scripts/restore-db.sh +++ b/scripts/restore-db.sh @@ -2,10 +2,16 @@ # restore-db.sh - Restore an encrypted backup produced by backup-db.sh. # # Usage: -# restore-db.sh -i -f -d [--force] +# RESTORE_DATABASE_URL= restore-db.sh -i -f [--force] +# restore-db.sh -i -f -D [--force] # -# Connect as the application's role (auth_api), owner of the target database: -# the dump assigns every object to it. The restore runs in a single transaction, +# The URL, password included, is never taken as an argument: arguments show in +# `ps` and in the shell history. It is read from RESTORE_DATABASE_URL or from +# the file given with -D, and handed to psql through its environment. +# +# Connect as the role that owns the schema (auth_api_owner, or auth_api when a +# single role owns it), owner of the target database: the dump assigns every +# object to it and carries the privileges of the API role. The restore runs in a single transaction, # so a failure leaves the target as it was. # # A database that already holds the `users` table is refused: a restore is @@ -31,12 +37,13 @@ while [[ $# -gt 0 ]]; do case "$1" in -i) KEY_FILE="$2"; shift 2 ;; -f) BACKUP_FILE="$2"; shift 2 ;; - -d) DB_URL="$2"; shift 2 ;; + -D) DB_URL=$(<"$2"); shift 2 ;; --force) FORCE=1; shift ;; *) usage ;; esac done +DB_URL=${DB_URL:-${RESTORE_DATABASE_URL:-}} [[ -n "$KEY_FILE" && -n "$BACKUP_FILE" && -n "$DB_URL" ]] || usage [[ -r "$KEY_FILE" ]] || { echo "ERROR: cannot read key file: $KEY_FILE" >&2; exit 1; } [[ -r "$BACKUP_FILE" ]] || { echo "ERROR: cannot read backup file: $BACKUP_FILE" >&2; exit 1; } @@ -46,9 +53,21 @@ command -v psql >/dev/null || { echo "ERROR: psql is not installed" >&2; exit 1; log() { echo "$(date -Iseconds) [restore] $*"; } +# libpq reads the connection from PG* variables: psql runs without the URL on +# its command line. The URL goes to python on stdin, not as an argument. +eval "$(python3 -c ' +import shlex, sys, urllib.parse +u = urllib.parse.urlsplit(sys.stdin.read().strip()) +for key, value in (("PGHOST", u.hostname), ("PGPORT", u.port), ("PGUSER", u.username), + ("PGPASSWORD", u.password), ("PGDATABASE", u.path.lstrip("/"))): + if value: + print(f"export {key}={shlex.quote(urllib.parse.unquote(str(value)))}") +' <<<"$DB_URL")" +unset DB_URL + # -- Safety check: refuse to overwrite an existing database --------------------- -HAS_USERS=$(psql "$DB_URL" -tAc \ +HAS_USERS=$(psql -tAc \ "SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'users'") if [[ "$HAS_USERS" != "0" ]]; then @@ -58,7 +77,7 @@ if [[ "$HAS_USERS" != "0" ]]; then exit 1 fi log "emptying the target database (--force)" - psql "$DB_URL" --set ON_ERROR_STOP=1 --quiet \ + psql --set ON_ERROR_STOP=1 --quiet \ -c 'DROP SCHEMA public CASCADE' -c 'CREATE SCHEMA public' fi @@ -68,11 +87,11 @@ log "starting from $BACKUP_FILE" age --decrypt -i "$KEY_FILE" "$BACKUP_FILE" \ | gunzip \ - | psql --set ON_ERROR_STOP=1 --single-transaction --quiet "$DB_URL" + | psql --set ON_ERROR_STOP=1 --single-transaction --quiet # -- Check ---------------------------------------------------------------------- -MIGRATION=$(psql "$DB_URL" -tAc \ +MIGRATION=$(psql -tAc \ "SELECT max(version) FROM _sqlx_migrations WHERE success" 2>/dev/null || true) if [[ -n "$MIGRATION" ]]; then log "OK - schema at migration $MIGRATION" diff --git a/scripts/rolling-update.sh b/scripts/rolling-update.sh index e7c2abc..26732a2 100755 --- a/scripts/rolling-update.sh +++ b/scripts/rolling-update.sh @@ -7,8 +7,10 @@ # and ready; if the new version does not come up, the script stops there with # the other instance still serving the previous one. # -# Usage, secrets exported as in docs/deploy/guides/update.md: -# AUTH_API_VERSION=X.Y.Z scripts/rolling-update.sh +# Usage, secrets written as in docs/deploy/guides/update.md, as root for the +# readiness checks (deploy/api/nftables-auth-api.conf lets only nginx and root +# reach the instances): +# AUTH_API_VERSION=X.Y.Z sudo -E scripts/rolling-update.sh set -euo pipefail COMPOSE_DIR=${COMPOSE_DIR:-/srv/auth-api} diff --git a/scripts/stack-smoke.sh b/scripts/stack-smoke.sh index 61fbdfb..87f97dd 100755 --- a/scripts/stack-smoke.sh +++ b/scripts/stack-smoke.sh @@ -27,7 +27,7 @@ SHELL_IMAGE=redis:7-alpine@sha256:ff02b58f971e7d7d156a1267e283fcbbeee91773b6aa36 unset COMPOSE_PROJECT_NAME mkdir -p "$D" "$KEYS/certs" -cp "$ROOT"/docker-compose.api.yml "$ROOT"/config.prod.env "$ROOT"/nats.conf "$ROOT"/scripts/rolling-update.sh "$D"/ +cp "$ROOT"/docker-compose.api.yml "$ROOT"/config.prod.env "$ROOT"/nats.conf "$ROOT"/scripts/rolling-update.sh "$ROOT"/scripts/write-secrets.sh "$D"/ cp "$ROOT"/deploy/profiles/m.env "$D"/profile.env # Throwaway keys: ES256 signing key, and a certificate for api.example.com. @@ -44,7 +44,11 @@ export METRICS_BIND_ADDRESS=127.0.0.1 POSTGRES_PASSWORD=$(openssl rand -hex 16) export POSTGRES_PASSWORD export DATABASE_URL=postgres://auth:${POSTGRES_PASSWORD}@postgres:5432/auth -export REDIS_URL=redis://redis:6379 +REDIS_PASSWORD=$(openssl rand -hex 16) +export REDIS_PASSWORD +export REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379 +METRICS_TOKEN=$(openssl rand -hex 32) +export METRICS_TOKEN JWT_PRIVATE_KEY=$(cat "$KEYS"/jwt-private.pem) JWT_PUBLIC_KEY=$(cat "$KEYS"/jwt-public.pem) ENCRYPTION_KEY=$(openssl rand -base64 32) @@ -58,6 +62,11 @@ install -m 600 /dev/null "$D"/nats-auth.conf printf 'authorization { token: "%s" }\n' "$NATS_TOKEN" > "$D"/nats-auth.conf # Owned by root, as on the server (the broker runs without capabilities). docker run --rm --entrypoint sh -v "$D:/d" "$SHELL_IMAGE" -c 'chown 0:0 /d/nats-auth.conf && chmod 600 /d/nats-auth.conf' +# The API's secrets as files, as write-secrets.sh leaves them on the server: +# readable by the image's user only. +export AUTH_API_SECRETS_DIR="$D/secrets" +SUDO="" AUTH_API_UID=$(id -u) AUTH_API_SECRETS_DIR_OWNER=$(id -u) "$D/write-secrets.sh" >/dev/null +docker run --rm --entrypoint sh -v "$D/secrets:/s" "$SHELL_IMAGE" -c 'chown 65532:65532 /s/* && chmod 400 /s/*' cat > "$D"/override.yml <<'YML' # Smoke test only: in production the database and cache run on the DB VPS. @@ -70,7 +79,9 @@ services: networks: [auth-api] redis: image: redis:7-alpine@sha256:ff02b58f971e7d7d156a1267e283fcbbeee91773b6aa36c49dac28ecfe28eadf - healthcheck: { test: ["CMD", "redis-cli", "ping"], interval: 2s, retries: 30 } + # A password, as production requires of REDIS_URL. + command: ["redis-server", "--requirepass", "${REDIS_PASSWORD}"] + healthcheck: { test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "--no-auth-warning", "ping"], interval: 2s, retries: 30 } networks: [auth-api] YML C=(docker compose --project-directory "$D" --env-file "$D/profile.env" -f "$D/docker-compose.api.yml" -f "$D/override.yml") @@ -103,7 +114,7 @@ for svc in api-a api-b; do id=$("${C[@]}" ps -q $svc) check "$svc healthy" healthy "$(docker inspect -f '{{.State.Health.Status}}' "$id")" check "$svc CPU limit (profile M)" 3000000000 "$(docker inspect -f '{{.HostConfig.NanoCpus}}' "$id")" - check "$svc memory limit" 536870912 "$(docker inspect -f '{{.HostConfig.Memory}}' "$id")" + check "$svc memory limit" 671088640 "$(docker inspect -f '{{.HostConfig.Memory}}' "$id")" check "$svc memory reservation" 268435456 "$(docker inspect -f '{{.HostConfig.MemoryReservation}}' "$id")" check "$svc pids limit" 256 "$(docker inspect -f '{{.HostConfig.PidsLimit}}' "$id")" check "$svc stop grace" 40 "$(docker inspect -f '{{.Config.StopTimeout}}' "$id")" @@ -113,14 +124,19 @@ for svc in api-a api-b; do done check "api-a ready" 200 "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3001/ready)" check "api-b ready" 200 "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3002/ready)" -check "metrics api-a" 200 "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:9465/metrics)" -check "metrics api-b" 200 "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:9466/metrics)" -metric() { curl -s "http://127.0.0.1:$1/metrics" | awk -v m="$2" '$1==m {printf "%d", $2}'; } -check "api-a publishes its memory limit" 536870912 "$(metric 9465 auth_container_memory_limit_bytes)" +check "public ready names no dependency" '{"status":"ready"}' "$(curl -s http://127.0.0.1:3001/ready)" +BEARER=(-H "Authorization: Bearer $METRICS_TOKEN") +check "internal listener needs its token" 401 "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:9465/metrics)" +check "internal ready details dependencies" up "$(curl -s "${BEARER[@]}" http://127.0.0.1:9465/ready | python3 -c 'import json,sys; print(json.load(sys.stdin)["nats"])')" +check "metrics api-a" 200 "$(curl -s "${BEARER[@]}" -o /dev/null -w '%{http_code}' http://127.0.0.1:9465/metrics)" +check "metrics api-b" 200 "$(curl -s "${BEARER[@]}" -o /dev/null -w '%{http_code}' http://127.0.0.1:9466/metrics)" +check "no secret in the container environment" 0 "$(docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$("${C[@]}" ps -q api-a)" | grep -cE '^(DATABASE_URL|REDIS_URL|JWT_PRIVATE_KEY|ENCRYPTION_KEY|SMTP_PASSWORD|CAPTCHA_SECRET|NATS_URL|METRICS_TOKEN)=')" +metric() { curl -s "${BEARER[@]}" "http://127.0.0.1:$1/metrics" | awk -v m="$2" '$1==m {printf "%d", $2}'; } +check "api-a publishes its memory limit" 671088640 "$(metric 9465 auth_container_memory_limit_bytes)" ws=$(metric 9465 auth_container_memory_working_set_bytes) -check "api-a working set within limit" true "$([ "${ws:-0}" -gt 0 ] && [ "$ws" -lt 536870912 ] && echo true || echo false)" -check "api-b publishes CPU periods" true "$(curl -s http://127.0.0.1:9466/metrics | grep -q '^auth_container_cpu_periods_total ' && echo true || echo false)" -check "api-b publishes its start time" true "$(curl -s http://127.0.0.1:9466/metrics | grep -q '^auth_process_start_time_seconds ' && echo true || echo false)" +check "api-a working set within limit" true "$([ "${ws:-0}" -gt 0 ] && [ "$ws" -lt 671088640 ] && echo true || echo false)" +check "api-b publishes CPU periods" true "$(curl -s "${BEARER[@]}" http://127.0.0.1:9466/metrics | grep -q '^auth_container_cpu_periods_total ' && echo true || echo false)" +check "api-b publishes its start time" true "$(curl -s "${BEARER[@]}" http://127.0.0.1:9466/metrics | grep -q '^auth_process_start_time_seconds ' && echo true || echo false)" nats_metrics=false for _ in $(seq 1 10); do curl -s http://127.0.0.1:7777/metrics > "$S/nats-exporter.txt" 2>&1 @@ -131,7 +147,10 @@ check "NATS exporter" true "$nats_metrics" nats_id=$("${C[@]}" ps -q nats) check "NATS token absent from broker arguments" false "$(docker inspect -f '{{json .Args}} {{json .Config.Cmd}}' "$nats_id" | grep -q "$NATS_TOKEN" && echo true || echo false)" check "NATS memory limit" 201326592 "$(docker inspect -f '{{.HostConfig.Memory}}' "$nats_id")" -check "NATS JetStream file store cap" 1073741824 "$(docker run --rm --network auth-smoke_auth-api natsio/nats-box:0.14.5 wget -qO- http://nats:8222/jsz 2>/dev/null | python3 -c 'import sys,json; print(json.load(sys.stdin)["config"]["max_storage"])' 2>/dev/null)" +check "NATS JetStream file store cap" 1073741824 "$("${C[@]}" exec -T nats wget -qO- http://127.0.0.1:8222/jsz 2>/dev/null | python3 -c 'import sys,json; print(json.load(sys.stdin)["config"]["max_storage"])' 2>/dev/null)" +# The monitoring endpoint listens on the broker's loopback only. +docker run --rm --network auth-smoke_auth-api natsio/nats-box:0.14.5 wget -qO- -T 3 http://nats:8222/varz >/dev/null 2>&1 +check "NATS monitoring closed to the compose network" 1 $? echo "== nginx.conf in front, TLS" docker run -d --name auth-smoke-nginx --network host \ diff --git a/scripts/write-secrets.sh b/scripts/write-secrets.sh new file mode 100755 index 0000000..87dc410 --- /dev/null +++ b/scripts/write-secrets.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# Write the API's secrets, exported from pass as in docs/deploy/guides/update.md, +# to the files docker-compose.api.yml mounts as compose secrets. The instances +# read each variable X from the file named by X_FILE: the values appear neither +# in `docker inspect` nor in the process environment. +# +# Files live in AUTH_API_SECRETS_DIR (/etc/auth-api/secrets by default): a +# directory only root enters, files only the image's user (65532) reads, like +# nats-auth.conf. They survive a reboot, so Docker can restart the instances. +# An optional secret without a value is written empty and counts as unset. +# +# Each value is read from pass (prod/auth-api/, PASS_PREFIX to change it) +# straight into its file: it never sits in an exported variable, where every +# process the operator's shell starts could read it. A variable that is set +# takes precedence (tests, other secret stores). +# +# Usage (as the operator, with sudo): +# scripts/write-secrets.sh +set -euo pipefail + +DIR=${AUTH_API_SECRETS_DIR:-/etc/auth-api/secrets} +APP_UID=${AUTH_API_UID:-65532} +DIR_OWNER=${AUTH_API_SECRETS_DIR_OWNER:-root} +SUDO=${SUDO-sudo} +PASS_PREFIX=${PASS_PREFIX:-prod/auth-api} + +REQUIRED=(DATABASE_URL REDIS_URL JWT_PRIVATE_KEY JWT_PUBLIC_KEY ENCRYPTION_KEY + SMTP_USERNAME SMTP_PASSWORD CAPTCHA_SECRET NATS_URL METRICS_TOKEN) +OPTIONAL=(JWT_PREVIOUS_PUBLIC_KEY JWT_NEXT_PUBLIC_KEY PREVIOUS_ENCRYPTION_KEY) + +# The value of a secret: its variable if set, else its pass entry, else empty. +value_of() { + local name=$1 + if [[ -n "${!name:-}" ]]; then + printf '%s' "${!name}" + elif command -v pass >/dev/null 2>&1; then + # A local, unexported variable: trailing newlines go, as with `$(pass ...)`. + local value + value=$(pass show "$PASS_PREFIX/$(tr '[:upper:]_' '[:lower:]-' <<<"$name")" 2>/dev/null || true) + printf '%s' "$value" + fi +} + +missing=() +for name in "${REQUIRED[@]}"; do + [[ -n "$(value_of "$name" | head -c 1)" ]] || missing+=("$name") +done +if ((${#missing[@]})); then + echo "write-secrets: no value for ${missing[*]} in pass ($PASS_PREFIX) (docs/deploy/guides/update.md)" >&2 + exit 1 +fi + +$SUDO install -d -m 700 -o "$DIR_OWNER" -g "$DIR_OWNER" "$DIR" +for name in "${REQUIRED[@]}" "${OPTIONAL[@]}"; do + file="$DIR/$(tr '[:upper:]' '[:lower:]' <<<"$name")" + value_of "$name" | $SUDO install -m 400 -o "$APP_UID" -g "$APP_UID" /dev/stdin "$file" +done +echo "write-secrets: ${#REQUIRED[@]} required and ${#OPTIONAL[@]} optional secrets written to $DIR" diff --git a/src/bin/bench_http.rs b/src/bin/bench_http.rs index f0c13c5..8978374 100644 --- a/src/bin/bench_http.rs +++ b/src/bin/bench_http.rs @@ -1891,7 +1891,10 @@ async fn inject_known_otp_into_flow( redis: &auth_api::utils::redis_pool::RedisPool, flow_token: &str, ) { - let key = format!("email_change_flow:{}", flow_token); + let key = format!( + "email_change_flow:{}", + auth_api::utils::crypto::token_id(flow_token) + ); if let Ok(mut conn) = redis.get().await && let Ok(raw) = conn.get::<_, String>(&key).await && let Ok(mut val) = serde_json::from_str::(&raw) @@ -1919,8 +1922,8 @@ async fn cancel_email_change_flow_bench(state: &AppState, user_id: Uuid) { if let Ok(mut conn) = state.redis.get().await { let active_key = format!("email_change_active:{}", user_id); if let Ok(Some(old_token)) = conn.get::<_, Option>(&active_key).await { - let _: Result<(), _> = conn.del(format!("email_change_flow:{}", old_token)).await; - let _: Result<(), _> = conn.del(format!("email_change_fail:{}", old_token)).await; + let _: Result<(), _> = conn.del(format!("email_change_flow:{old_token}")).await; + let _: Result<(), _> = conn.del(format!("email_change_fail:{old_token}")).await; } let _: Result<(), _> = conn.del(&active_key).await; } diff --git a/src/bin/bench_support.rs b/src/bin/bench_support.rs index 2ab039e..704a97a 100644 --- a/src/bin/bench_support.rs +++ b/src/bin/bench_support.rs @@ -382,12 +382,16 @@ fn fallback_config(db_url: &str, redis_url: &str) -> Config { sensitive_action_reauth_secs: 600, new_device_alerts: true, magic_links: false, + registrations_per_ip_per_hour: 10_000, + reset_revokes_factors_added_hours: 72, }, captcha: CaptchaConfig { secret: None, verify_url: "https://hcaptcha.com/siteverify".into(), request_timeout_secs: 1, fail_open_on_error: true, + site_key: None, + expected_hostnames: Vec::new(), }, cors: CorsConfig { allowed_origins: vec!["*".into()], @@ -454,7 +458,9 @@ fn fallback_config(db_url: &str, redis_url: &str) -> Config { }, metrics: MetricsConfig { enabled: false, + host: "127.0.0.1".into(), port: 9464, + token: None, }, } } diff --git a/src/bin/perf_load.rs b/src/bin/perf_load.rs index 37079bb..f3c9110 100644 --- a/src/bin/perf_load.rs +++ b/src/bin/perf_load.rs @@ -874,6 +874,7 @@ async fn db_step(pool: &PgPool, scenario: &str, rng: &mut Rng, users: u64) -> Re client_id: None, family_created_at: None, scopes: None, + mfa: false, }, ) .await?; diff --git a/src/cli.rs b/src/cli.rs index ba4df32..c39d16c 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -7,7 +7,7 @@ use crate::{ domain::audit::AuditAction, repositories::{ audit::{self, NewAuditEntry}, - registered_client::NewRegisteredClient, + registered_client::{self, NewRegisteredClient}, role, user, }, }; @@ -170,31 +170,143 @@ pub async fn grant_role(pool: &PgPool, grant: &RoleGrant) -> Result<(), String> .map_err(|e| e.to_string())? .ok_or_else(|| format!("no role named {}", grant.role))?; - match role::assign_to_user(pool, account.id, granted.id, None).await { + // The check, the grant and its audit entry commit together, under the + // account's lock: no role without its trace, no factor removed meanwhile. + let mut tx = pool.begin().await.map_err(|e| e.to_string())?; + user::lock_row(&mut *tx, account.id) + .await + .map_err(|e| e.to_string())?; + // As over HTTP: the administration refuses every session that did not + // prove a second factor, so the account must have one first. + if role::grants_administration(&mut *tx, granted.id) + .await + .map_err(|e| e.to_string())? + { + let ready = account.status == crate::domain::user::UserStatus::Active + && user::has_second_factor(&mut *tx, account.id) + .await + .map_err(|e| e.to_string())?; + if !ready { + return Err(format!( + "{} must be an active account with a verified second factor or a passkey \ + before it receives the {} role: sign in and enroll one first", + grant.email, granted.name + )); + } + } + + match role::assign_to_user(&mut *tx, account.id, granted.id, None).await { Ok(_) => {} // ON CONFLICT DO NOTHING returns no row: the role was already held. Err(sqlx::Error::RowNotFound) => return Ok(()), Err(e) => return Err(e.to_string()), } audit::append( - pool, + &mut *tx, &NewAuditEntry { user_id: Some(account.id), request_id: None, action: AuditAction::RoleAssigned, ip_address: None, - metadata: json!({ "role": granted.name, "by": "command_line" }), + metadata: { + let mut metadata = command_line_origin(); + metadata["role"] = json!(granted.name); + metadata + }, }, ) .await .map_err(|e| e.to_string())?; + tx.commit().await.map_err(|e| e.to_string())?; Ok(()) } +/// Who ran a command-line change: the account on the server (the one behind +/// `sudo` when there is one) and the host. Command-line changes have no +/// administrator account; this is what the audit log can tell of them. +pub fn command_line_origin() -> serde_json::Value { + let operator = ["SUDO_USER", "USER", "LOGNAME"] + .iter() + .find_map(|name| std::env::var(name).ok().filter(|v| !v.is_empty())) + .unwrap_or_else(|| "unknown".to_owned()); + let host = std::env::var("HOSTNAME") + .ok() + .or_else(|| std::fs::read_to_string("/etc/hostname").ok()) + .map(|h| h.trim().to_owned()) + .filter(|h| !h.is_empty()) + .unwrap_or_else(|| "unknown".to_owned()); + json!({ "by": "command_line", "operator": operator, "host": host }) +} + +/// Save a client application from the command line, validated like the +/// administration does and audited in the same transaction. +pub async fn register_client( + pool: &PgPool, + registration: &ClientRegistration, +) -> Result { + let client = registration.as_new(); + crate::domain::registered_client::check_settings( + client.client_id, + client.display_name, + client.redirect_uris, + client.default_max_sessions, + )?; + let unknown = role::unknown_permissions(pool, client.scopes) + .await + .map_err(|e| e.to_string())?; + if !unknown.is_empty() { + return Err(format!("unknown scopes: {}", unknown.join(", "))); + } + + let mut tx = pool.begin().await.map_err(|e| e.to_string())?; + let existed = registered_client::lock_existing(&mut *tx, client.client_id) + .await + .map_err(|e| e.to_string())?; + let previous = registered_client::find_by_id(&mut *tx, client.client_id) + .await + .map_err(|e| e.to_string())?; + let saved = registered_client::upsert(&mut *tx, &client) + .await + .map_err(|e| e.to_string())?; + audit::append( + &mut *tx, + &NewAuditEntry { + user_id: None, + request_id: None, + action: if existed { + AuditAction::ClientUpdated + } else { + AuditAction::ClientRegistered + }, + ip_address: None, + metadata: { + let mut metadata = + crate::domain::registered_client::audit_changes(previous.as_ref(), &saved); + for (key, value) in command_line_origin().as_object().unwrap() { + metadata[key] = value.clone(); + } + metadata + }, + }, + ) + .await + .map_err(|e| e.to_string())?; + tx.commit().await.map_err(|e| e.to_string())?; + Ok(saved) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn a_command_line_change_names_its_operator_and_host() { + let origin = command_line_origin(); + assert_eq!(origin["by"], "command_line"); + assert!(origin["operator"].as_str().is_some_and(|o| !o.is_empty())); + assert!(origin["host"].as_str().is_some_and(|h| !h.is_empty())); + } + fn args(line: &str) -> Vec { line.split(' ').map(str::to_owned).collect() } diff --git a/src/config/env_vars.rs b/src/config/env_vars.rs index 00706e8..cfd4b1e 100644 --- a/src/config/env_vars.rs +++ b/src/config/env_vars.rs @@ -4,7 +4,7 @@ //! production, a map in tests, so loading is tested without mutating the //! environment of a running process. -use std::str::FromStr; +use std::{collections::HashMap, str::FromStr}; use ipnetwork::IpNetwork; @@ -19,8 +19,11 @@ impl Option> Env { Self { lookup } } + /// A required variable. Blank counts as missing, like everywhere else: a + /// required secret set to "" must stop the start, not pass as configured. pub(super) fn require(&self, key: &str) -> Result { - (self.lookup)(key).ok_or_else(|| ConfigError::Missing(key.into())) + self.string(key) + .ok_or_else(|| ConfigError::Missing(key.into())) } /// Optional string variable. A blank value counts as unset: a secret that @@ -74,6 +77,57 @@ impl Option> Env { } } +/// The values of the variables given as `X_FILE`, keyed by `X`: the content +/// of the file, without the trailing newline editors and `echo` add. Setting +/// both `X` and `X_FILE` is refused: which one wins would be a guess. A file +/// that does not exist counts as an unset variable, like an empty one: Docker +/// mounts no file for a secret whose value is empty, such as the previous key +/// outside a rotation. A required variable then stops the start as missing. +pub(super) fn values_from_files( + vars: impl IntoIterator, + read: impl Fn(&str) -> std::io::Result, +) -> Result, ConfigError> { + let vars: HashMap = vars.into_iter().collect(); + let mut values = HashMap::new(); + for (file_key, path) in &vars { + let Some(key) = file_key.strip_suffix("_FILE") else { + continue; + }; + if key.is_empty() || path.trim().is_empty() { + continue; + } + if vars.get(key).is_some_and(|value| !value.trim().is_empty()) { + return Err(ConfigError::Invalid { + key: file_key.clone(), + reason: format!("{key} is also set: give one of them"), + }); + } + let content = match read(path) { + Ok(content) => content, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, + Err(e) => { + return Err(ConfigError::Invalid { + key: file_key.clone(), + reason: format!("cannot read '{path}': {e}"), + }); + } + }; + let value = content.strip_suffix('\n').unwrap_or(&content); + let value = value.strip_suffix('\r').unwrap_or(value); + values.insert(key.to_owned(), value.to_owned()); + } + Ok(values) +} + +/// The value of a variable: set in the environment, or read from its +/// `X_FILE`. A blank variable counts as unset and leaves the file its place, +/// as `values_from_files` already judged it. +pub(super) fn env_or_file(value: Option, from_file: Option<&String>) -> Option { + value + .filter(|value| !value.trim().is_empty()) + .or_else(|| from_file.cloned()) +} + pub(super) fn default_argon2_max_concurrency() -> u32 { std::thread::available_parallelism() .map(|n| n.get() as u32) diff --git a/src/config/mod.rs b/src/config/mod.rs index 3ea9c2e..6764ffc 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -13,12 +13,13 @@ mod env_vars; #[cfg(test)] mod tests; mod validate; +pub use validate::{MAX_TOTP_SKEW, weakened_settings}; use env_vars::*; #[derive(Debug, thiserror::Error)] pub enum ConfigError { - #[error("missing required env var: {0}")] + #[error("missing required env var: {0} (or {0}_FILE)")] Missing(String), #[error("invalid value for '{key}': {reason}")] Invalid { key: String, reason: String }, @@ -189,6 +190,14 @@ pub struct SecurityConfig { /// Offer sign-in links by email (`/auth/magic-link`). Whoever reads the /// mailbox can then sign in without the password, so it is off by default. pub magic_links: bool, + /// Registrations accepted per client address (IPv6 /64) per hour; 0 + /// removes the budget. Bounds how fast usernames can be squatted with + /// throwaway addresses. Default: 20. + pub registrations_per_ip_per_hour: u32, + /// A password reset removes the second factors, passkeys and external + /// identities added in this many hours before it was asked for: whoever + /// held the password may have planted them. 0 keeps them. Default: 72. + pub reset_revokes_factors_added_hours: u32, } #[derive(Debug, Clone, PartialEq)] @@ -253,7 +262,7 @@ pub struct CleanupConfig { /// Grace period in days after recovery code expiry before deletion. Default: 7. pub recovery_codes_grace_days: u32, /// Age in days after which an account whose address was never verified is - /// deleted; 0 keeps them. Default: 7. + /// deleted; 0 keeps them. Default: 2. pub unverified_accounts_retention_days: u32, /// Days after which a device unseen is forgotten (a sign-in from it alerts /// again). Default: 90. @@ -355,6 +364,12 @@ pub struct CaptchaConfig { pub request_timeout_secs: u64, /// When true, network/5xx errors from the CAPTCHA provider allow the request through. pub fail_open_on_error: bool, + /// Site key of the widget, sent with each verification so a token solved + /// for another site key is refused. + pub site_key: Option, + /// Hostnames a solved challenge may come from; empty means the host of + /// `FRONTEND_URL`. + pub expected_hostnames: Vec, } #[derive(Debug, Clone)] @@ -366,14 +381,33 @@ pub struct CorsConfig { pub allow_credentials: bool, } -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct MetricsConfig { /// When true, Prometheus metrics are collected and served on `port`. pub enabled: bool, + /// Address of the internal listener (`METRICS_HOST`, default + /// `SERVER_HOST`): outside a container, `127.0.0.1` keeps it off every + /// other interface. + pub host: String, /// Port of the internal metrics listener (`/metrics`). Conventionally 9464 /// (Prometheus exporter range). Must never be exposed publicly: publish it /// on loopback only in docker-compose, never through the reverse proxy. pub port: u16, + /// Bearer token the internal listener requires (`METRICS_TOKEN`): the + /// traffic per route and the state of each dependency are not for every + /// host of the private network. Required in production. + pub token: Option, +} + +impl std::fmt::Debug for MetricsConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("MetricsConfig") + .field("enabled", &self.enabled) + .field("host", &self.host) + .field("port", &self.port) + .field("token", &self.token.as_ref().map(|_| "")) + .finish() + } } #[derive(Debug, Clone)] @@ -423,9 +457,14 @@ pub struct Config { impl Config { /// Load configuration from environment variables. /// Silently ignores a missing `.env` file; production relies on real env vars. + /// + /// Any variable `X` may instead be given as `X_FILE`, the path of a file + /// holding its value (a Docker or systemd secret), so secrets stay out of + /// the environment that `docker inspect` and `/proc` show. pub fn from_env() -> Result { dotenvy::dotenv().ok(); - Self::from_lookup(|key| std::env::var(key).ok()) + let files = values_from_files(std::env::vars(), |path| std::fs::read_to_string(path))?; + Self::from_lookup(|key| env_or_file(std::env::var(key).ok(), files.get(key))) } /// Load configuration from `lookup`, which returns the value of a variable: @@ -538,6 +577,12 @@ impl Config { .unwrap_or(600), new_device_alerts: vars.parse("NEW_DEVICE_ALERTS_ENABLED")?.unwrap_or(true), magic_links: vars.parse("MAGIC_LINK_ENABLED")?.unwrap_or(false), + registrations_per_ip_per_hour: vars + .parse("REGISTRATIONS_PER_IP_PER_HOUR")? + .unwrap_or(20), + reset_revokes_factors_added_hours: vars + .parse("RESET_REVOKES_FACTORS_ADDED_HOURS")? + .unwrap_or(72), }, mail: MailConfig { smtp: SmtpConfig { @@ -564,6 +609,8 @@ impl Config { .unwrap_or_else(|| "https://hcaptcha.com/siteverify".into()), request_timeout_secs: vars.parse("CAPTCHA_TIMEOUT_SECS")?.unwrap_or(5), fail_open_on_error: vars.parse("CAPTCHA_FAIL_OPEN")?.unwrap_or(!is_production), + site_key: vars.string("CAPTCHA_SITE_KEY"), + expected_hostnames: vars.csv("CAPTCHA_EXPECTED_HOSTNAMES").unwrap_or_default(), }, pwned_passwords: PwnedPasswordsConfig { enabled: vars.parse("PWNED_PASSWORDS_ENABLED")?.unwrap_or(true), @@ -641,7 +688,7 @@ impl Config { .unwrap_or(7), unverified_accounts_retention_days: vars .parse("CLEANUP_UNVERIFIED_ACCOUNT_DAYS")? - .unwrap_or(7), + .unwrap_or(2), known_devices_retention_days: vars .parse("CLEANUP_KNOWN_DEVICE_DAYS")? .unwrap_or(90), @@ -685,7 +732,12 @@ impl Config { }, metrics: MetricsConfig { enabled: vars.parse("METRICS_ENABLED")?.unwrap_or(true), + host: vars + .string("METRICS_HOST") + .or_else(|| vars.string("SERVER_HOST")) + .unwrap_or_else(|| "0.0.0.0".into()), port: vars.parse("METRICS_PORT")?.unwrap_or(9464), + token: vars.string("METRICS_TOKEN"), }, }; diff --git a/src/config/tests.rs b/src/config/tests.rs index 9a16993..28494be 100644 --- a/src/config/tests.rs +++ b/src/config/tests.rs @@ -17,7 +17,7 @@ fn valid_config() -> Config { port: 3000, public_url: "https://api.example.com".into(), frontend_url: "https://api.example.com".into(), - trusted_proxy_cidrs: vec!["10.0.0.0/8".parse().unwrap()], + trusted_proxy_cidrs: vec!["10.0.0.1/32".parse().unwrap()], }, database: DatabaseConfig { url: "postgres://user:pass@localhost/db".into(), @@ -27,7 +27,7 @@ fn valid_config() -> Config { read_url: None, }, redis: RedisConfig { - url: "redis://127.0.0.1:6379".into(), + url: "redis://auth_api:redis-pass@127.0.0.1:6379".into(), pool_size: 5, wait_timeout_ms: 2000, }, @@ -48,8 +48,8 @@ fn valid_config() -> Config { audience: vec!["https://core.example.com".into()], }, crypto: CryptoConfig { - argon2_memory_kib: 8192, - argon2_iterations: 1, + argon2_memory_kib: 19_456, + argon2_iterations: 2, argon2_parallelism: 1, argon2_max_concurrency: 4, totp_issuer: "test".into(), @@ -70,6 +70,8 @@ fn valid_config() -> Config { sensitive_action_reauth_secs: 600, new_device_alerts: true, magic_links: false, + registrations_per_ip_per_hour: 20, + reset_revokes_factors_added_hours: 72, }, mail: MailConfig { smtp: SmtpConfig { @@ -92,6 +94,8 @@ fn valid_config() -> Config { verify_url: "https://hcaptcha.com/siteverify".into(), request_timeout_secs: 5, fail_open_on_error: false, + site_key: None, + expected_hostnames: Vec::new(), }, pwned_passwords: PwnedPasswordsConfig { enabled: true, @@ -142,7 +146,9 @@ fn valid_config() -> Config { }, metrics: MetricsConfig { enabled: true, + host: "127.0.0.1".into(), port: 9464, + token: Some("metrics-token-0123456789abcdef0123456789".into()), }, } } @@ -1089,3 +1095,291 @@ fn validate_rejects_an_even_number_of_stream_replicas() { .expect_err("2 replicas cannot hold a quorum"); assert!(matches!(err, ConfigError::Invalid { key, .. } if key == "NATS_STREAM_REPLICAS")); } + +#[test] +fn validate_bounds_the_totp_skew_to_what_the_replay_table_covers() { + let mut crypto = valid_config().crypto; + crypto.totp_skew = 2; + let err = validate_crypto(&crypto).expect_err("skew 2"); + assert!(matches!(err, ConfigError::Invalid { key, .. } if key == "TOTP_SKEW")); + crypto.totp_skew = 1; + assert!(validate_crypto(&crypto).is_ok()); +} + +#[test] +fn validate_refuses_weak_argon2_parameters_in_production_only() { + for (field, key) in [ + ("memory", "ARGON2_MEMORY_KIB"), + ("iterations", "ARGON2_ITERATIONS"), + ] { + let mut config = valid_config(); + match field { + "memory" => config.crypto.argon2_memory_kib = 8, + _ => config.crypto.argon2_iterations = 1, + } + let err = config.validate().expect_err(field); + assert!( + matches!(err, ConfigError::Invalid { key: k, .. } if k == key), + "{field}" + ); + } + let mut development = valid_config(); + development.env = Environment::Development; + development.crypto.argon2_memory_kib = 8; + development.crypto.argon2_iterations = 1; + assert!(validate_production_argon2(&development.crypto).is_err()); + assert!( + validate_crypto(&development.crypto).is_ok(), + "only production refuses them" + ); +} + +#[test] +fn validate_bounds_lifetimes_windows_and_limits() { + type Change = fn(&mut Config); + let cases: [(&str, Change); 6] = [ + ("JWT_ACCESS_EXPIRY_SECS", |c| c.jwt.access_expiry_secs = 30), + ("JWT_ACCESS_EXPIRY_SECS", |c| { + c.jwt.access_expiry_secs = 86_400 + }), + ("JWT_SHORT_SESSION_EXPIRY_SECS", |c| { + c.jwt.short_session_expiry_secs = 0 + }), + ("JWT_REFRESH_EXPIRY_SECS", |c| { + c.jwt.refresh_expiry_secs = 60; + c.jwt.short_session_expiry_secs = 3600; + }), + ("SENSITIVE_ACTION_REAUTH_SECS", |c| { + c.security.sensitive_action_reauth_secs = 86_400 + }), + ("RATE_LIMIT_RPM", |c| c.rate_limit.requests_per_minute = 0), + ]; + for (expected, change) in cases { + let mut config = valid_config(); + change(&mut config); + let err = config.validate().expect_err(expected); + assert!( + matches!(&err, ConfigError::Invalid { key, .. } if key == expected), + "{expected}: {err:?}" + ); + } +} + +fn file_vars(pairs: &[(&str, &str)]) -> Vec<(String, String)> { + pairs + .iter() + .map(|(k, v)| ((*k).to_owned(), (*v).to_owned())) + .collect() +} + +#[test] +fn a_variable_can_come_from_a_file() { + let read = |path: &str| match path { + "/run/secrets/smtp" => Ok("s3cret\n".to_owned()), + "/run/secrets/key" => Ok("-----BEGIN-----\nabc\n-----END-----\r\n".to_owned()), + _ => Err(std::io::Error::from(std::io::ErrorKind::NotFound)), + }; + let values = values_from_files( + file_vars(&[ + ("SMTP_PASSWORD_FILE", "/run/secrets/smtp"), + ("JWT_PRIVATE_KEY_FILE", "/run/secrets/key"), + ("SMTP_HOST", "smtp.example.com"), + ("UNUSED_FILE", ""), + ("PREVIOUS_ENCRYPTION_KEY_FILE", "/run/secrets/absent"), + ]), + read, + ) + .unwrap(); + assert_eq!(values.get("SMTP_PASSWORD").unwrap(), "s3cret"); + assert_eq!( + values.get("JWT_PRIVATE_KEY").unwrap(), + "-----BEGIN-----\nabc\n-----END-----" + ); + assert_eq!(values.len(), 2); +} + +#[test] +fn a_variable_and_its_file_together_are_refused() { + let read = |_: &str| Ok("from-file".to_owned()); + let error = values_from_files( + file_vars(&[("ENCRYPTION_KEY", "inline"), ("ENCRYPTION_KEY_FILE", "/k")]), + read, + ) + .unwrap_err(); + assert!( + matches!(&error, ConfigError::Invalid { key, .. } if key == "ENCRYPTION_KEY_FILE"), + "{error}" + ); +} + +#[test] +fn an_unreadable_secret_file_stops_the_start() { + let read = |_: &str| Err(std::io::Error::from(std::io::ErrorKind::PermissionDenied)); + let error = + values_from_files(file_vars(&[("CAPTCHA_SECRET_FILE", "/nope")]), read).unwrap_err(); + assert!(error.to_string().contains("CAPTCHA_SECRET_FILE"), "{error}"); +} + +#[test] +fn a_blank_variable_defers_to_its_file() { + let file = "from-file".to_owned(); + assert_eq!( + env_or_file(Some(" ".into()), Some(&file)), + Some(file.clone()) + ); + assert_eq!( + env_or_file(Some("set".into()), Some(&file)), + Some("set".into()) + ); + assert_eq!(env_or_file(None, None), None); +} + +#[test] +fn validate_rejects_production_connections_without_a_password() { + let mut config = valid_config(); + config.database.url = "postgres://auth_api@10.0.0.2/auth_api".into(); + assert!( + matches!(config.validate(), Err(ConfigError::Invalid { key, .. }) if key == "DATABASE_URL") + ); + + let mut config = valid_config(); + config.redis.url = "redis://10.0.0.2:6379".into(); + assert!( + matches!(config.validate(), Err(ConfigError::Invalid { key, .. }) if key == "REDIS_URL") + ); +} + +#[test] +fn validate_rejects_settings_that_undo_their_control() { + let invalid_key = |config: Config| match config.validate() { + Err(ConfigError::Invalid { key, .. }) => key, + other => panic!("expected a refusal, got {other:?}"), + }; + + let mut config = valid_config(); + config.security.lockout_duration_secs = 0; + assert_eq!(invalid_key(config), "LOCKOUT_DURATION_SECS"); + + let mut config = valid_config(); + config.device_auth.ttl_secs = 86_400; + assert_eq!(invalid_key(config), "DEVICE_AUTH_TTL_SECS"); + + let mut config = valid_config(); + config.device_auth.verification_uri = "http://auth.example.com/device".into(); + assert_eq!(invalid_key(config), "DEVICE_AUTH_VERIFICATION_URI"); + + for wide in [ + "0.0.0.0/0", + "::/0", + "10.0.0.0/8", + "172.30.0.0/24", + "fd00::/64", + ] { + let mut config = valid_config(); + config.server.trusted_proxy_cidrs = vec![wide.parse().unwrap()]; + assert_eq!(invalid_key(config), "TRUSTED_PROXY_CIDRS", "{wide}"); + } +} + +#[test] +fn a_blank_required_variable_is_missing() { + let result = load(&[("SMTP_PASSWORD", "")], &[]); + assert!( + matches!(&result, Err(ConfigError::Missing(key)) if key == "SMTP_PASSWORD"), + "{result:?}" + ); +} + +#[test] +fn validate_rejects_production_settings_past_their_ceiling() { + let invalid_key = |config: Config| match config.validate() { + Err(ConfigError::Invalid { key, .. }) => key, + other => panic!("expected a refusal, got {other:?}"), + }; + let mut config = valid_config(); + config.security.lockout_threshold = 100_000; + assert_eq!(invalid_key(config), "LOCKOUT_THRESHOLD"); + let mut config = valid_config(); + config.rate_limit.auth_requests_per_minute = 200_000; + assert_eq!(invalid_key(config), "RATE_LIMIT_AUTH_RPM"); + let mut config = valid_config(); + config.jwt.refresh_expiry_secs = 3 * 365 * 86_400; + config.jwt.max_session_lifetime_secs = 3 * 365 * 86_400; + assert_eq!(invalid_key(config), "JWT_REFRESH_EXPIRY_SECS"); + let mut config = valid_config(); + config.security.registrations_per_ip_per_hour = 0; + assert_eq!(invalid_key(config), "REGISTRATIONS_PER_IP_PER_HOUR"); + let mut config = valid_config(); + config.pwned_passwords.enabled = false; + assert_eq!(invalid_key(config), "PWNED_PASSWORDS_ENABLED"); +} + +#[test] +fn validate_rejects_a_production_key_that_is_text() { + use base64::Engine; + let mut config = valid_config(); + config.crypto.encryption_key = + base64::engine::general_purpose::STANDARD.encode("correct horse battery staple 42!"); + + let err = config.validate().expect_err("a passphrase is not a key"); + match err { + ConfigError::Invalid { key, reason } => { + assert_eq!(key, "ENCRYPTION_KEY"); + assert!(reason.contains("printable text"), "reason: {reason}"); + } + other => panic!("unexpected error: {other:?}"), + } +} + +#[test] +fn validate_rejects_cors_entries_that_are_not_origins() { + for entry in [ + "https://app.example.com/", + "https://app.example.com/login", + "https://app.example.com?x=1", + ] { + let mut config = valid_config(); + config.cors.allowed_origins = vec![entry.into()]; + let err = config.validate().expect_err(entry); + match err { + ConfigError::Invalid { key, reason } => { + assert_eq!(key, "CORS_ALLOWED_ORIGINS"); + assert!(reason.contains("https://app.example.com"), "{reason}"); + } + other => panic!("unexpected error: {other:?}"), + } + } +} + +#[test] +fn production_bounds_recovery_codes_and_names_weakened_settings() { + let invalid_key = |config: Config| match config.validate() { + Err(ConfigError::Invalid { key, .. }) => key, + other => panic!("expected an invalid setting, got {other:?}"), + }; + for days in [0, 731] { + let mut config = valid_config(); + config.crypto.recovery_code_expiry_days = days; + assert_eq!(invalid_key(config), "RECOVERY_CODE_EXPIRY_DAYS", "{days}"); + } + + let mut config = valid_config(); + assert!(super::weakened_settings(&config).is_empty()); + config.security.magic_links = true; + config.security.reset_revokes_factors_added_hours = 0; + config.security.new_device_alerts = false; + config.audit.retention_months = 0; + let keys: Vec<&str> = super::weakened_settings(&config) + .into_iter() + .map(|(key, _)| key) + .collect(); + assert_eq!( + keys, + [ + "MAGIC_LINK_ENABLED", + "RESET_REVOKES_FACTORS_ADDED_HOURS", + "NEW_DEVICE_ALERTS_ENABLED", + "AUDIT_LOG_RETENTION_MONTHS" + ] + ); +} diff --git a/src/config/validate.rs b/src/config/validate.rs index 79cdc7e..dc63368 100644 --- a/src/config/validate.rs +++ b/src/config/validate.rs @@ -23,6 +23,7 @@ impl Config { validate_device_auth(&self.device_auth)?; validate_session_lifetime(&self.jwt)?; validate_crypto(&self.crypto)?; + validate_rate_limits(&self.rate_limit)?; validate_jwt_audience(&self.jwt.audience, self.is_production())?; @@ -53,6 +54,10 @@ impl Config { validate_https_url("APP_PUBLIC_URL", &self.server.public_url)?; validate_https_url("FRONTEND_URL", &self.server.frontend_url)?; validate_https_url("OAUTH_CONSENT_URI", &self.device_auth.consent_uri)?; + validate_https_url( + "DEVICE_AUTH_VERIFICATION_URI", + &self.device_auth.verification_uri, + )?; // TLS terminates at a reverse proxy in production. With no trusted // CIDR every request resolves to the proxy's address: one rate-limit @@ -63,7 +68,24 @@ impl Config { reason: "must not be empty in production -- without it every client is rate-limited and audited as the reverse proxy".into(), }); } + if let Some(wide) = self.server.trusted_proxy_cidrs.iter().find(|cidr| { + cidr.prefix() + < if cidr.is_ipv4() { + MIN_TRUSTED_PROXY_PREFIX_V4 + } else { + MIN_TRUSTED_PROXY_PREFIX_V6 + } + }) { + return Err(ConfigError::Invalid { + key: "TRUSTED_PROXY_CIDRS".into(), + reason: format!( + "{wide} is a network -- name each reverse proxy by its address (/32, /128), or any other host in it can forge X-Forwarded-For" + ), + }); + } + validate_production_ceilings(self)?; + validate_production_argon2(&self.crypto)?; validate_production_encryption_key("ENCRYPTION_KEY", &self.crypto.encryption_key)?; if let Some(previous) = self.crypto.previous_encryption_key.as_deref() { validate_production_encryption_key("PREVIOUS_ENCRYPTION_KEY", previous)?; @@ -114,6 +136,10 @@ impl Config { }); } + // PWNED_PASSWORDS_FAIL_OPEN stays allowed in production, unlike the + // other fail-open switches: failing closed would make registration, + // password changes and resets depend on a third-party service. An + // alert (AuthApiPwnedPasswordsUnavailable) shows the degraded mode. if self.pwned_passwords.enabled { validate_https_url("PWNED_PASSWORDS_URL", &self.pwned_passwords.api_url)?; } @@ -145,6 +171,40 @@ impl Config { }); } + // The internal listener answers only to the monitoring's token. + if self.metrics.enabled + && self + .metrics + .token + .as_deref() + .is_none_or(|token| token.len() < MIN_METRICS_TOKEN_LEN) + { + return Err(ConfigError::Invalid { + key: "METRICS_TOKEN".into(), + reason: format!( + "must be set in production, at least {MIN_METRICS_TOKEN_LEN} characters (openssl rand -hex 32)" + ), + }); + } + + // The database and the cache sit behind the private network only; + // a role or a user without a password would leave them open to + // anything that reaches that network. + for (key, url) in [ + ("DATABASE_URL", Some(self.database.url.as_str())), + ("DATABASE_READ_URL", self.database.read_url.as_deref()), + ("REDIS_URL", Some(self.redis.url.as_str())), + ] { + if let Some(url) = url + && !url_has_password(url) + { + return Err(ConfigError::Invalid { + key: key.into(), + reason: "must carry a password in production".into(), + }); + } + } + // Hardened-default switches: in production these MUST be set to the // secure value, even if an env override re-enables the permissive // behaviour. Refuse to boot rather than start in a degraded state. @@ -291,6 +351,16 @@ pub(super) fn validate_production_encryption_key( reason: format!("must be valid base64: {e}"), })?; + // Text encoded as base64 (a passphrase, a sentence) is 32 printable ASCII + // bytes: guessable from a dictionary, and all but impossible for random + // bytes ((95/256)^32, about 10^-14). + if decoded.iter().all(|b| (0x20..=0x7e).contains(b)) { + return Err(ConfigError::Invalid { + key: key_name.into(), + reason: "key is printable text, not random bytes: use a cryptographically random key (e.g. openssl rand -base64 32)".into(), + }); + } + let stride = decoded .windows(2) .next() @@ -373,7 +443,155 @@ pub(super) fn validate_crypto(crypto: &CryptoConfig) -> Result<(), ConfigError> reason: "must be greater than 0".into(), }); } + // A code is accepted over (2 * skew + 1) steps of 30 seconds, and consumed + // codes are remembered for 90 seconds: a wider skew would let a used code + // be replayed once its record is gone. + if crypto.totp_skew > MAX_TOTP_SKEW { + return Err(ConfigError::Invalid { + key: "TOTP_SKEW".into(), + reason: format!("must be at most {MAX_TOTP_SKEW}"), + }); + } + + Ok(()) +} + +/// Widest TOTP skew the replay table covers (see `used_totp_codes`). +pub const MAX_TOTP_SKEW: u8 = 1; + +/// Argon2id floors in production (OWASP: 19 MiB and 2 iterations): lower +/// values make every stored hash cheap to crack. +pub const MIN_ARGON2_MEMORY_KIB: u32 = 19_456; +pub const MIN_ARGON2_ITERATIONS: u32 = 2; + +pub(super) fn validate_production_argon2(crypto: &CryptoConfig) -> Result<(), ConfigError> { + if crypto.argon2_memory_kib < MIN_ARGON2_MEMORY_KIB { + return Err(ConfigError::Invalid { + key: "ARGON2_MEMORY_KIB".into(), + reason: format!("must be at least {MIN_ARGON2_MEMORY_KIB} in production"), + }); + } + if crypto.argon2_iterations < MIN_ARGON2_ITERATIONS { + return Err(ConfigError::Invalid { + key: "ARGON2_ITERATIONS".into(), + reason: format!("must be at least {MIN_ARGON2_ITERATIONS} in production"), + }); + } + if crypto.argon2_parallelism == 0 { + return Err(ConfigError::Invalid { + key: "ARGON2_PARALLELISM".into(), + reason: "must be at least 1".into(), + }); + } + Ok(()) +} + +/// Settings a typo could push so far that their control no longer works: +/// refused in production. +pub(super) fn validate_production_ceilings(config: &Config) -> Result<(), ConfigError> { + let too_high = |key: &str, max: String| ConfigError::Invalid { + key: key.into(), + reason: format!("must be at most {max} in production"), + }; + if config.security.lockout_threshold > MAX_LOCKOUT_THRESHOLD { + return Err(too_high( + "LOCKOUT_THRESHOLD", + MAX_LOCKOUT_THRESHOLD.to_string(), + )); + } + for (key, value) in [ + ("RATE_LIMIT_RPM", config.rate_limit.requests_per_minute), + ( + "RATE_LIMIT_AUTH_RPM", + config.rate_limit.auth_requests_per_minute, + ), + ] { + if value > MAX_REQUESTS_PER_MINUTE { + return Err(too_high(key, MAX_REQUESTS_PER_MINUTE.to_string())); + } + } + for (key, value) in [ + ("JWT_REFRESH_EXPIRY_SECS", config.jwt.refresh_expiry_secs), + ( + "JWT_MAX_SESSION_LIFETIME_SECS", + config.jwt.max_session_lifetime_secs, + ), + ] { + if value > MAX_SESSION_SECS { + return Err(too_high(key, format!("{MAX_SESSION_SECS} (a year)"))); + } + } + if config.security.registrations_per_ip_per_hour == 0 { + return Err(ConfigError::Invalid { + key: "REGISTRATIONS_PER_IP_PER_HOUR".into(), + reason: "must be at least 1 in production".into(), + }); + } + if !config.pwned_passwords.enabled { + return Err(ConfigError::Invalid { + key: "PWNED_PASSWORDS_ENABLED".into(), + reason: "must be true in production -- breached passwords would be accepted".into(), + }); + } + // Recovery codes that never expire stay usable forever from wherever they + // were written down. + if !(1..=MAX_RECOVERY_CODE_EXPIRY_DAYS).contains(&config.crypto.recovery_code_expiry_days) { + return Err(ConfigError::Invalid { + key: "RECOVERY_CODE_EXPIRY_DAYS".into(), + reason: format!("must be between 1 and {MAX_RECOVERY_CODE_EXPIRY_DAYS} in production"), + }); + } + Ok(()) +} + +/// Longest life of a recovery code in production (two years). +const MAX_RECOVERY_CODE_EXPIRY_DAYS: u32 = 730; + +/// Settings allowed in production that weaken a control, with what each one +/// gives up: logged as warnings at startup, so a typo or a forgotten test +/// value shows instead of silently lowering the bar. +pub fn weakened_settings(config: &Config) -> Vec<(&'static str, &'static str)> { + let mut weakened = Vec::new(); + if config.security.magic_links { + weakened.push(( + "MAGIC_LINK_ENABLED", + "the mailbox alone signs in (a second factor still applies)", + )); + } + if config.security.reset_revokes_factors_added_hours == 0 { + weakened.push(( + "RESET_REVOKES_FACTORS_ADDED_HOURS", + "factors planted by whoever held the password survive a reset", + )); + } + if !config.security.new_device_alerts { + weakened.push(( + "NEW_DEVICE_ALERTS_ENABLED", + "owners are not told of sign-ins from new devices", + )); + } + if config.audit.retention_months == 0 { + weakened.push(( + "AUDIT_LOG_RETENTION_MONTHS", + "the audit log, full addresses aside, is kept forever", + )); + } + weakened +} +/// A limit of zero refuses every request. +pub(super) fn validate_rate_limits(limits: &RateLimitConfig) -> Result<(), ConfigError> { + for (key, value) in [ + ("RATE_LIMIT_RPM", limits.requests_per_minute), + ("RATE_LIMIT_AUTH_RPM", limits.auth_requests_per_minute), + ] { + if value == 0 { + return Err(ConfigError::Invalid { + key: key.into(), + reason: "must be at least 1".into(), + }); + } + } Ok(()) } @@ -385,11 +603,20 @@ pub(super) fn validate_security(security: &SecurityConfig) -> Result<(), ConfigE reason: "must be at least 1".into(), }); } + // A lock shorter than a minute is no lock: it ends before the next guess. + if security.lockout_duration_secs < MIN_LOCKOUT_DURATION_SECS { + return Err(ConfigError::Invalid { + key: "LOCKOUT_DURATION_SECS".into(), + reason: format!("must be at least {MIN_LOCKOUT_DURATION_SECS}"), + }); + } - if security.sensitive_action_reauth_secs == 0 { + if security.sensitive_action_reauth_secs == 0 + || security.sensitive_action_reauth_secs > MAX_REAUTH_WINDOW_SECS + { return Err(ConfigError::Invalid { key: "SENSITIVE_ACTION_REAUTH_SECS".into(), - reason: "must be greater than 0".into(), + reason: format!("must be between 1 and {MAX_REAUTH_WINDOW_SECS}"), }); } @@ -398,10 +625,11 @@ pub(super) fn validate_security(security: &SecurityConfig) -> Result<(), ConfigE /// A device flow needs a code that lives long enough to be polled at least once. pub(super) fn validate_device_auth(device: &DeviceAuthConfig) -> Result<(), ConfigError> { - if device.ttl_secs == 0 { + // A device code is a bearer of the approval to come: it lives minutes. + if device.ttl_secs == 0 || device.ttl_secs > MAX_DEVICE_AUTH_TTL_SECS { return Err(ConfigError::Invalid { key: "DEVICE_AUTH_TTL_SECS".into(), - reason: "must be greater than 0".into(), + reason: format!("must be between 1 and {MAX_DEVICE_AUTH_TTL_SECS}"), }); } // 0 was advertised to clients as is, while polling was paced at one second: @@ -421,7 +649,39 @@ pub(super) fn validate_device_auth(device: &DeviceAuthConfig) -> Result<(), Conf Ok(()) } -/// A zero absolute lifetime would refuse every refresh. +/// Shortest bearer token of the internal listener. +const MIN_METRICS_TOKEN_LEN: usize = 32; + +/// Shortest lockout that still holds a guesser back. +const MIN_LOCKOUT_DURATION_SECS: u64 = 60; + +/// Longest life of a device code (RFC 8628 suggests minutes). +const MAX_DEVICE_AUTH_TTL_SECS: u64 = 1800; + +/// Trusted proxies are named one address at a time in production: a network +/// would also trust its other hosts (on a compose bridge, the broker's +/// containers), and any of them could forge `X-Forwarded-For` and pick its +/// own address. +const MIN_TRUSTED_PROXY_PREFIX_V4: u8 = 32; +const MIN_TRUSTED_PROXY_PREFIX_V6: u8 = 128; + +/// Production ceilings of the anti-abuse settings: a typo must not turn a +/// control off. +const MAX_LOCKOUT_THRESHOLD: u32 = 50; +const MAX_REQUESTS_PER_MINUTE: u64 = 10_000; +const MAX_SESSION_SECS: u64 = 365 * 86_400; + +/// Longest re-authentication window: a proof of the password stands for +/// sensitive actions only for a few minutes. +pub const MAX_REAUTH_WINDOW_SECS: u64 = 900; + +/// Access token lifetimes accepted: revocation reaches resource servers that +/// verify tokens offline only when the token expires, so it stays short. +pub const ACCESS_EXPIRY_RANGE_SECS: std::ops::RangeInclusive = 60..=3600; + +/// Lifetimes that make sense together. A zero absolute lifetime would refuse +/// every refresh, and a refresh lifetime of zero would end every session at +/// once. pub(super) fn validate_session_lifetime(jwt: &JwtConfig) -> Result<(), ConfigError> { if jwt.max_session_lifetime_secs == 0 { return Err(ConfigError::Invalid { @@ -429,6 +689,28 @@ pub(super) fn validate_session_lifetime(jwt: &JwtConfig) -> Result<(), ConfigErr reason: "must be greater than 0".into(), }); } + if !ACCESS_EXPIRY_RANGE_SECS.contains(&jwt.access_expiry_secs) { + return Err(ConfigError::Invalid { + key: "JWT_ACCESS_EXPIRY_SECS".into(), + reason: format!( + "must be between {} and {}", + ACCESS_EXPIRY_RANGE_SECS.start(), + ACCESS_EXPIRY_RANGE_SECS.end() + ), + }); + } + if jwt.short_session_expiry_secs == 0 { + return Err(ConfigError::Invalid { + key: "JWT_SHORT_SESSION_EXPIRY_SECS".into(), + reason: "must be greater than 0".into(), + }); + } + if jwt.refresh_expiry_secs < jwt.short_session_expiry_secs { + return Err(ConfigError::Invalid { + key: "JWT_REFRESH_EXPIRY_SECS".into(), + reason: "must be at least JWT_SHORT_SESSION_EXPIRY_SECS: remember me must not shorten a session".into(), + }); + } Ok(()) } pub(super) fn validate_cors(cors: &CorsConfig, is_production: bool) -> Result<(), ConfigError> { @@ -470,7 +752,25 @@ pub(super) fn validate_cors(cors: &CorsConfig, is_production: bool) -> Result<() reason: format!("origin '{origin}' must use https in production"), }); } + // A browser sends `Origin: scheme://host[:port]`, nothing more: an + // entry with a path, a query or a trailing slash never matches, and + // the front end would be refused without a word. + let serialized = parsed.origin().ascii_serialization(); + if origin != &serialized || axum::http::HeaderValue::from_str(origin).is_err() { + return Err(ConfigError::Invalid { + key: "CORS_ALLOWED_ORIGINS".into(), + reason: format!("'{origin}' is not an origin: write it as '{serialized}'"), + }); + } } Ok(()) } + +/// Whether a connection URL carries a password (`scheme://user:password@host`). +pub(super) fn url_has_password(url: &str) -> bool { + reqwest::Url::parse(url) + .ok() + .and_then(|parsed| parsed.password().map(|p| !p.is_empty())) + .unwrap_or(false) +} diff --git a/src/domain/audit.rs b/src/domain/audit.rs index 5142b18..dc4f6ff 100644 --- a/src/domain/audit.rs +++ b/src/domain/audit.rs @@ -43,6 +43,15 @@ pub enum AuditAction { EncryptionKeyRotated, AccountUnlocked, PasswordResetForced, + AccessFactorsRemoved, + /// The user approved a client application's authorization request. + ClientAuthorized, + /// The user approved a device authorization request. + DeviceApproved, + /// The user refused a device authorization request. + DeviceDenied, + /// An administrator read accounts or the audit log (in their own history). + AdminDataRead, RoleCreated, RoleDeleted, RolePermissionsChanged, @@ -74,3 +83,25 @@ pub struct AuditLog { pub ip_address: Option, pub metadata: JsonValue, } + +/// An entry as the account's owner sees it: a change made by an administrator +/// or from the command line keeps what was done, not who did it nor from +/// where (no administrator id or address, no operator account or host). +pub fn owner_view( + mut metadata: serde_json::Value, + ip_address: Option, +) -> (serde_json::Value, Option) { + let by = metadata.get("by").and_then(|by| by.as_str()); + if !matches!(by, Some("administrator" | "command_line")) { + return (metadata, ip_address); + } + if let Some(fields) = metadata.as_object_mut() { + for field in OPERATOR_FIELDS { + fields.remove(*field); + } + } + (metadata, None) +} + +/// Metadata naming who made a change on someone else's account. +pub const OPERATOR_FIELDS: &[&str] = &["administrator_id", "operator", "host"]; diff --git a/src/domain/login_attempt.rs b/src/domain/login_attempt.rs index ec52161..5b618aa 100644 --- a/src/domain/login_attempt.rs +++ b/src/domain/login_attempt.rs @@ -20,6 +20,9 @@ pub enum LoginFailureReason { TwoFactorRequired, TwoFactorFailed, RateLimited, + /// A sign-in while the account's password is locked, recorded like a + /// wrong password so the budgets read the same as for any other account. + AccountLocked, } #[derive(Debug, Clone, sqlx::FromRow)] @@ -101,3 +104,34 @@ mod tests { assert!(at_identifier.reach(&CEILINGS)); } } + +/// What a failed sign-in records of the identifier typed: the identifier when +/// it has the shape of an address or a username, a fixed marker otherwise. A +/// password typed into the identifier field by mistake must not sit in the +/// table for months, attached to no account and so never forgotten. +pub fn storable_identifier(typed: &str) -> &str { + let username = (3..=50).contains(&typed.len()) && crate::domain::user::is_valid_username(typed); + if username || crate::domain::user::is_storable_email(typed) { + typed + } else { + UNRECOGNIZED_IDENTIFIER + } +} + +/// Recorded in place of an identifier that is neither an address nor a +/// username. +pub const UNRECOGNIZED_IDENTIFIER: &str = ""; + +#[cfg(test)] +mod storable_identifier_tests { + use super::*; + + #[test] + fn only_addresses_and_usernames_are_recorded() { + assert_eq!(storable_identifier("jane@example.com"), "jane@example.com"); + assert_eq!(storable_identifier("jane_doe"), "jane_doe"); + assert_eq!(storable_identifier("Tr0ub4dor&3"), UNRECOGNIZED_IDENTIFIER); + assert_eq!(storable_identifier("ab"), UNRECOGNIZED_IDENTIFIER); + assert_eq!(storable_identifier("pass word!"), UNRECOGNIZED_IDENTIFIER); + } +} diff --git a/src/domain/oauth.rs b/src/domain/oauth.rs index 86e4390..53ed458 100644 --- a/src/domain/oauth.rs +++ b/src/domain/oauth.rs @@ -18,6 +18,9 @@ pub enum ErrorCode { AuthorizationPending, SlowDown, ExpiredToken, + InteractionRequired, + RequestNotSupported, + RequestUriNotSupported, } impl ErrorCode { @@ -34,6 +37,9 @@ impl ErrorCode { Self::AuthorizationPending => "authorization_pending", Self::SlowDown => "slow_down", Self::ExpiredToken => "expired_token", + Self::InteractionRequired => "interaction_required", + Self::RequestNotSupported => "request_not_supported", + Self::RequestUriNotSupported => "request_uri_not_supported", } } } @@ -258,3 +264,55 @@ mod tests { assert_eq!(redirect_with("not a url", &[]), None); } } + +/// A session's consented scopes, narrowed to the scopes its client is +/// registered with now. `None` consent (a client without scopes) stays +/// unrestricted only while the client still has none; identity scopes are +/// kept, as they grant no permission. +pub fn within_client_scopes( + consented: Option<&[String]>, + client_scopes: Option<&[String]>, +) -> Option> { + let registered = match client_scopes { + Some(registered) if !registered.is_empty() => registered, + _ => return consented.map(<[String]>::to_vec), + }; + Some(match consented { + Some(consented) => consented + .iter() + .filter(|scope| crate::domain::oidc::is_oidc_scope(scope) || registered.contains(scope)) + .cloned() + .collect(), + None => registered.to_vec(), + }) +} + +#[cfg(test)] +mod client_scope_tests { + use super::within_client_scopes; + + fn v(items: &[&str]) -> Vec { + items.iter().map(|s| (*s).to_owned()).collect() + } + + #[test] + fn a_scope_taken_from_the_client_leaves_its_sessions() { + let registered = v(&["users:read"]); + assert_eq!( + within_client_scopes( + Some(&v(&["users:read", "users:manage", "openid"])), + Some(®istered) + ), + Some(v(&["users:read", "openid"])) + ); + assert_eq!( + within_client_scopes(None, Some(®istered)), + Some(registered.clone()) + ); + assert_eq!(within_client_scopes(None, Some(&[])), None); + assert_eq!( + within_client_scopes(Some(&v(&["a:b"])), None), + Some(v(&["a:b"])) + ); + } +} diff --git a/src/domain/registered_client.rs b/src/domain/registered_client.rs index a42c2df..4c2a999 100644 --- a/src/domain/registered_client.rs +++ b/src/domain/registered_client.rs @@ -29,6 +29,8 @@ pub struct RegisteredClient { pub client_secret_hash: Option>, /// May obtain tokens for itself with the client credentials grant. pub allows_client_credentials: bool, + /// A resource server: may introspect the access tokens of others. + pub allows_introspection: bool, } impl RegisteredClient { @@ -98,6 +100,27 @@ pub fn is_valid_client_id(client_id: &str) -> bool { .all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b)) } +/// Whether a redirect URI may be registered: `https`, `http` on a loopback +/// address, or a private-use scheme in reverse domain form (RFC 8252 section +/// 7.1). `javascript:`, `data:`, `file:` and the like would run in, or read +/// from, whatever the user agent follows them with. +pub fn is_allowed_redirect_scheme(uri: &reqwest::Url) -> bool { + match uri.scheme() { + "https" => uri.host().is_some(), + "http" => uri + .host_str() + .map(|host| host.trim_start_matches('[').trim_end_matches(']')) + .and_then(|host| host.parse::().ok()) + .is_some_and(|ip| ip.is_loopback()), + scheme => { + scheme.contains('.') + && !["javascript", "data", "vbscript", "file", "blob", "about"] + .iter() + .any(|forbidden| scheme.eq_ignore_ascii_case(forbidden)) + } + } +} + /// Check client settings before they are stored, with a message for the caller. pub fn check_settings( client_id: &str, @@ -112,7 +135,24 @@ pub fn check_settings( return Err("name must be 1 to 200 characters".into()); } for uri in redirect_uris { - reqwest::Url::parse(uri).map_err(|e| format!("invalid redirect uri {uri}: {e}"))?; + let parsed = + reqwest::Url::parse(uri).map_err(|e| format!("invalid redirect uri {uri}: {e}"))?; + // RFC 6749 section 3.1.2: no fragment; and no credentials, which + // would travel in every redirect. + if parsed.fragment().is_some() + || !parsed.username().is_empty() + || parsed.password().is_some() + { + return Err(format!( + "redirect uri {uri}: no fragment and no user or password" + )); + } + if !is_allowed_redirect_scheme(&parsed) { + return Err(format!( + "redirect uri {uri}: use https, http on 127.0.0.1 or [::1], or a private-use \ + scheme containing a dot (RFC 8252)" + )); + } } if default_max_sessions <= 0 { return Err("max sessions must be a positive number".into()); @@ -120,10 +160,100 @@ pub fn check_settings( Ok(()) } +/// Audit metadata of a saved client: its id, and for each setting that +/// changed, the value before and after. Redirect URIs are reduced to their +/// hosts: a path or query may carry something of the client's own. +pub fn audit_changes( + previous: Option<&RegisteredClient>, + saved: &RegisteredClient, +) -> serde_json::Value { + fn hosts(uris: &[String]) -> Vec { + let mut hosts: Vec = uris + .iter() + .filter_map(|uri| reqwest::Url::parse(uri).ok()) + .filter_map(|url| url.host_str().map(str::to_owned)) + .collect(); + hosts.sort(); + hosts.dedup(); + hosts + } + let mut changes = serde_json::Map::new(); + let mut compare = |field: &str, before: serde_json::Value, after: serde_json::Value| { + if before != after { + changes.insert( + field.to_owned(), + serde_json::json!({ "before": before, "after": after }), + ); + } + }; + let null = serde_json::Value::Null; + compare( + "redirect_hosts", + previous.map_or(null.clone(), |p| serde_json::json!(hosts(&p.redirect_uris))), + serde_json::json!(hosts(&saved.redirect_uris)), + ); + compare( + "scopes", + previous.map_or(null.clone(), |p| serde_json::json!(p.scopes)), + serde_json::json!(saved.scopes), + ); + compare( + "is_primary", + previous.map_or(null.clone(), |p| serde_json::json!(p.is_primary)), + serde_json::json!(saved.is_primary), + ); + compare( + "allows_client_credentials", + previous.map_or(null.clone(), |p| { + serde_json::json!(p.allows_client_credentials) + }), + serde_json::json!(saved.allows_client_credentials), + ); + compare( + "allows_introspection", + previous.map_or(null.clone(), |p| serde_json::json!(p.allows_introspection)), + serde_json::json!(saved.allows_introspection), + ); + compare( + "allows_loopback_redirect", + previous.map_or(null, |p| serde_json::json!(p.allows_loopback_redirect)), + serde_json::json!(saved.allows_loopback_redirect), + ); + serde_json::json!({ "client_id": saved.client_id, "changes": changes }) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn only_safe_redirect_schemes_are_registered() { + for allowed in [ + "https://app.example.com/cb", + "http://127.0.0.1/cb", + "http://[::1]:8080/cb", + "com.example.app:/callback", + ] { + assert!( + is_allowed_redirect_scheme(&reqwest::Url::parse(allowed).unwrap()), + "{allowed}" + ); + } + for refused in [ + "javascript:alert(1)", + "data:text/html,hi", + "file:///etc/passwd", + "vbscript:msgbox", + "http://app.example.com/cb", + "myapp:/callback", + ] { + assert!( + !is_allowed_redirect_scheme(&reqwest::Url::parse(refused).unwrap()), + "{refused}" + ); + } + } + fn client(is_primary: bool, scopes: &[&str]) -> RegisteredClient { RegisteredClient { client_id: "app".into(), @@ -136,6 +266,7 @@ mod tests { default_max_sessions: 2, client_secret_hash: None, allows_client_credentials: false, + allows_introspection: false, } } diff --git a/src/domain/session.rs b/src/domain/session.rs index 5eb71a7..57c37f2 100644 --- a/src/domain/session.rs +++ b/src/domain/session.rs @@ -48,6 +48,16 @@ pub enum SessionType { PersonalAccessToken, } +impl SessionType { + pub fn as_str(&self) -> &'static str { + match self { + Self::Web => "web", + Self::Device => "device", + Self::PersonalAccessToken => "personal_access_token", + } + } +} + #[derive(Debug, Clone, PartialEq, sqlx::Type)] #[sqlx(type_name = "session_compromise_reason", rename_all = "snake_case")] pub enum SessionCompromiseReason { @@ -84,6 +94,11 @@ pub struct Session { pub session_type: SessionType, pub client_id: Option, pub compromise_reason: Option, + /// The sign-in that started the session proved a second factor. + pub mfa: bool, + /// When the password was last proved for the consent the session came + /// from (OpenID Connect `auth_time`). + pub auth_time: Option, } impl Session { @@ -164,27 +179,65 @@ impl Session { } } +/// Whether a session acts for the account itself (first party) rather than +/// for a client the account delegated to. +/// +/// First party: a sign-in to the instance (no client), or a session of the +/// instance's own application without consented scopes. Delegated: a session +/// of any other client, any session restricted to consented scopes (OpenID +/// Connect included), and a personal access token. A delegated token reaches +/// the resource servers and the routes meant for clients (`/oauth/userinfo`, +/// logout); the account routes, the approval routes and the administration +/// refuse it, or it could widen its own grant. +pub fn is_first_party( + session_type: SessionType, + scoped: bool, + for_client: bool, + primary_client: bool, +) -> bool { + session_type != SessionType::PersonalAccessToken && !scoped && (!for_client || primary_client) +} + /// What the per-request token check learned from Redis. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TokenState { /// The token's id is on the logout blocklist. Revoked, - /// The session is cached as active. - Active, + /// The session is cached as active, first party or delegated. + Active { first_party: bool }, /// The session is cached as ended (revoked or expired). Ended, /// Nothing cached: the database decides. Unknown, } +/// Cached value of an ended session. +pub const CACHED_ENDED: u8 = 0; +/// Cached value of an active first-party session. +pub const CACHED_FIRST_PARTY: u8 = 1; +/// Cached value of an active delegated session. A release that predates the +/// distinction reads it as ended: during a rolling update a delegated token is +/// at worst refused early, never accepted where it should not be. +pub const CACHED_DELEGATED: u8 = 2; + +/// The value cached for a session read from the database. +pub fn cached_value(active: bool, first_party: bool) -> u8 { + match (active, first_party) { + (false, _) => CACHED_ENDED, + (true, true) => CACHED_FIRST_PARTY, + (true, false) => CACHED_DELEGATED, + } +} + /// Combine the blocklist and the session cache. The blocklist wins: a logout -/// takes effect before the cached validity expires. Only a cached `1` means -/// active; any other cached value reads as ended. +/// takes effect before the cached validity expires. Only the two active values +/// mean active; any other cached value reads as ended. pub fn token_state(blocklisted: bool, cached: Option) -> TokenState { match (blocklisted, cached) { (true, _) => TokenState::Revoked, (false, None) => TokenState::Unknown, - (false, Some(1)) => TokenState::Active, + (false, Some(CACHED_FIRST_PARTY)) => TokenState::Active { first_party: true }, + (false, Some(CACHED_DELEGATED)) => TokenState::Active { first_party: false }, (false, Some(_)) => TokenState::Ended, } } @@ -220,6 +273,8 @@ mod tests { session_type: SessionType::Web, client_id: None, compromise_reason: None, + mfa: false, + auth_time: None, } } @@ -334,12 +389,48 @@ mod tests { fn the_blocklist_wins_over_the_session_cache() { assert_eq!(token_state(true, Some(1)), TokenState::Revoked); assert_eq!(token_state(true, None), TokenState::Revoked); - assert_eq!(token_state(false, Some(1)), TokenState::Active); + assert_eq!( + token_state(false, Some(1)), + TokenState::Active { first_party: true } + ); + assert_eq!( + token_state(false, Some(2)), + TokenState::Active { first_party: false } + ); assert_eq!(token_state(false, Some(0)), TokenState::Ended); - assert_eq!(token_state(false, Some(2)), TokenState::Ended); + assert_eq!(token_state(false, Some(3)), TokenState::Ended); assert_eq!(token_state(false, None), TokenState::Unknown); } + #[test] + fn a_cached_session_reads_back_as_it_was_stored() { + for (active, first_party) in [(true, true), (true, false), (false, true), (false, false)] { + let state = token_state(false, Some(cached_value(active, first_party))); + if active { + assert_eq!(state, TokenState::Active { first_party }); + } else { + assert_eq!(state, TokenState::Ended); + } + } + } + + #[test] + fn only_sign_ins_and_the_primary_application_act_as_the_account() { + use SessionType::*; + // A sign-in to the instance. + assert!(is_first_party(Web, false, false, false)); + // The instance's own application, unrestricted. + assert!(is_first_party(Device, false, true, true)); + // Another client, even unrestricted. + assert!(!is_first_party(Device, false, true, false)); + // Consented scopes, the primary application included. + assert!(!is_first_party(Device, true, true, true)); + assert!(!is_first_party(Device, true, true, false)); + // A personal access token. + assert!(!is_first_party(PersonalAccessToken, true, false, false)); + assert!(!is_first_party(PersonalAccessToken, false, false, false)); + } + #[test] fn a_new_sign_in_expires_with_its_refresh_lifetime() { assert_eq!( diff --git a/src/domain/token.rs b/src/domain/token.rs index 19cfb42..6907ee4 100644 --- a/src/domain/token.rs +++ b/src/domain/token.rs @@ -8,7 +8,7 @@ use ipnetwork::IpNetwork; use time::OffsetDateTime; use uuid::Uuid; -#[derive(Debug, Clone, sqlx::FromRow)] +#[derive(Clone, sqlx::FromRow)] pub struct EmailVerificationToken { pub id: Uuid, pub user_id: Uuid, @@ -19,6 +19,32 @@ pub struct EmailVerificationToken { pub request_ip: Option, pub request_user_agent: Option, pub target_email: String, + /// Credentials of the registration that sent this link, applied when it + /// verifies a pending account; `None` keeps the account's own. + pub password_hash: Option, + pub username: Option, + pub preferred_locale: Option, +} + +impl std::fmt::Debug for EmailVerificationToken { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("EmailVerificationToken") + .field("id", &self.id) + .field("user_id", &self.user_id) + .field("created_at", &self.created_at) + .field("expires_at", &self.expires_at) + .field("used_at", &self.used_at) + .field("carries_credentials", &self.password_hash.is_some()) + .finish_non_exhaustive() + } +} + +/// Credentials a registration chose, carried by its verification link. +#[derive(Clone)] +pub struct PendingCredentials { + pub password_hash: String, + pub username: String, + pub preferred_locale: String, } #[derive(Debug, Clone, sqlx::FromRow)] @@ -213,6 +239,9 @@ mod tests { request_ip: None, request_user_agent: None, target_email: "jane@example.com".into(), + password_hash: None, + username: None, + preferred_locale: None, }; assert!(token(false).is_valid(now)); assert!(token(true).is_used()); diff --git a/src/domain/user.rs b/src/domain/user.rs index d272ecc..eac0530 100644 --- a/src/domain/user.rs +++ b/src/domain/user.rs @@ -15,7 +15,7 @@ pub enum UserStatus { PendingVerification, } -#[derive(Debug, Clone, sqlx::FromRow)] +#[derive(Clone, sqlx::FromRow)] pub struct User { pub id: Uuid, pub created_at: OffsetDateTime, @@ -30,6 +30,21 @@ pub struct User { pub password_hash: String, } +/// The password hash never reaches a log line through `{:?}`. +impl std::fmt::Debug for User { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("User") + .field("id", &self.id) + .field("status", &self.status) + .field("username", &self.username) + .field("email", &self.email) + .field("email_verified_at", &self.email_verified_at) + .field("locked_until", &self.locked_until) + .field("password_hash", &"") + .finish_non_exhaustive() + } +} + impl User { pub fn is_locked(&self, now: OffsetDateTime) -> bool { self.locked_until.is_some_and(|t| t > now) @@ -99,6 +114,15 @@ pub fn prefix_pattern(query: &str) -> String { mod tests { use super::*; + #[test] + fn debug_output_never_shows_the_password_hash() { + let mut user = make_user(UserStatus::Active, None, true); + user.password_hash = "$argon2id$v=19$secret-material".into(); + let printed = format!("{user:?}"); + assert!(!printed.contains("secret-material"), "{printed}"); + assert!(printed.contains("")); + } + #[test] fn a_search_prefix_matches_wildcards_literally() { assert_eq!(prefix_pattern(" Alice "), "alice%"); diff --git a/src/domain/webhook.rs b/src/domain/webhook.rs index df44ff2..d0094c2 100644 --- a/src/domain/webhook.rs +++ b/src/domain/webhook.rs @@ -120,6 +120,8 @@ fn is_public_v4(ip: Ipv4Addr) -> bool { || a == 0 || (a == 100 && (64..=127).contains(&b)) || (a == 192 && b == 0 && c == 0) + // Former 6to4 relay anycast (RFC 7526). + || (a == 192 && b == 88 && c == 99) || (a == 198 && (18..=19).contains(&b)) || a >= 240) } @@ -135,6 +137,11 @@ fn is_public_v6(ip: Ipv6Addr) -> bool { (u32::from(segments[6]) << 16) | u32::from(segments[7]), )); } + // Local-use NAT64 (64:ff9b:1::/48, RFC 8215) translates into networks of + // the operator's choosing, wherever the IPv4 address sits. + if segments[0] == 0x64 && segments[1] == 0xff9b && segments[2] == 1 { + return false; + } if segments[0] == 0x2002 { return is_public_v4(Ipv4Addr::from( (u32::from(segments[1]) << 16) | u32::from(segments[2]), @@ -236,6 +243,7 @@ mod tests { "100.64.0.1", "0.0.0.0", "192.0.0.8", + "192.88.99.1", "198.18.0.1", "224.0.0.1", "240.0.0.1", @@ -249,6 +257,8 @@ mod tests { "::ffff:127.0.0.1", "::127.0.0.1", "64:ff9b::a00:1", + "64:ff9b:1::5db8:d822", + "64:ff9b:1:a00:100::", "2002:a00:1::", "2001:db8::1", "2001::1", diff --git a/src/error.rs b/src/error.rs index 7418445..7c78a0e 100644 --- a/src/error.rs +++ b/src/error.rs @@ -69,6 +69,10 @@ pub enum AppError { TwoFactorRequired, #[error("recent re-authentication required")] ReauthenticationRequired, + #[error("a session of the account itself is required")] + FirstPartySessionRequired, + #[error("a session that proved a second factor is required")] + SecondFactorSessionRequired, // 404 #[error("resource not found")] @@ -221,6 +225,20 @@ impl IntoResponse for AppError { "Recent re-authentication is required for this action.", ), ), + Self::SecondFactorSessionRequired => ( + StatusCode::FORBIDDEN, + ErrorBody::new( + "second_factor_session_required", + "Sign in with your second factor to approve this.", + ), + ), + Self::FirstPartySessionRequired => ( + StatusCode::FORBIDDEN, + ErrorBody::new( + "first_party_session_required", + "This token was delegated to an application or issued for a personal access token; sign in to the account itself.", + ), + ), // 404 Self::NotFound => ( @@ -238,6 +256,9 @@ impl IntoResponse for AppError { "At least one account must keep the permission to manage roles." } "default_role" => "The role given to every new account cannot be deleted.", + "administrator_without_second_factor" => { + "An administrative role goes only to an active account with a second factor or a passkey." + } "too_many_tokens" => "Revoke a personal access token before creating another.", "external_identity_not_linked" => { "No account is linked to this identity: sign in, then link it from the account settings." @@ -245,6 +266,12 @@ impl IntoResponse for AppError { "external_identity_already_linked" => { "This identity is already linked to an account." } + "holders_without_second_factor" => { + "Every holder of this role must be an active account with a second factor before it grants administration." + } + "primary_client_managed_by_command_line" => { + "The primary client is designated and changed from the command line only." + } _ => "A resource with this value already exists.", }; (StatusCode::CONFLICT, ErrorBody::new(code, message)) @@ -541,6 +568,8 @@ mod tests { #[test] fn reauthentication_required_is_403() { assert_eq!(status(AppError::ReauthenticationRequired), 403); + assert_eq!(status(AppError::FirstPartySessionRequired), 403); + assert_eq!(status(AppError::SecondFactorSessionRequired), 403); } // 404 diff --git a/src/fuzzing.rs b/src/fuzzing.rs index 891ac14..d7899c7 100644 --- a/src/fuzzing.rs +++ b/src/fuzzing.rs @@ -143,6 +143,7 @@ pub fn redirect_uri(data: &[u8]) { default_max_sessions: 1, client_secret_hash: None, allows_client_credentials: false, + allows_introspection: false, }; if validate_redirect(&client, candidate).is_err() || registered.iter().any(|r| r == candidate) { @@ -282,6 +283,9 @@ pub fn access_token(data: &[u8]) { } } +/// The row the fixture ciphertext is bound to. +const KEYRING_CONTEXT: &[u8] = b"fuzz-row"; + struct KeyringFixture { keyring: Keyring, plaintext: &'static str, @@ -294,24 +298,37 @@ fn keyring_fixture() -> &'static KeyringFixture { let keyring = Keyring::new([7; 32], Some([9; 32])); let plaintext = "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP"; KeyringFixture { - ciphertext: keyring.encrypt(plaintext).expect("fixture ciphertext"), + ciphertext: keyring + .encrypt(plaintext, KEYRING_CONTEXT) + .expect("fixture ciphertext"), keyring, plaintext, } }) } -/// Secrets encrypted at rest: decryption never panics, and a ciphertext -/// altered anywhere never decrypts to another plaintext. +/// Secrets encrypted at rest: decryption never panics, a ciphertext altered +/// anywhere never decrypts to another plaintext, and none decrypts for another +/// row than its own. /// Layout: the input itself, then read as (position: u16, byte) edits. pub fn keyring(data: &[u8]) { let fixture = keyring_fixture(); if let Some(input) = text(data) { let _ = fixture.keyring.needs_rotation(input); - if let Ok(plaintext) = fixture.keyring.decrypt(input) { + let _ = fixture.keyring.knows_key_of(input); + if let Ok(plaintext) = fixture.keyring.decrypt(input, KEYRING_CONTEXT) { assert_eq!(plaintext, fixture.plaintext, "forged ciphertext accepted"); } + // Read as another row's context. + assert!( + fixture + .keyring + .decrypt(&fixture.ciphertext, input.as_bytes()) + .is_err() + || input.as_bytes() == KEYRING_CONTEXT, + "a ciphertext decrypted for another row" + ); } let mut ciphertext = fixture.ciphertext.clone().into_bytes(); @@ -320,7 +337,7 @@ pub fn keyring(data: &[u8]) { ciphertext[at] = *byte; } if let Ok(tampered) = String::from_utf8(ciphertext) - && let Ok(plaintext) = fixture.keyring.decrypt(&tampered) + && let Ok(plaintext) = fixture.keyring.decrypt(&tampered, KEYRING_CONTEXT) { assert_eq!( plaintext, fixture.plaintext, @@ -335,10 +352,15 @@ pub fn totp_code(data: &[u8]) { let fixture = keyring_fixture(); let secret = fixture .keyring - .encrypt("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ") + .encrypt( + "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ", + uuid::Uuid::nil().as_bytes(), + ) .expect("secret"); for now in [0, 59, TOKEN_ISSUED_AT] { - if totp::verify_code(&secret, code, &fixture.keyring, 1, now).unwrap_or(false) { + if totp::verify_code(&secret, uuid::Uuid::nil(), code, &fixture.keyring, 1, now) + .unwrap_or(false) + { assert!( code.len() == 6 && code.bytes().all(|b| b.is_ascii_digit()), "{code:?} accepted as a TOTP code" diff --git a/src/handlers/admin/audit.rs b/src/handlers/admin/audit.rs index 4e7f18a..b153f08 100644 --- a/src/handlers/admin/audit.rs +++ b/src/handlers/admin/audit.rs @@ -14,7 +14,7 @@ use crate::{ AuditEntryResponse, action_name, decode_cursor, encode_cursor, page_limit, rows_to_fetch, split_page, }, - extractors::AdminUser, + extractors::{AdminUser, ClientIp}, }, repositories::audit as audit_repo, state::AppState, @@ -61,7 +61,7 @@ pub struct AdminAuditPage { responses( (status = 200, description = "Audit entries, newest first", body = AdminAuditPage), (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), - (status = 403, description = "Missing `audit:read`, or no second factor enrolled", body = crate::error::ErrorBody), + (status = 403, description = "Missing `audit:read`, or no second factor proven by the session", body = crate::error::ErrorBody), (status = 422, description = "Invalid cursor or account id", body = crate::error::ErrorBody), ), security(("bearer" = [])), @@ -69,6 +69,7 @@ pub struct AdminAuditPage { pub async fn list( admin: AdminUser, State(state): State, + ClientIp(ip): ClientIp, Query(params): Query, ) -> Result, AppError> { admin.require(&state, "audit:read").await?; @@ -84,6 +85,17 @@ pub async fn list( ) .await?; let (rows, more) = split_page(rows, limit); + crate::services::admin::record_read( + &state, + &super::actor(&admin, ip), + serde_json::json!({ + "read": "audit", + "user_id": params.user_id, + "action": params.action, + "rows": rows.len(), + }), + ) + .await?; let next_cursor = more .then(|| { rows.last() diff --git a/src/handlers/admin/clients.rs b/src/handlers/admin/clients.rs index a4e4e00..7cc25a2 100644 --- a/src/handlers/admin/clients.rs +++ b/src/handlers/admin/clients.rs @@ -32,6 +32,8 @@ pub struct ClientResponse { pub confidential: bool, /// May obtain tokens for itself with the client credentials grant. pub allows_client_credentials: bool, + /// A resource server: may introspect the access tokens of others. + pub allows_introspection: bool, pub created_at: i64, } @@ -44,10 +46,17 @@ pub struct ClientSecretResponse { #[derive(Deserialize, utoipa::ToSchema)] pub struct SaveClientRequest { pub display_name: String, + /// Refused when true: the primary client is designated from the command + /// line only (`auth-api --register-client --primary`). #[serde(default)] pub is_primary: bool, + /// Permissions its tokens may carry. Empty needs `unrestricted`. #[serde(default)] pub scopes: Vec, + /// Required to register or keep a client without scopes: its tokens then + /// carry every permission of the user who approves it (never the roles). + #[serde(default)] + pub unrestricted: bool, #[serde(default)] pub redirect_uris: Vec, #[serde(default)] @@ -57,12 +66,16 @@ pub struct SaveClientRequest { /// Allow the client credentials grant; needs a secret and scopes. Omitted: /// unchanged (false for a new client). pub allows_client_credentials: Option, + /// Make the client a resource server, allowed to introspect the access + /// tokens of others. Omitted: unchanged (false for a new client). + pub allows_introspection: Option, } fn client_response(client: RegisteredClient) -> ClientResponse { ClientResponse { confidential: client.is_confidential(), allows_client_credentials: client.allows_client_credentials, + allows_introspection: client.allows_introspection, created_at: client.created_at.unix_timestamp(), client_id: client.client_id, display_name: client.display_name, @@ -81,7 +94,7 @@ fn client_response(client: RegisteredClient) -> ClientResponse { responses( (status = 200, description = "Every registered client", body = [ClientResponse]), (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), - (status = 403, description = "Missing `clients:manage`, or no second factor enrolled", body = crate::error::ErrorBody), + (status = 403, description = "Missing `clients:manage`, or no second factor proven by the session", body = crate::error::ErrorBody), ), security(("bearer" = [])), )] @@ -105,7 +118,7 @@ pub async fn list( (status = 201, description = "Client registered", body = ClientResponse), (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), (status = 403, description = "Missing `clients:manage`, no second factor, or re-authentication required", body = crate::error::ErrorBody), - (status = 409, description = "`primary_client_exists`", body = crate::error::ErrorBody), + (status = 409, description = "`primary_client_managed_by_command_line`: the primary client changes only through `auth-api --register-client`", body = crate::error::ErrorBody), (status = 422, description = "Invalid settings or unknown scope", body = crate::error::ErrorBody), ), security(("bearer" = [])), @@ -118,6 +131,15 @@ pub async fn save( Json(body): Json, ) -> Result<(StatusCode, Json), AppError> { admin.require(&state, "clients:manage").await?; + // A client without scopes acts with every permission of its users: never + // by omission. + if body.scopes.is_empty() && !body.unrestricted { + return Err(AppError::Validation( + "a client without scopes acts with every permission of its users: list its scopes, \ + or set unrestricted to true" + .into(), + )); + } let (client, created) = admin_clients::save( &state, &actor(&admin, ip), @@ -131,6 +153,7 @@ pub async fn save( default_max_sessions: body.default_max_sessions.unwrap_or(5), }, body.allows_client_credentials, + body.allows_introspection, ) .await?; let status = if created { @@ -148,8 +171,9 @@ pub async fn save( params(("client_id" = String, Path, description = "Client id")), responses( (status = 204, description = "Client removed and its sessions revoked"), + (status = 409, description = "`primary_client_managed_by_command_line`", body = crate::error::ErrorBody), (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), - (status = 403, description = "Missing `clients:manage`, or no second factor enrolled", body = crate::error::ErrorBody), + (status = 403, description = "Missing `clients:manage`, or no second factor proven by the session", body = crate::error::ErrorBody), (status = 404, description = "No such client", body = crate::error::ErrorBody), ), security(("bearer" = [])), @@ -172,6 +196,7 @@ pub async fn delete( params(("client_id" = String, Path, description = "Client id")), responses( (status = 200, description = "A new secret; the client is confidential from now on", body = ClientSecretResponse), + (status = 409, description = "`primary_client_managed_by_command_line`", body = crate::error::ErrorBody), (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), (status = 403, description = "Missing `clients:manage`, no second factor, or re-authentication required", body = crate::error::ErrorBody), (status = 404, description = "No such client", body = crate::error::ErrorBody), @@ -185,16 +210,6 @@ pub async fn rotate_secret( Path(client_id): Path, ) -> Result, AppError> { admin.require(&state, "clients:manage").await?; - crate::services::reauth::require_recent_reauth_or_password( - &state, - admin.auth.user_id, - admin.auth.session_id, - None, - ip, - admin.auth.request_id, - "admin_client_secret", - ) - .await?; let client_secret = admin_clients::rotate_secret(&state, &actor(&admin, ip), &client_id).await?; Ok(Json(ClientSecretResponse { client_secret })) @@ -207,8 +222,9 @@ pub async fn rotate_secret( params(("client_id" = String, Path, description = "Client id")), responses( (status = 204, description = "Secret removed; the client is public"), + (status = 409, description = "`primary_client_managed_by_command_line`", body = crate::error::ErrorBody), (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), - (status = 403, description = "Missing `clients:manage`, or no second factor enrolled", body = crate::error::ErrorBody), + (status = 403, description = "Missing `clients:manage`, or no second factor proven by the session, or re-authentication required", body = crate::error::ErrorBody), (status = 404, description = "No such client", body = crate::error::ErrorBody), ), security(("bearer" = [])), diff --git a/src/handlers/admin/roles.rs b/src/handlers/admin/roles.rs index 9764924..06182dc 100644 --- a/src/handlers/admin/roles.rs +++ b/src/handlers/admin/roles.rs @@ -73,7 +73,7 @@ fn role_response(role: Role, permissions: Vec) -> RoleResponse { responses( (status = 200, description = "Every permission a role can grant", body = [PermissionResponse]), (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), - (status = 403, description = "Missing `roles:manage`, or no second factor enrolled", body = crate::error::ErrorBody), + (status = 403, description = "Missing `roles:manage`, or no second factor proven by the session", body = crate::error::ErrorBody), ), security(("bearer" = [])), )] @@ -101,7 +101,7 @@ pub async fn permissions( responses( (status = 200, description = "Every role with its permissions", body = [RoleResponse]), (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), - (status = 403, description = "Missing `roles:manage`, or no second factor enrolled", body = crate::error::ErrorBody), + (status = 403, description = "Missing `roles:manage`, or no second factor proven by the session", body = crate::error::ErrorBody), ), security(("bearer" = [])), )] @@ -160,9 +160,9 @@ pub async fn create( responses( (status = 200, description = "The role now grants exactly these permissions", body = RoleResponse), (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), - (status = 403, description = "Missing `roles:manage`, no second factor, or re-authentication required", body = crate::error::ErrorBody), + (status = 403, description = "Missing `roles:manage`, no second factor, re-authentication required, or adding to a role the administrator holds a permission they lack", body = crate::error::ErrorBody), (status = 404, description = "No such role", body = crate::error::ErrorBody), - (status = 409, description = "`last_administrator`: nobody would keep `roles:manage`", body = crate::error::ErrorBody), + (status = 409, description = "`last_administrator`: nobody would keep `roles:manage`; `default_role_administration`: the default role never grants an administrative permission", body = crate::error::ErrorBody), (status = 422, description = "Unknown permission", body = crate::error::ErrorBody), ), security(("bearer" = [])), @@ -188,7 +188,7 @@ pub async fn set_permissions( responses( (status = 204, description = "Role deleted and taken back from every account"), (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), - (status = 403, description = "Missing `roles:manage`, or no second factor enrolled", body = crate::error::ErrorBody), + (status = 403, description = "Missing `roles:manage`, or no second factor proven by the session", body = crate::error::ErrorBody), (status = 404, description = "No such role", body = crate::error::ErrorBody), (status = 409, description = "`default_role`, or `last_administrator`", body = crate::error::ErrorBody), ), @@ -214,8 +214,9 @@ pub async fn delete( responses( (status = 204, description = "Role granted, or already held"), (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), - (status = 403, description = "Missing `roles:manage`, no second factor, or re-authentication required", body = crate::error::ErrorBody), + (status = 403, description = "Missing `roles:manage`, no second factor, re-authentication required, or the administrator's own account", body = crate::error::ErrorBody), (status = 404, description = "No such account or role", body = crate::error::ErrorBody), + (status = 409, description = "`administrator_without_second_factor`: the role grants administration and the account is not active or has no second factor", body = crate::error::ErrorBody), ), security(("bearer" = [])), )] @@ -242,7 +243,7 @@ pub async fn assign( responses( (status = 204, description = "Role taken back, or not held"), (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), - (status = 403, description = "Missing `roles:manage`, or no second factor enrolled", body = crate::error::ErrorBody), + (status = 403, description = "Missing `roles:manage`, or no second factor proven by the session", body = crate::error::ErrorBody), (status = 404, description = "No such role", body = crate::error::ErrorBody), (status = 409, description = "`last_administrator`: nobody would keep `roles:manage`", body = crate::error::ErrorBody), ), diff --git a/src/handlers/admin/users.rs b/src/handlers/admin/users.rs index 2fd2f69..be3afa2 100644 --- a/src/handlers/admin/users.rs +++ b/src/handlers/admin/users.rs @@ -14,7 +14,7 @@ use crate::{ handlers::{ audit::{decode_cursor, encode_cursor, page_limit, rows_to_fetch, split_page}, extractors::{AdminUser, ClientIp}, - user::{CurrentPasswordRequest, user_status_str}, + user::user_status_str, }, services::admin::users as admin_users, state::AppState, @@ -114,7 +114,7 @@ fn parse_status(status: &str) -> Result { responses( (status = 200, description = "Accounts, newest first", body = AdminUserPage), (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), - (status = 403, description = "Missing `users:read`, or no second factor enrolled", body = crate::error::ErrorBody), + (status = 403, description = "Missing `users:read`, or no second factor proven by the session", body = crate::error::ErrorBody), (status = 422, description = "Invalid status or cursor", body = crate::error::ErrorBody), ), security(("bearer" = [])), @@ -122,6 +122,7 @@ fn parse_status(status: &str) -> Result { pub async fn search( admin: AdminUser, State(state): State, + ClientIp(ip): ClientIp, Query(params): Query, ) -> Result, AppError> { admin.require(&state, "users:read").await?; @@ -138,6 +139,18 @@ pub async fn search( ) .await?; let (rows, more) = split_page(rows, limit); + // What was searched is not kept (a prefix of someone's address), only + // that a search ran and how much it returned. + crate::services::admin::record_read( + &state, + &actor(&admin, ip), + serde_json::json!({ + "read": "accounts", + "filtered": params.query.as_deref().is_some_and(|q| !q.trim().is_empty()), + "rows": rows.len(), + }), + ) + .await?; let next_cursor = more .then(|| { rows.last() @@ -159,7 +172,7 @@ pub async fn search( responses( (status = 200, description = "The account", body = AdminUserDetail), (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), - (status = 403, description = "Missing `users:read`, or no second factor enrolled", body = crate::error::ErrorBody), + (status = 403, description = "Missing `users:read`, or no second factor proven by the session", body = crate::error::ErrorBody), (status = 404, description = "No such account", body = crate::error::ErrorBody), ), security(("bearer" = [])), @@ -167,10 +180,17 @@ pub async fn search( pub async fn detail( admin: AdminUser, State(state): State, + ClientIp(ip): ClientIp, Path(user_id): Path, ) -> Result, AppError> { admin.require(&state, "users:read").await?; let detail = admin_users::detail(&state, user_id).await?; + crate::services::admin::record_read( + &state, + &actor(&admin, ip), + serde_json::json!({ "read": "account", "user_id": user_id }), + ) + .await?; Ok(Json(AdminUserDetail { account: summary(&state, detail.user), roles: detail.roles, @@ -187,9 +207,10 @@ pub async fn detail( responses( (status = 204, description = "Suspended and signed out everywhere, or already suspended"), (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), - (status = 403, description = "Missing `users:manage`, no second factor enrolled, or the administrator's own account", body = crate::error::ErrorBody), + (status = 403, description = "Missing `users:manage`, no second factor proven by the session, or the administrator's own account, or re-authentication required", body = crate::error::ErrorBody), (status = 404, description = "No such account", body = crate::error::ErrorBody), (status = 422, description = "The account was never verified", body = crate::error::ErrorBody), + (status = 409, description = "`last_administrator`: the account is the last active one able to manage roles", body = crate::error::ErrorBody), ), security(("bearer" = [])), )] @@ -212,7 +233,7 @@ pub async fn suspend( responses( (status = 204, description = "Active again, or already active"), (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), - (status = 403, description = "Missing `users:manage`, or no second factor enrolled", body = crate::error::ErrorBody), + (status = 403, description = "Missing `users:manage`, or no second factor proven by the session", body = crate::error::ErrorBody), (status = 404, description = "No such account", body = crate::error::ErrorBody), ), security(("bearer" = [])), @@ -236,7 +257,7 @@ pub async fn reactivate( responses( (status = 204, description = "Lockouts ended and past failures forgiven"), (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), - (status = 403, description = "Missing `users:manage`, or no second factor enrolled", body = crate::error::ErrorBody), + (status = 403, description = "Missing `users:manage`, or no second factor proven by the session", body = crate::error::ErrorBody), (status = 404, description = "No such account", body = crate::error::ErrorBody), ), security(("bearer" = [])), @@ -260,7 +281,7 @@ pub async fn unlock( responses( (status = 200, description = "Signed out everywhere", body = RevokedSessionsResponse), (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), - (status = 403, description = "Missing `users:manage`, no second factor enrolled, or the administrator's own account", body = crate::error::ErrorBody), + (status = 403, description = "Missing `users:manage`, no second factor proven by the session, or the administrator's own account", body = crate::error::ErrorBody), (status = 404, description = "No such account", body = crate::error::ErrorBody), ), security(("bearer" = [])), @@ -281,11 +302,13 @@ pub async fn revoke_sessions( path = "/admin/users/{id}/password-reset", tag = "admin", params(("id" = Uuid, Path, description = "Account id")), + request_body = Option, responses( (status = 204, description = "Signed out everywhere and a reset link mailed to the owner"), (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), - (status = 403, description = "Missing `users:manage`, no second factor enrolled, or the administrator's own account", body = crate::error::ErrorBody), + (status = 403, description = "Missing `users:manage`, no second factor proven by the session, or the administrator's own account, or re-authentication required", body = crate::error::ErrorBody), (status = 404, description = "No such account", body = crate::error::ErrorBody), + (status = 409, description = "`administrator_needs_second_factor`: `revoke_access_factors` on an account holding administrative permissions", body = crate::error::ErrorBody), ), security(("bearer" = [])), )] @@ -294,9 +317,45 @@ pub async fn force_password_reset( State(state): State, ClientIp(ip): ClientIp, Path(user_id): Path, + body: Option>, +) -> Result { + admin.require(&state, "users:manage").await?; + let revoke = body.is_some_and(|Json(b)| b.revoke_access_factors); + admin_users::force_password_reset(&state, &actor(&admin, ip), user_id, revoke).await?; + Ok(StatusCode::NO_CONTENT) +} + +#[derive(serde::Deserialize, utoipa::ToSchema)] +pub struct ForcePasswordResetRequest { + /// Also remove the account's second factors, passkeys, external + /// identities and personal access tokens: what someone who knew the + /// password may have added. + #[serde(default)] + pub revoke_access_factors: bool, +} + +#[utoipa::path( + delete, + path = "/admin/users/{id}/access-factors", + tag = "admin", + params(("id" = Uuid, Path, description = "Account id")), + responses( + (status = 204, description = "Second factors, recovery codes, passkeys, external identities and personal access tokens removed; the owner is mailed"), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 403, description = "Missing `users:manage`, no second factor proven by the session, the administrator's own account, or re-authentication required", body = crate::error::ErrorBody), + (status = 404, description = "No such account", body = crate::error::ErrorBody), + (status = 409, description = "`administrator_needs_second_factor`: the account holds administrative permissions", body = crate::error::ErrorBody), + ), + security(("bearer" = [])), +)] +pub async fn remove_access_factors( + admin: AdminUser, + State(state): State, + ClientIp(ip): ClientIp, + Path(user_id): Path, ) -> Result { admin.require(&state, "users:manage").await?; - admin_users::force_password_reset(&state, &actor(&admin, ip), user_id).await?; + admin_users::remove_access_factors(&state, &actor(&admin, ip), user_id).await?; Ok(StatusCode::NO_CONTENT) } @@ -305,12 +364,12 @@ pub async fn force_password_reset( path = "/admin/users/{id}", tag = "admin", params(("id" = Uuid, Path, description = "Account id")), - request_body = Option, responses( (status = 204, description = "Account deleted and `user.deleted` announced"), - (status = 401, description = "Missing, invalid or revoked access token, or wrong password", body = crate::error::ErrorBody), + (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), (status = 403, description = "Missing `users:manage`, no second factor, the administrator's own account, or re-authentication required", body = crate::error::ErrorBody), (status = 404, description = "No such account", body = crate::error::ErrorBody), + (status = 409, description = "`last_administrator`: the account is the last active one able to manage roles", body = crate::error::ErrorBody), ), security(("bearer" = [])), )] @@ -319,16 +378,8 @@ pub async fn delete( State(state): State, ClientIp(ip): ClientIp, Path(user_id): Path, - body: Option>, ) -> Result { admin.require(&state, "users:manage").await?; - let current_password = body.and_then(|Json(b)| b.current_password); - admin_users::delete( - &state, - &actor(&admin, ip), - user_id, - current_password.as_deref(), - ) - .await?; + admin_users::delete(&state, &actor(&admin, ip), user_id).await?; Ok(StatusCode::NO_CONTENT) } diff --git a/src/handlers/admin/webhooks.rs b/src/handlers/admin/webhooks.rs index 5d1f500..9d9327c 100644 --- a/src/handlers/admin/webhooks.rs +++ b/src/handlers/admin/webhooks.rs @@ -49,6 +49,17 @@ pub struct CreatedWebhookResponse { pub secret: String, } +#[derive(Serialize, utoipa::ToSchema)] +pub struct UpdatedWebhookResponse { + #[serde(flatten)] + pub webhook: WebhookResponse, + /// A new signing secret (`whsec_...`), shown once: present when the + /// endpoint now points at another host, whose previous secret stopped + /// signing. + #[serde(skip_serializing_if = "Option::is_none")] + pub secret: Option, +} + #[derive(Serialize, utoipa::ToSchema)] pub struct WebhookSecretResponse { /// The new signing secret, shown once; the previous one stops signing now. @@ -119,7 +130,7 @@ fn input(body: &WebhookRequest) -> EndpointInput<'_> { responses( (status = 200, description = "Every webhook endpoint", body = [WebhookResponse]), (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), - (status = 403, description = "Missing `webhooks:manage`, or no second factor enrolled", body = crate::error::ErrorBody), + (status = 403, description = "Missing `webhooks:manage`, or no second factor proven by the session", body = crate::error::ErrorBody), ), security(("bearer" = [])), )] @@ -140,7 +151,7 @@ pub async fn list( responses( (status = 201, description = "Endpoint registered; its signing secret is in this response only", body = CreatedWebhookResponse), (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), - (status = 403, description = "Missing `webhooks:manage`, or no second factor enrolled", body = crate::error::ErrorBody), + (status = 403, description = "Missing `webhooks:manage`, or no second factor proven by the session, or re-authentication required", body = crate::error::ErrorBody), (status = 422, description = "Invalid URL or unknown event", body = crate::error::ErrorBody), ), security(("bearer" = [])), @@ -169,9 +180,9 @@ pub async fn create( params(("id" = Uuid, Path, description = "Webhook id")), request_body = WebhookRequest, responses( - (status = 200, description = "Endpoint updated; the secret is unchanged", body = WebhookResponse), + (status = 200, description = "Endpoint updated; a new secret is returned when it points at another host, otherwise the secret is unchanged", body = UpdatedWebhookResponse), (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), - (status = 403, description = "Missing `webhooks:manage`, or no second factor enrolled", body = crate::error::ErrorBody), + (status = 403, description = "Missing `webhooks:manage`, or no second factor proven by the session, or re-authentication required", body = crate::error::ErrorBody), (status = 404, description = "No such webhook", body = crate::error::ErrorBody), (status = 422, description = "Invalid URL or unknown event", body = crate::error::ErrorBody), ), @@ -183,10 +194,13 @@ pub async fn update( ClientIp(ip): ClientIp, Path(id): Path, Json(body): Json, -) -> Result, AppError> { +) -> Result, AppError> { admin.require(&state, "webhooks:manage").await?; - let endpoint = webhook_svc::update(&state, &actor(&admin, ip), id, &input(&body)).await?; - Ok(Json(webhook_response(endpoint))) + let saved = webhook_svc::update(&state, &actor(&admin, ip), id, &input(&body)).await?; + Ok(Json(UpdatedWebhookResponse { + webhook: webhook_response(saved.endpoint), + secret: saved.secret, + })) } #[utoipa::path( @@ -197,7 +211,7 @@ pub async fn update( responses( (status = 204, description = "Endpoint and its pending deliveries removed"), (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), - (status = 403, description = "Missing `webhooks:manage`, or no second factor enrolled", body = crate::error::ErrorBody), + (status = 403, description = "Missing `webhooks:manage`, or no second factor proven by the session", body = crate::error::ErrorBody), (status = 404, description = "No such webhook", body = crate::error::ErrorBody), ), security(("bearer" = [])), @@ -221,7 +235,7 @@ pub async fn delete( responses( (status = 200, description = "A new signing secret", body = WebhookSecretResponse), (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), - (status = 403, description = "Missing `webhooks:manage`, or no second factor enrolled", body = crate::error::ErrorBody), + (status = 403, description = "Missing `webhooks:manage`, or no second factor proven by the session, or re-authentication required", body = crate::error::ErrorBody), (status = 404, description = "No such webhook", body = crate::error::ErrorBody), ), security(("bearer" = [])), @@ -245,7 +259,8 @@ pub async fn rotate_secret( responses( (status = 200, description = "The latest 100 deliveries, newest first", body = [WebhookDeliveryResponse]), (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), - (status = 403, description = "Missing `webhooks:manage`, or no second factor enrolled", body = crate::error::ErrorBody), + (status = 403, description = "Missing `webhooks:manage`, or no second factor proven by the session", body = crate::error::ErrorBody), + (status = 404, description = "No such webhook", body = crate::error::ErrorBody), ), security(("bearer" = [])), )] @@ -255,6 +270,10 @@ pub async fn deliveries( Path(id): Path, ) -> Result>, AppError> { admin.require(&state, "webhooks:manage").await?; + // Like the routes beside it: an unknown endpoint is not found. + webhook_repo::find_endpoint(&state.db_read, id) + .await? + .ok_or(AppError::NotFound)?; let deliveries = webhook_repo::find_recent_deliveries(&state.db_read, id, 100).await?; Ok(Json( deliveries.into_iter().map(delivery_response).collect(), @@ -272,7 +291,7 @@ pub async fn deliveries( responses( (status = 204, description = "Delivery queued again with a fresh attempt budget"), (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), - (status = 403, description = "Missing `webhooks:manage`, or no second factor enrolled", body = crate::error::ErrorBody), + (status = 403, description = "Missing `webhooks:manage`, or no second factor proven by the session", body = crate::error::ErrorBody), (status = 404, description = "No such delivery for this webhook", body = crate::error::ErrorBody), ), security(("bearer" = [])), @@ -280,9 +299,10 @@ pub async fn deliveries( pub async fn retry( admin: AdminUser, State(state): State, + ClientIp(ip): ClientIp, Path((id, delivery_id)): Path<(Uuid, Uuid)>, ) -> Result { admin.require(&state, "webhooks:manage").await?; - webhook_svc::redeliver(&state, id, delivery_id).await?; + webhook_svc::redeliver(&state, &actor(&admin, ip), id, delivery_id).await?; Ok(StatusCode::NO_CONTENT) } diff --git a/src/handlers/audit.rs b/src/handlers/audit.rs index 9c94be0..b773c2b 100644 --- a/src/handlers/audit.rs +++ b/src/handlers/audit.rs @@ -18,7 +18,7 @@ use crate::{ domain::audit::AuditAction, error::AppError, repositories::audit as audit_repo, state::AppState, }; -use super::extractors::AuthUser; +use super::extractors::FirstPartyUser; const DEFAULT_LIMIT: i64 = 50; /// Most entries one page may hold; larger requests are clamped, not refused. @@ -69,7 +69,7 @@ pub struct AuditPageResponse { )] pub async fn list( State(state): State, - auth: AuthUser, + auth: FirstPartyUser, Query(params): Query, ) -> Result, AppError> { let limit = page_limit(params.limit); @@ -91,15 +91,21 @@ pub async fn list( Ok(Json(AuditPageResponse { entries: rows .into_iter() - .map(|entry| AuditEntryResponse { - id: entry.id, - created_at: entry.created_at.unix_timestamp(), - action: action_name(&entry.action), - // The address, not the network: every row is written from one - // address and a `/32` on each line says nothing. - ip_address: entry.ip_address.map(|net| net.ip().to_string()), - request_id: entry.request_id, - metadata: entry.metadata, + .map(|entry| { + // An administrator's change names neither the administrator + // nor the address they acted from, as in the export. + let (metadata, ip_address) = + crate::domain::audit::owner_view(entry.metadata, entry.ip_address); + AuditEntryResponse { + id: entry.id, + created_at: entry.created_at.unix_timestamp(), + action: action_name(&entry.action), + // The address, not the network: every row is written from + // one address and a `/32` on each line says nothing. + ip_address: ip_address.map(|net| net.ip().to_string()), + request_id: entry.request_id, + metadata, + } }) .collect(), next_cursor, @@ -176,6 +182,11 @@ pub(crate) fn action_name(action: &AuditAction) -> &'static str { A::EncryptionKeyRotated => "encryption_key_rotated", A::AccountUnlocked => "account_unlocked", A::PasswordResetForced => "password_reset_forced", + A::AccessFactorsRemoved => "access_factors_removed", + A::ClientAuthorized => "client_authorized", + A::DeviceApproved => "device_approved", + A::DeviceDenied => "device_denied", + A::AdminDataRead => "admin_data_read", A::RoleCreated => "role_created", A::RoleDeleted => "role_deleted", A::RolePermissionsChanged => "role_permissions_changed", diff --git a/src/handlers/auth.rs b/src/handlers/auth.rs index 2ea9f91..1264da0 100644 --- a/src/handlers/auth.rs +++ b/src/handlers/auth.rs @@ -46,6 +46,9 @@ pub struct RefreshRequest { #[derive(Deserialize, utoipa::ToSchema)] pub struct VerifyEmailRequest { pub token: String, + /// The password chosen at the registration that sent the link: the link + /// proves the mailbox, the password proves which registration it activates. + pub password: String, } #[derive(Deserialize, utoipa::ToSchema)] @@ -165,7 +168,7 @@ pub async fn register( // Verify CAPTCHA if a secret is configured; skip silently otherwise. let captcha_token = body.captcha_token.as_deref().unwrap_or(""); - captcha_svc::verify(&state, captcha_token).await?; + captcha_svc::verify(&state, captcha_token, ip).await?; auth_svc::register( &state, @@ -196,7 +199,7 @@ pub async fn register( responses( (status = 200, description = "Tokens, or a two-factor challenge", body = LoginResponse), (status = 401, description = "Invalid credentials", body = crate::error::ErrorBody), - (status = 403, description = "Account locked, suspended or not verified", body = crate::error::ErrorBody), + (status = 403, description = "Account suspended or inactive; a locked password and an account whose address is not verified answer 401 like a wrong password", body = crate::error::ErrorBody), (status = 422, description = "Invalid input", body = crate::error::ErrorBody), (status = 429, description = "Rate limited; see Retry-After"), ), @@ -219,7 +222,7 @@ pub async fn login( } let captcha_token = body.captcha_token.as_deref().unwrap_or(""); - captcha_svc::verify(&state, captcha_token).await?; + captcha_svc::verify(&state, captcha_token, ip).await?; let result = auth_svc::login( &state, @@ -317,7 +320,7 @@ pub async fn refresh( request_body = VerifyEmailRequest, responses( (status = 200, description = "Address verified"), - (status = 401, description = "Invalid or expired token", body = crate::error::ErrorBody), + (status = 401, description = "Invalid or expired token (`token_invalid`, `token_expired`), or not the password of the registration that sent this link (`invalid_credentials`)", body = crate::error::ErrorBody), (status = 429, description = "Rate limited; see Retry-After"), ), )] @@ -327,7 +330,7 @@ pub async fn verify_email( RequestId(rid): RequestId, Json(body): Json, ) -> Result { - auth_svc::verify_email(&state, &body.token, ip, rid).await?; + auth_svc::verify_email(&state, &body.token, &body.password, ip, rid).await?; Ok(StatusCode::OK) } @@ -349,7 +352,7 @@ pub async fn resend_verification( Json(body): Json, ) -> Result { let captcha_token = body.captcha_token.as_deref().unwrap_or(""); - captcha_svc::verify(&state, captcha_token).await?; + captcha_svc::verify(&state, captcha_token, ip).await?; auth_svc::resend_verification(&state, &body.email, ip, ua.as_deref(), rid).await?; Ok(StatusCode::OK) @@ -374,7 +377,7 @@ pub async fn request_magic_link( Json(body): Json, ) -> Result { let captcha_token = body.captcha_token.as_deref().unwrap_or(""); - captcha_svc::verify(&state, captcha_token).await?; + captcha_svc::verify(&state, captcha_token, ip).await?; auth_svc::request_magic_link(&state, &body.email, ip, ua.as_deref(), rid).await?; Ok(StatusCode::OK) @@ -388,7 +391,7 @@ pub async fn request_magic_link( responses( (status = 200, description = "Tokens, or the account's two-factor challenge", body = LoginResponse), (status = 401, description = "Invalid, used or expired link", body = crate::error::ErrorBody), - (status = 403, description = "Account locked, suspended or inactive", body = crate::error::ErrorBody), + (status = 403, description = "Account suspended or inactive", body = crate::error::ErrorBody), (status = 404, description = "Sign-in links are not enabled on this deployment", body = crate::error::ErrorBody), (status = 429, description = "Rate limited; see Retry-After"), ), @@ -431,7 +434,7 @@ pub async fn forgot_password( Json(body): Json, ) -> Result { let captcha_token = body.captcha_token.as_deref().unwrap_or(""); - captcha_svc::verify(&state, captcha_token).await?; + captcha_svc::verify(&state, captcha_token, ip).await?; auth_svc::forgot_password(&state, &body.email, ip, ua.as_deref(), rid).await?; Ok(StatusCode::OK) @@ -467,7 +470,7 @@ pub async fn reset_password( responses( (status = 200, description = "Tokens issued", body = TokensResponse), (status = 401, description = "Invalid code or pre-auth token", body = crate::error::ErrorBody), - (status = 403, description = "Account suspended or locked since the challenge", body = crate::error::ErrorBody), + (status = 403, description = "Account suspended or inactive since the challenge", body = crate::error::ErrorBody), (status = 429, description = "Rate limited; see Retry-After"), ), )] @@ -503,7 +506,7 @@ pub async fn complete_two_factor( responses( (status = 200, description = "Tokens issued", body = TokensResponse), (status = 401, description = "Invalid recovery code or pre-auth token", body = crate::error::ErrorBody), - (status = 403, description = "Account suspended or locked since the challenge", body = crate::error::ErrorBody), + (status = 403, description = "Account suspended or inactive since the challenge", body = crate::error::ErrorBody), (status = 429, description = "Rate limited; see Retry-After"), ), )] @@ -538,7 +541,7 @@ pub async fn recovery_login( responses( (status = 200, description = "Tokens issued", body = TokensResponse), (status = 401, description = "Invalid code or pre-auth token", body = crate::error::ErrorBody), - (status = 403, description = "Account suspended or locked since the challenge", body = crate::error::ErrorBody), + (status = 403, description = "Account suspended or inactive since the challenge", body = crate::error::ErrorBody), (status = 429, description = "Rate limited; see Retry-After"), ), )] @@ -586,9 +589,12 @@ pub async fn resend_email_two_factor( // TOTP challenge would let a mailbox stand in for the authenticator app. pre_auth.expect_method(auth_svc::ChallengeMethod::Email)?; let user_id = pre_auth.user_id; + // A challenge resends at most twice, an account a few times an hour: a + // holder of the password cannot flood the owner's mailbox. + auth_svc::budget_email_resend(&state, &body.pre_auth_token, user_id).await?; // Fire-and-forget: errors are non-fatal to avoid enumeration via timing. - let _ = email_2fa_svc::send_code(&state, user_id).await; + let _ = email_2fa_svc::send_code(&state, user_id, Some(&body.pre_auth_token)).await; Ok(StatusCode::NO_CONTENT) } @@ -598,7 +604,7 @@ pub async fn resend_email_two_factor( const MAX_IDENTIFIER_LEN: usize = 254; /// Longest password accepted at login. Above the registration limit (128) so /// no existing account is refused, but bounded before Argon2 runs. -const MAX_LOGIN_PASSWORD_LEN: usize = 256; +const MAX_LOGIN_PASSWORD_LEN: usize = crate::utils::password::MAX_VERIFIED_PASSWORD_BYTES; /// Emails accepted for storage: syntactically valid and in the shape the /// `users_email_format` constraint accepts, so a bad address is a 422 rather diff --git a/src/handlers/external_identity.rs b/src/handlers/external_identity.rs index 2b99a64..b468497 100644 --- a/src/handlers/external_identity.rs +++ b/src/handlers/external_identity.rs @@ -20,7 +20,7 @@ use crate::{ use super::{ auth::LoginResponse, - extractors::{AuthUser, ClientIp, RequestId, UserAgent}, + extractors::{ClientIp, FirstPartyUser, RequestId, UserAgent}, user::CurrentPasswordRequest, }; @@ -181,7 +181,7 @@ pub async fn complete_sign_in( )] pub async fn list( State(state): State, - auth: AuthUser, + auth: FirstPartyUser, ) -> Result>, AppError> { let identities = external_svc::list(&state, auth.user_id).await?; Ok(Json( @@ -205,7 +205,7 @@ pub async fn list( pub async fn start_link( State(state): State, ClientIp(ip): ClientIp, - auth: AuthUser, + auth: FirstPartyUser, Path(provider): Path, ) -> Result, AppError> { Ok(started( @@ -236,7 +236,7 @@ pub async fn start_link( )] pub async fn complete_link( State(state): State, - auth: AuthUser, + auth: FirstPartyUser, Json(body): Json, ) -> Result<(StatusCode, Json), AppError> { let identity = @@ -261,7 +261,7 @@ pub async fn complete_link( pub async fn unlink( State(state): State, ClientIp(ip): ClientIp, - auth: AuthUser, + auth: FirstPartyUser, Path(id): Path, body: Option>, ) -> Result { diff --git a/src/handlers/extractors.rs b/src/handlers/extractors.rs index b68d520..a8844f8 100644 --- a/src/handlers/extractors.rs +++ b/src/handlers/extractors.rs @@ -30,6 +30,9 @@ pub struct AuthUser { pub permissions: Vec, /// Request ID injected by the request_id middleware; propagate to audit log entries. pub request_id: Option, + /// The session acts for the account itself, not for a client it delegated + /// to ([`crate::domain::session::is_first_party`]). + pub first_party: bool, } impl FromRequestParts for AuthUser { @@ -70,7 +73,7 @@ impl FromRequestParts for AuthUser { // Revoked token or ended session: one Redis round trip, the database // only on a cache miss. Fails closed when Redis is unavailable. - auth_svc::verify_token_state(state, claims.jti, claims.sid).await?; + let first_party = auth_svc::verify_token_state(state, claims.jti, claims.sid).await?; let request_id = parts .headers @@ -86,13 +89,46 @@ impl FromRequestParts for AuthUser { roles: claims.roles, permissions: claims.permissions, request_id, + first_party, }) } } -/// An administrator: a valid access token carrying at least one administrative -/// permission, from an account with a second factor enrolled. Each handler then -/// requires the permission of its action with [`AdminUser::require`]. +/// An access token of a session acting for the account itself: a sign-in to +/// the instance, or its own application without consented scopes. The +/// account routes, the approval routes and the administration take this +/// rather than [`AuthUser`]: a token delegated to a client or issued for a +/// personal access token would otherwise reach beyond what was granted to it, +/// up to approving itself a new, unrestricted session. +pub struct FirstPartyUser(pub AuthUser); + +impl std::ops::Deref for FirstPartyUser { + type Target = AuthUser; + + fn deref(&self) -> &AuthUser { + &self.0 + } +} + +impl FromRequestParts for FirstPartyUser { + type Rejection = AppError; + + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { + let auth = AuthUser::from_request_parts(parts, state).await?; + if !auth.first_party { + return Err(AppError::FirstPartySessionRequired); + } + Ok(Self(auth)) + } +} + +/// An administrator: a first-party access token carrying at least one +/// administrative permission, from a session whose sign-in proved a second +/// factor. Each handler then requires the permission of its action with +/// [`AdminUser::require`]. pub struct AdminUser { pub auth: AuthUser, } @@ -104,7 +140,7 @@ impl FromRequestParts for AdminUser { parts: &mut Parts, state: &AppState, ) -> Result { - let auth = AuthUser::from_request_parts(parts, state).await?; + let FirstPartyUser(auth) = FirstPartyUser::from_request_parts(parts, state).await?; if !auth .permissions .iter() @@ -112,12 +148,10 @@ impl FromRequestParts for AdminUser { { return Err(AppError::Forbidden); } - // An administrator's password alone must not open the administration. - if crate::repositories::two_factor::find_primary_by_user(&state.db, auth.user_id) - .await? - .is_none() - && !crate::repositories::passkey::exists_for_user(&state.db, auth.user_id).await? - { + // An administrator's password alone, or a sign-in link alone, must not + // open the administration: the session itself must have proven a + // second factor, whatever factors the account has enrolled. + if !crate::repositories::session::proved_second_factor(&state.db, auth.session_id).await? { return Err(AppError::TwoFactorRequired); } Ok(Self { auth }) diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 642865f..60bde37 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -64,34 +64,36 @@ pub async fn live() -> &'static str { "ok" } -/// What `/ready` found for each dependency. +/// Whether this instance can serve traffic, as the public `/ready` says it. #[derive(serde::Serialize, utoipa::ToSchema)] pub struct ReadyResponse { /// `ready` when every dependency answered, `unavailable` otherwise. pub status: &'static str, +} + +/// What a readiness check found for each dependency. Served on the internal +/// listener only: which dependency is down is operational detail. +#[derive(serde::Serialize)] +pub struct Readiness { + /// `ready` when every dependency answered, `unavailable` otherwise. + pub status: &'static str, /// `up` or `down`. pub database: &'static str, pub redis: &'static str, pub nats: &'static str, } +impl Readiness { + pub fn is_ready(&self) -> bool { + self.status == "ready" + } +} + /// How long a readiness check waits for one dependency. const READY_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1); -#[utoipa::path( - get, - path = "/ready", - tag = "discovery", - responses( - (status = 200, description = "Every dependency answered", body = ReadyResponse), - (status = 503, description = "A dependency did not answer", body = ReadyResponse), - ), -)] -/// Readiness: whether this instance can serve traffic now. The reverse proxy -/// and the rolling update send traffic only to a ready instance. -pub async fn ready( - axum::extract::State(state): axum::extract::State, -) -> (axum::http::StatusCode, axum::Json) { +/// Check every dependency, each within [`READY_PROBE_TIMEOUT`]. +pub async fn readiness(state: &AppState) -> Readiness { let database = async { matches!( tokio::time::timeout( @@ -119,20 +121,123 @@ pub async fn ready( let nats = state.nats.connection_state() == async_nats::connection::State::Connected; let up = |ok: bool| if ok { "up" } else { "down" }; - let all = database && redis && nats; - ( - if all { - axum::http::StatusCode::OK + Readiness { + status: if database && redis && nats { + "ready" } else { - axum::http::StatusCode::SERVICE_UNAVAILABLE + "unavailable" }, - axum::Json(ReadyResponse { - status: if all { "ready" } else { "unavailable" }, - database: up(database), - redis: up(redis), - nats: up(nats), - }), - ) + database: up(database), + redis: up(redis), + nats: up(nats), + } +} + +fn ready_status(readiness: &Readiness) -> axum::http::StatusCode { + if readiness.is_ready() { + axum::http::StatusCode::OK + } else { + axum::http::StatusCode::SERVICE_UNAVAILABLE + } +} + +#[utoipa::path( + get, + path = "/ready", + tag = "discovery", + responses( + (status = 200, description = "Every dependency answered", body = ReadyResponse), + (status = 503, description = "A dependency did not answer; the internal listener says which", body = ReadyResponse), + ), +)] +/// Readiness: whether this instance can serve traffic now. The reverse proxy +/// and the rolling update send traffic only to a ready instance. +pub async fn ready( + axum::extract::State(state): axum::extract::State, +) -> (axum::http::StatusCode, axum::Json) { + // Anyone may poll it: the answer is reused for a second, so a flood costs + // the dependencies one check per second and no pool connections. + let ready = { + let mut cached = state.readiness_cache.lock().await; + match *cached { + Some((taken, ready)) if taken.elapsed() < READY_CACHE_TTL => ready, + _ => { + let ready = readiness(&state).await.is_ready(); + *cached = Some((std::time::Instant::now(), ready)); + ready + } + } + }; + let (status, word) = if ready { + (axum::http::StatusCode::OK, "ready") + } else { + (axum::http::StatusCode::SERVICE_UNAVAILABLE, "unavailable") + }; + (status, axum::Json(ReadyResponse { status: word })) +} + +/// How long the public readiness answer is reused. +const READY_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(1); + +/// The readiness of each dependency, on the internal listener. +async fn ready_detail( + axum::extract::State(state): axum::extract::State, +) -> (axum::http::StatusCode, axum::Json) { + let readiness = readiness(&state).await; + (ready_status(&readiness), axum::Json(readiness)) +} + +/// Nothing on the internal listener takes a body or should take long: slow or +/// large requests from the private network cannot pile up. +fn internal_limits(router: Router) -> Router { + router + .layer(DefaultBodyLimit::max(1024)) + .layer(TimeoutLayer::with_status_code( + axum::http::StatusCode::SERVICE_UNAVAILABLE, + std::time::Duration::from_secs(10), + )) +} + +/// The internal listener answers only with `Authorization: Bearer +/// ` when a token is configured (always, in production). +async fn internal_bearer( + axum::extract::State(token): axum::extract::State>, + request: axum::extract::Request, + next: middleware::Next, +) -> axum::response::Response { + if let Some(token) = token.as_deref() { + let presented = request + .headers() + .get(header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")) + .unwrap_or_default(); + if !crate::utils::crypto::constant_time_eq(presented.as_bytes(), token.as_bytes()) { + return axum::response::IntoResponse::into_response( + axum::http::StatusCode::UNAUTHORIZED, + ); + } + } + next.run(request).await +} + +/// The endpoint label of requests no route matched: the raw path would give +/// every scanned URL a series of its own. +fn unmatched_endpoint_label(_path: &str) -> String { + "".to_owned() +} + +/// The HTTP metrics layer and the handle that renders the exposition. +fn metrics_layer() -> ( + axum_prometheus::PrometheusMetricLayer<'static>, + axum_prometheus::metrics_exporter_prometheus::PrometheusHandle, +) { + axum_prometheus::PrometheusMetricLayerBuilder::new() + .with_endpoint_label_type(axum_prometheus::EndpointLabel::MatchedPathWithFallbackFn( + unmatched_endpoint_label, + )) + .with_default_metrics() + .build_pair() } #[utoipa::path( @@ -156,9 +261,10 @@ pub async fn jwks( ) } -/// Build the main application router plus a separate `/metrics` router. +/// Build the main application router plus a separate internal router: +/// `/metrics` and the detailed `/ready`. /// -/// The metrics router MUST be served on an internal listener only (see +/// The internal router MUST be served on an internal listener only (see /// `MetricsConfig`): the Prometheus exposition reveals route-level traffic /// patterns and must never sit behind the public reverse proxy. /// @@ -166,18 +272,26 @@ pub async fn jwks( /// happen once per process: use it from `main` only. Tests use `router()`, /// which records no metrics. pub fn router_with_metrics(state: AppState) -> (Router, Router) { - let (prometheus_layer, metric_handle) = axum_prometheus::PrometheusMetricLayer::pair(); - - let app = build_router(state, Some(prometheus_layer)); - let metrics = Router::new().route( - "/metrics", - get(move || { - let handle = metric_handle.clone(); - async move { handle.render() } - }), - ); - - (app, metrics) + let (prometheus_layer, metric_handle) = metrics_layer(); + + let app = build_router(state.clone(), Some(prometheus_layer)); + let internal = Router::new() + .route( + "/metrics", + get(move || { + let handle = metric_handle.clone(); + async move { handle.render() } + }), + ) + .route("/ready", get(ready_detail)) + .layer(middleware::from_fn_with_state( + state.config.metrics.token.clone(), + internal_bearer, + )) + .with_state(state); + let internal = internal_limits(internal); + + (app, internal) } pub fn router(state: AppState) -> Router { @@ -236,6 +350,15 @@ fn build_router( .route("/live", get(live)) .route("/ready", get(ready)); + // Requests no route matches spend the general budget too: a scan of + // unknown paths is still traffic from one address. + let unmatched = Router::new() + .fallback(not_found) + .layer(middleware::from_fn_with_state( + rl_general.clone(), + rate_limit::layer_with_state, + )); + let admin = admin_router().layer(middleware::from_fn_with_state( rl_general.clone(), rate_limit::layer_with_state, @@ -256,12 +379,13 @@ fn build_router( // brute-force attack must not prevent the legitimate user from ending their session. .route("/auth/logout", post(auth::logout)) .layer(middleware::from_fn_with_state( - rl_general, + rl_general.clone(), rate_limit::layer_with_state, )); let router = probes .merge(public) + .merge(unmatched) .nest( "/auth", auth_router().layer(middleware::from_fn_with_state( @@ -271,10 +395,15 @@ fn build_router( ) .nest( "/oauth", - oauth_router().layer(middleware::from_fn_with_state( - rl_auth, - rate_limit::layer_with_state, - )), + oauth_router() + .layer(middleware::from_fn_with_state( + rl_auth, + rate_limit::layer_with_state, + )) + .merge(oauth_client_router().layer(middleware::from_fn_with_state( + rl_general, + rate_limit::layer_with_state, + ))), ) .nest("/users/me", me_with_strict_reauth) .nest("/admin", admin) @@ -311,6 +440,11 @@ fn build_router( router.with_state(state) } +/// The router's own 404, which the error body layer documents. +async fn not_found() -> axum::http::StatusCode { + axum::http::StatusCode::NOT_FOUND +} + fn build_cors(cfg: &crate::config::CorsConfig) -> CorsLayer { let allow_origin = if cfg.allowed_origins.iter().any(|o| o == "*") { AllowOrigin::any() @@ -399,6 +533,10 @@ fn admin_router() -> Router { .route("/users/{id}/suspend", post(admin::users::suspend)) .route("/users/{id}/reactivate", post(admin::users::reactivate)) .route("/users/{id}/unlock", post(admin::users::unlock)) + .route( + "/users/{id}/access-factors", + delete(admin::users::remove_access_factors), + ) .route( "/users/{id}/sessions", delete(admin::users::revoke_sessions), @@ -454,11 +592,8 @@ fn admin_router() -> Router { fn oauth_router() -> Router { Router::new() .route("/authorize", get(oauth::authorize)) - .route("/token", post(oauth::token)) .route("/device_authorization", post(oauth::device_authorization)) - .route("/introspect", post(oauth::introspect)) .route("/userinfo", get(oauth::userinfo)) - .route("/revoke", post(oauth::revoke)) .route("/device/verify", post(oauth::verify_device)) .route("/device/{user_code}", get(oauth::describe_device)) .route("/authorization-requests/{id}", get(oauth::describe_request)) @@ -472,6 +607,19 @@ fn oauth_router() -> Router { ) } +// The endpoints a client application calls with its own credentials: under +// the general per-address bucket, with a per-client budget and a per-address +// budget of wrong secrets in `services::oauth::authenticate_client`. The strict +// bucket would cap a resource server introspecting from one address, or every +// client behind a NAT, at a handful of requests a minute. + +fn oauth_client_router() -> Router { + Router::new() + .route("/token", post(oauth::token)) + .route("/introspect", post(oauth::introspect)) + .route("/revoke", post(oauth::revoke)) +} + // Sensitive authenticated routes placed under the strict auth rate-limit bucket. // The email-change flow is included here because each step involves OTP dispatch // or verification - the same threat model as the reauth endpoint. @@ -485,6 +633,30 @@ fn me_strict_router() -> Router { .route("/email/verify-current", post(user::verify_current_email)) .route("/email/submit", post(user::submit_new_email)) .route("/email/confirm", post(user::confirm_new_email)) + // Every route accepting `current_password` guesses the password like + // `/reauth` does, so it shares its bucket: the general one would let a + // stolen access token try passwords fifteen times faster. + .route("/", delete(user::delete_account)) + .route("/username", patch(user::change_username)) + .route("/password", patch(user::change_password)) + .route("/sessions", delete(session::revoke_all)) + .route("/sessions/{id}", delete(session::revoke)) + .route("/passkeys/{id}", delete(passkey::remove)) + .route( + "/external-identities/{id}", + delete(external_identity::unlink), + ) + .route("/two-factor/totp/setup", post(two_factor::setup_totp)) + .route("/two-factor/totp/{id}", delete(two_factor::disable_totp)) + .route( + "/two-factor/recovery-codes", + post(two_factor::regenerate_recovery_codes), + ) + .route("/two-factor/email/setup", post(two_factor::setup_email_otp)) + .route( + "/two-factor/email/{id}", + delete(two_factor::disable_email_otp), + ) } // Protected routes under /users/me (all require a valid JWT). @@ -496,10 +668,7 @@ fn me_router() -> Router { .route("/", get(user::me)) .route("/audit", get(audit::list)) .route("/two-factor", get(two_factor::list)) - .route("/username", patch(user::change_username)) - .route("/password", patch(user::change_password)) .route("/locale", patch(user::change_locale)) - .route("/", delete(user::delete_account)) // External identities .route("/external-identities", get(external_identity::list)) .route( @@ -510,40 +679,22 @@ fn me_router() -> Router { "/external-identities/{provider}/start", post(external_identity::start_link), ) - .route( - "/external-identities/{id}", - delete(external_identity::unlink), - ) // Passkeys .route("/passkeys", get(passkey::list)) .route("/passkeys", post(passkey::register)) .route("/passkeys/options", post(passkey::registration_options)) - .route("/passkeys/{id}", delete(passkey::remove)) // Personal access tokens .route("/tokens", get(personal_access_token::list)) .route("/tokens", post(personal_access_token::create)) .route("/tokens/{id}", delete(personal_access_token::revoke)) // Sessions .route("/sessions", get(session::list)) - .route("/sessions", delete(session::revoke_all)) - .route("/sessions/{id}", delete(session::revoke)) // Two-factor: TOTP - .route("/two-factor/totp/setup", post(two_factor::setup_totp)) .route( "/two-factor/totp/{id}/verify", post(two_factor::verify_totp_setup), ) - .route("/two-factor/totp/{id}", delete(two_factor::disable_totp)) - .route( - "/two-factor/recovery-codes", - post(two_factor::regenerate_recovery_codes), - ) - .route( - "/two-factor/recovery-codes/use", - post(two_factor::use_recovery_code), - ) // Two-factor: Email OTP - .route("/two-factor/email/setup", post(two_factor::setup_email_otp)) .route( "/two-factor/email/send", post(two_factor::send_email_otp_code), @@ -552,30 +703,96 @@ fn me_router() -> Router { "/two-factor/email/{id}/verify", post(two_factor::verify_email_otp_setup), ) - .route( - "/two-factor/email/{id}", - delete(two_factor::disable_email_otp), - ) } #[cfg(test)] mod tests { + use tower::ServiceExt; + + /// The internal listener refuses large bodies (SEC-73). + #[tokio::test] + async fn the_internal_listener_refuses_large_bodies() { + let app = super::internal_limits(axum::Router::new().route( + "/metrics", + axum::routing::post(|body: axum::body::Bytes| async move { body.len().to_string() }), + )); + let status = app + .oneshot( + axum::http::Request::post("/metrics") + .body(axum::body::Body::from(vec![b'x'; 4096])) + .unwrap(), + ) + .await + .unwrap() + .status() + .as_u16(); + assert_eq!(status, 413); + } + + /// The internal listener answers only with its bearer token (SEC-65). + #[tokio::test] + async fn the_internal_listener_needs_its_token() { + let app = axum::Router::new() + .route("/ready", axum::routing::get(|| async { "ok" })) + .layer(axum::middleware::from_fn_with_state( + Some("metrics-token".to_owned()), + super::internal_bearer, + )); + let status = |authorization: Option<&'static str>| { + let app = app.clone(); + async move { + let mut request = axum::http::Request::get("/ready"); + if let Some(value) = authorization { + request = request.header("authorization", value); + } + app.oneshot(request.body(axum::body::Body::empty()).unwrap()) + .await + .unwrap() + .status() + .as_u16() + } + }; + assert_eq!(status(None).await, 401); + assert_eq!(status(Some("Bearer wrong")).await, 401); + assert_eq!(status(Some("Bearer metrics-token")).await, 200); + } + #[tokio::test] - async fn metrics_recorder_renders_business_counters() { - // pair() installs the process-global Prometheus recorder (and spawns - // its upkeep task, hence the Tokio runtime); this must stay the only - // test doing so (router() never installs it, so the integration suite - // is unaffected). - let (_layer, handle) = axum_prometheus::PrometheusMetricLayer::pair(); + async fn metrics_recorder_renders_business_counters_and_folds_unmatched_paths() { + // The builder installs the process-global Prometheus recorder (and + // spawns its upkeep task, hence the Tokio runtime); this must stay the + // only test doing so (router() never installs it, so the integration + // suite is unaffected). + let (layer, handle) = super::metrics_layer(); metrics::counter!("auth_logins_total", "outcome" => "success").increment(1); metrics::gauge!("argon2_queue_available_permits").set(4.0); + let app = axum::Router::new() + .route("/known", axum::routing::get(|| async { "ok" })) + .layer(layer); + for path in ["/known", "/scan-a1b2c3", "/.env"] { + app.clone() + .oneshot( + axum::http::Request::get(path) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + } + let body = handle.render(); assert!( body.contains("auth_logins_total"), "missing counter: {body}" ); assert!(body.contains("argon2_queue_available_permits")); + assert!(body.contains(r#"endpoint="/known""#), "{body}"); + assert!(body.contains(r#"endpoint="""#), "{body}"); + assert!( + !body.contains("scan-a1b2c3") && !body.contains("/.env"), + "an unmatched path became a label: {body}" + ); } } diff --git a/src/handlers/oauth.rs b/src/handlers/oauth.rs index 7a71b9e..eedc013 100644 --- a/src/handlers/oauth.rs +++ b/src/handlers/oauth.rs @@ -14,7 +14,6 @@ use serde_json::json; use crate::{ domain::oauth::{self, ErrorCode}, error::AppError, - repositories::role as role_repo, services::{ device as device_svc, oauth::{self as oauth_svc, AuthorizeOutcome, EndpointError, OAuthError}, @@ -22,7 +21,7 @@ use crate::{ state::AppState, }; -use super::extractors::{AuthUser, ClientIp, UserAgent}; +use super::extractors::{AuthUser, ClientIp, FirstPartyUser, UserAgent}; /// RFC 6749 section 5.2. #[derive(Serialize, utoipa::ToSchema)] @@ -147,6 +146,9 @@ pub struct DeviceVerifyRequest { pub user_code: String, #[serde(default = "default_approve")] pub approve: bool, + /// Approving a device of a client other than the instance's own + /// application needs this, or a recent `POST /users/me/reauth`. + pub current_password: Option, } fn default_approve() -> bool { @@ -193,11 +195,9 @@ pub async fn metadata(State(state): State) -> Result = role_repo::find_all_permissions(&state.db) - .await? - .into_iter() - .map(|p| p.name) - .collect(); + // Only what a registered client may ask for: the full permission catalog + // would map the authorization model for anyone. + let scopes = crate::repositories::registered_client::assignable_scopes(&state.db).await?; Ok(( [(header::CACHE_CONTROL, "public, max-age=300")], Json(json!({ @@ -221,7 +221,7 @@ pub async fn metadata(State(state): State) -> Result, - auth: AuthUser, + auth: FirstPartyUser, Path(id): Path, ) -> Result, AppError> { let described = oauth_svc::describe_request(&state, auth.user_id, auth.session_id, &id).await?; @@ -392,7 +387,7 @@ pub async fn describe_request( pub async fn approve_request( State(state): State, ClientIp(ip): ClientIp, - auth: AuthUser, + auth: FirstPartyUser, Path(id): Path, body: Option>, ) -> Result, AppError> { @@ -424,10 +419,10 @@ pub async fn approve_request( )] pub async fn deny_request( State(state): State, - _auth: AuthUser, + auth: FirstPartyUser, Path(id): Path, ) -> Result, AppError> { - let redirect_to = oauth_svc::deny_request(&state, &id).await?; + let redirect_to = oauth_svc::deny_request(&state, auth.user_id, &id).await?; Ok(Json(AuthorizationDecisionResponse { redirect_to })) } @@ -529,11 +524,13 @@ pub struct TokenOperationRequest { )] pub async fn introspect( State(state): State, + ClientIp(ip): ClientIp, headers: HeaderMap, body: Bytes, ) -> Result { let parameters = form(&headers, &body)?; - let introspection = oauth_svc::introspect(&state, authorization(&headers), ¶meters).await?; + let introspection = + oauth_svc::introspect(&state, authorization(&headers), ¶meters, ip).await?; Ok(([(header::CACHE_CONTROL, "no-store")], Json(introspection)).into_response()) } @@ -575,11 +572,13 @@ pub async fn revoke( pub async fn describe_device( State(state): State, ClientIp(ip): ClientIp, - _auth: AuthUser, + auth: FirstPartyUser, Path(user_code): Path, ) -> Result, AppError> { validate_user_code(&user_code)?; - Ok(Json(device_svc::describe(&state, &user_code, ip).await?)) + Ok(Json( + device_svc::describe(&state, auth.user_id, auth.session_id, &user_code, ip).await?, + )) } #[utoipa::path( @@ -589,7 +588,8 @@ pub async fn describe_device( request_body = DeviceVerifyRequest, responses( (status = 200, description = "Decision recorded"), - (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 401, description = "Missing, invalid or revoked access token, or wrong password", body = crate::error::ErrorBody), + (status = 403, description = "Re-authentication required to approve a client other than the instance's own application, or a delegated token", body = crate::error::ErrorBody), (status = 404, description = "Unknown or expired code", body = crate::error::ErrorBody), (status = 409, description = "Already decided", body = crate::error::ErrorBody), ), @@ -598,14 +598,25 @@ pub async fn describe_device( pub async fn verify_device( State(state): State, ClientIp(ip): ClientIp, - auth: AuthUser, + auth: FirstPartyUser, Json(body): Json, ) -> Result { validate_user_code(&body.user_code)?; if body.approve { - device_svc::verify(&state, auth.user_id, &body.user_code, ip).await?; + device_svc::verify( + &state, + &device_svc::Approval { + user_id: auth.user_id, + session_id: auth.session_id, + user_code: &body.user_code, + current_password: body.current_password.as_deref(), + ip, + request_id: auth.request_id, + }, + ) + .await?; } else { - device_svc::deny(&state, &body.user_code, ip).await?; + device_svc::deny(&state, auth.user_id, &body.user_code, ip).await?; } Ok(StatusCode::OK) } diff --git a/src/handlers/passkey.rs b/src/handlers/passkey.rs index f88e247..7e45df6 100644 --- a/src/handlers/passkey.rs +++ b/src/handlers/passkey.rs @@ -19,7 +19,7 @@ use crate::{ }; use super::{ - extractors::{AuthUser, ClientIp, UserAgent}, + extractors::{ClientIp, FirstPartyUser, UserAgent}, user::CurrentPasswordRequest, }; @@ -91,7 +91,7 @@ fn passkey_response(passkey: Passkey) -> PasskeyResponse { )] pub async fn list( State(state): State, - auth: AuthUser, + auth: FirstPartyUser, ) -> Result>, AppError> { let passkeys = passkey_svc::list(&state, auth.user_id).await?; Ok(Json(passkeys.into_iter().map(passkey_response).collect())) @@ -112,7 +112,7 @@ pub async fn list( pub async fn registration_options( State(state): State, ClientIp(ip): ClientIp, - auth: AuthUser, + auth: FirstPartyUser, ) -> Result, AppError> { Ok(Json( passkey_svc::registration_options( @@ -142,7 +142,7 @@ pub async fn registration_options( pub async fn register( State(state): State, ClientIp(ip): ClientIp, - auth: AuthUser, + auth: FirstPartyUser, Json(body): Json, ) -> Result<(StatusCode, Json), AppError> { let registered = passkey_svc::register( @@ -175,13 +175,14 @@ pub async fn register( (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), (status = 403, description = "Recent re-authentication required", body = crate::error::ErrorBody), (status = 404, description = "No such passkey on this account", body = crate::error::ErrorBody), + (status = 409, description = "`administrator_needs_second_factor`: the account holds administrative permissions and this is its last second factor", body = crate::error::ErrorBody), ), security(("bearer" = [])), )] pub async fn remove( State(state): State, ClientIp(ip): ClientIp, - auth: AuthUser, + auth: FirstPartyUser, Path(id): Path, body: Option>, ) -> Result { diff --git a/src/handlers/personal_access_token.rs b/src/handlers/personal_access_token.rs index 8126487..5ea9f99 100644 --- a/src/handlers/personal_access_token.rs +++ b/src/handlers/personal_access_token.rs @@ -14,7 +14,7 @@ use crate::{ services::personal_access_token as pat_svc, state::AppState, }; -use super::extractors::{AuthUser, ClientIp}; +use super::extractors::{ClientIp, FirstPartyUser}; #[derive(Deserialize, utoipa::ToSchema)] pub struct CreatePersonalAccessTokenRequest { @@ -84,7 +84,7 @@ fn token_response(token: PersonalAccessToken) -> PersonalAccessTokenResponse { )] pub async fn list( State(state): State, - auth: AuthUser, + auth: FirstPartyUser, ) -> Result>, AppError> { let tokens = pat_svc::list(&state, auth.user_id).await?; Ok(Json(tokens.into_iter().map(token_response).collect())) @@ -107,7 +107,7 @@ pub async fn list( pub async fn create( State(state): State, ClientIp(ip): ClientIp, - auth: AuthUser, + auth: FirstPartyUser, Json(body): Json, ) -> Result<(StatusCode, Json), AppError> { let created = pat_svc::create( @@ -145,7 +145,7 @@ pub async fn create( pub async fn revoke( State(state): State, ClientIp(ip): ClientIp, - auth: AuthUser, + auth: FirstPartyUser, Path(id): Path, ) -> Result { pat_svc::revoke(&state, auth.user_id, id, ip, auth.request_id).await?; diff --git a/src/handlers/session.rs b/src/handlers/session.rs index fce6cd0..8c86fc5 100644 --- a/src/handlers/session.rs +++ b/src/handlers/session.rs @@ -13,7 +13,7 @@ use crate::{ state::AppState, }; -use super::extractors::{AuthUser, ClientIp}; +use super::extractors::{ClientIp, FirstPartyUser}; // Response types @@ -60,7 +60,7 @@ pub struct RevokeAllRequest { )] pub async fn list( State(state): State, - auth: AuthUser, + auth: FirstPartyUser, ) -> Result>, AppError> { let sessions = session_svc::list_active(&state, auth.user_id).await?; @@ -103,7 +103,7 @@ pub async fn list( pub async fn revoke( State(state): State, ClientIp(ip): ClientIp, - auth: AuthUser, + auth: FirstPartyUser, Path(session_id): Path, body: Option>, ) -> Result { @@ -136,7 +136,7 @@ pub async fn revoke( pub async fn revoke_all( State(state): State, ClientIp(ip): ClientIp, - auth: AuthUser, + auth: FirstPartyUser, body: Option>, ) -> Result { let (current_password, keep_current_session) = body diff --git a/src/handlers/two_factor.rs b/src/handlers/two_factor.rs index c8ba985..9a52e2f 100644 --- a/src/handlers/two_factor.rs +++ b/src/handlers/two_factor.rs @@ -15,7 +15,7 @@ use crate::{ state::AppState, }; -use super::extractors::{AuthUser, ClientIp}; +use super::extractors::{ClientIp, FirstPartyUser}; // Request types @@ -41,11 +41,6 @@ pub struct RegenerateRecoveryCodesRequest { pub current_password: Option, } -#[derive(Deserialize, utoipa::ToSchema)] -pub struct UseRecoveryCodeRequest { - pub code: String, -} - #[derive(Deserialize, utoipa::ToSchema)] pub struct VerifyEmailOtpSetupRequest { pub code: String, @@ -95,7 +90,7 @@ pub struct EmailOtpSetupResponse { pub async fn setup_totp( State(state): State, ClientIp(ip): ClientIp, - auth: AuthUser, + auth: FirstPartyUser, body: Option>, ) -> Result, AppError> { let body = body.map(|Json(b)| b).unwrap_or_default(); @@ -132,7 +127,7 @@ pub async fn setup_totp( )] pub async fn verify_totp_setup( State(state): State, - auth: AuthUser, + auth: FirstPartyUser, Path(method_id): Path, Json(body): Json, ) -> Result, AppError> { @@ -154,13 +149,14 @@ pub async fn verify_totp_setup( (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), (status = 403, description = "Recent re-authentication required", body = crate::error::ErrorBody), (status = 404, description = "No such method", body = crate::error::ErrorBody), + (status = 409, description = "`administrator_needs_second_factor`: the account holds administrative permissions and this is its last second factor", body = crate::error::ErrorBody), ), security(("bearer" = [])), )] pub async fn disable_totp( State(state): State, ClientIp(ip): ClientIp, - auth: AuthUser, + auth: FirstPartyUser, Path(method_id): Path, body: Option>, ) -> Result { @@ -193,7 +189,7 @@ pub async fn disable_totp( pub async fn regenerate_recovery_codes( State(state): State, ClientIp(ip): ClientIp, - auth: AuthUser, + auth: FirstPartyUser, Json(body): Json, ) -> Result, AppError> { let codes = tf_svc::generate_recovery_codes( @@ -210,26 +206,6 @@ pub async fn regenerate_recovery_codes( })) } -#[utoipa::path( - post, - path = "/users/me/two-factor/recovery-codes/use", - tag = "two-factor", - request_body = UseRecoveryCodeRequest, - responses( - (status = 204, description = "Code consumed"), - (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), - ), - security(("bearer" = [])), -)] -pub async fn use_recovery_code( - State(state): State, - auth: AuthUser, - Json(body): Json, -) -> Result { - tf_svc::use_recovery_code(&state, auth.user_id, &body.code, auth.request_id).await?; - Ok(StatusCode::NO_CONTENT) -} - // Email OTP 2FA #[utoipa::path( @@ -248,7 +224,7 @@ pub async fn use_recovery_code( pub async fn setup_email_otp( State(state): State, ClientIp(ip): ClientIp, - auth: AuthUser, + auth: FirstPartyUser, body: Option>, ) -> Result, AppError> { let body = body.map(|Json(b)| b).unwrap_or_default(); @@ -262,7 +238,7 @@ pub async fn setup_email_otp( ) .await?; // Send the first code immediately so the user can verify right away. - email_2fa_svc::send_code(&state, auth.user_id).await?; + email_2fa_svc::send_code(&state, auth.user_id, None).await?; Ok(Json(EmailOtpSetupResponse { method_id })) } @@ -273,15 +249,29 @@ pub async fn setup_email_otp( responses( (status = 204, description = "Code sent"), (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), + (status = 404, description = "No e-mail method waiting for confirmation", body = crate::error::ErrorBody), (status = 429, description = "Rate limited; see Retry-After"), ), security(("bearer" = [])), )] pub async fn send_email_otp_code( State(state): State, - auth: AuthUser, + auth: FirstPartyUser, ) -> Result { - email_2fa_svc::send_code(&state, auth.user_id).await?; + // Only while a method is being set up: otherwise a stolen session could + // fill the owner's mailbox with codes nobody asked for. + let pending = crate::repositories::two_factor::find_all_by_type( + &state.db, + auth.user_id, + crate::domain::two_factor::TwoFactorType::Email, + ) + .await? + .into_iter() + .any(|method| !method.is_verified); + if !pending { + return Err(AppError::NotFound); + } + email_2fa_svc::send_code(&state, auth.user_id, None).await?; Ok(StatusCode::NO_CONTENT) } @@ -300,7 +290,7 @@ pub async fn send_email_otp_code( )] pub async fn verify_email_otp_setup( State(state): State, - auth: AuthUser, + auth: FirstPartyUser, Path(method_id): Path, Json(body): Json, ) -> Result, AppError> { @@ -323,13 +313,14 @@ pub async fn verify_email_otp_setup( (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), (status = 403, description = "Recent re-authentication required", body = crate::error::ErrorBody), (status = 404, description = "No such method", body = crate::error::ErrorBody), + (status = 409, description = "`administrator_needs_second_factor`: the account holds administrative permissions and this is its last second factor", body = crate::error::ErrorBody), ), security(("bearer" = [])), )] pub async fn disable_email_otp( State(state): State, ClientIp(ip): ClientIp, - auth: AuthUser, + auth: FirstPartyUser, Path(method_id): Path, body: Option>, ) -> Result { @@ -386,7 +377,7 @@ pub struct TwoFactorOverviewResponse { )] pub async fn list( State(state): State, - auth: AuthUser, + auth: FirstPartyUser, ) -> Result, AppError> { let (methods, recovery_codes_remaining) = tf_svc::list_methods(&state, auth.user_id).await?; diff --git a/src/handlers/user.rs b/src/handlers/user.rs index 612656d..7f4fc1d 100644 --- a/src/handlers/user.rs +++ b/src/handlers/user.rs @@ -10,7 +10,7 @@ use crate::{ state::AppState, }; -use super::extractors::{AuthUser, ClientIp}; +use super::extractors::{ClientIp, FirstPartyUser}; // Request types @@ -105,7 +105,7 @@ pub struct UserResponse { )] pub async fn me( State(state): State, - auth: AuthUser, + auth: FirstPartyUser, ) -> Result, AppError> { let user = user_svc::get_profile(&state, auth.user_id).await?; @@ -138,7 +138,7 @@ pub async fn me( pub async fn change_username( State(state): State, ClientIp(ip): ClientIp, - auth: AuthUser, + auth: FirstPartyUser, Json(body): Json, ) -> Result { super::auth::validate_username(&body.username)?; @@ -173,7 +173,7 @@ pub async fn change_username( pub async fn start_email_change( State(state): State, ClientIp(ip): ClientIp, - auth: AuthUser, + auth: FirstPartyUser, body: Option>, ) -> Result, AppError> { let body = body.map(|Json(b)| b).unwrap_or_default(); @@ -202,7 +202,7 @@ pub async fn start_email_change( )] pub async fn verify_current_email( State(state): State, - auth: AuthUser, + auth: FirstPartyUser, Json(body): Json, ) -> Result { email_change_svc::verify_current(&state, auth.user_id, &body.flow_token, &body.code).await?; @@ -215,9 +215,8 @@ pub async fn verify_current_email( tag = "email-change", request_body = SubmitNewEmailRequest, responses( - (status = 204, description = "Code sent to the new address"), + (status = 204, description = "Code sent to the new address, unless it belongs to another account: the answer is the same either way"), (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), - (status = 409, description = "Address taken", body = crate::error::ErrorBody), (status = 422, description = "Invalid input", body = crate::error::ErrorBody), ), security(("bearer" = [])), @@ -225,7 +224,7 @@ pub async fn verify_current_email( pub async fn submit_new_email( State(state): State, ClientIp(ip): ClientIp, - auth: AuthUser, + auth: FirstPartyUser, Json(body): Json, ) -> Result { super::auth::validate_email(&body.new_email)?; @@ -256,7 +255,7 @@ pub async fn submit_new_email( pub async fn confirm_new_email( State(state): State, ClientIp(ip): ClientIp, - auth: AuthUser, + auth: FirstPartyUser, Json(body): Json, ) -> Result { email_change_svc::confirm_new( @@ -288,7 +287,7 @@ pub async fn confirm_new_email( pub async fn change_password( State(state): State, ClientIp(ip): ClientIp, - auth: AuthUser, + auth: FirstPartyUser, Json(body): Json, ) -> Result { validate_password(&body.new_password)?; @@ -322,7 +321,7 @@ pub async fn change_password( )] pub async fn change_locale( State(state): State, - auth: AuthUser, + auth: FirstPartyUser, Json(body): Json, ) -> Result { validate_locale(&body.locale)?; @@ -396,13 +395,14 @@ pub fn validate_password(password: &str) -> Result<(), AppError> { (status = 204, description = "Account deleted"), (status = 401, description = "Missing, invalid or revoked access token", body = crate::error::ErrorBody), (status = 403, description = "Recent re-authentication required", body = crate::error::ErrorBody), + (status = 409, description = "`last_administrator`: the account is the last active one able to manage roles", body = crate::error::ErrorBody), ), security(("bearer" = [])), )] pub async fn delete_account( State(state): State, ClientIp(ip): ClientIp, - auth: AuthUser, + auth: FirstPartyUser, body: Option>, ) -> Result { let current_password = body.and_then(|Json(b)| b.current_password); @@ -434,7 +434,7 @@ pub async fn delete_account( pub async fn reauthenticate( State(state): State, ClientIp(ip): ClientIp, - auth: AuthUser, + auth: FirstPartyUser, Json(body): Json, ) -> Result { user_svc::reauthenticate( @@ -464,7 +464,7 @@ pub async fn reauthenticate( pub async fn export_data( State(state): State, ClientIp(ip): ClientIp, - auth: AuthUser, + auth: FirstPartyUser, ) -> Result { let document = user_svc::export_data(&state, auth.user_id, auth.session_id, ip, auth.request_id).await?; diff --git a/src/main.rs b/src/main.rs index 1f7c68f..efad2de 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,6 +30,11 @@ async fn main() -> anyhow::Result<()> { let tracer_provider = auth_api::telemetry::tracer_provider(&config.telemetry)?; init_tracing(&config.log, tracer_provider.as_ref()); auth_api::utils::password::log_capacity(&config.crypto); + if config.is_production() { + for (key, consequence) in auth_api::config::weakened_settings(&config) { + tracing::warn!(key, consequence, "production runs with a weakened setting"); + } + } // One-off command: re-encrypt all TOTP secrets with the new key. // Set PREVIOUS_ENCRYPTION_KEY= ENCRYPTION_KEY=, run, then remove PREVIOUS_ENCRYPTION_KEY. @@ -76,9 +81,9 @@ async fn main() -> anyhow::Result<()> { .max_connections(1) .connect(&config.database.url) .await?; - let client = - auth_api::repositories::registered_client::upsert(&pool, ®istration.as_new()) - .await?; + let client = auth_api::cli::register_client(&pool, ®istration) + .await + .map_err(|message| anyhow::anyhow!("--register-client: {message}"))?; tracing::info!( client_id = client.client_id, primary = client.is_primary, @@ -94,6 +99,18 @@ async fn main() -> anyhow::Result<()> { let state = AppState::from_config(config).await?; + // Secrets written under a key that is no longer configured cannot be read: + // every TOTP sign-in and webhook delivery they belong to would fail. Refuse + // to serve rather than fail those requests one by one. + let unreadable = key_rotation::secrets_under_unknown_keys(&state).await?; + if unreadable > 0 { + anyhow::bail!( + "{unreadable} TOTP or webhook secrets are encrypted with a key that is neither \ + ENCRYPTION_KEY nor PREVIOUS_ENCRYPTION_KEY: restore the previous key and finish \ + the rotation with --rotate-totp-keys before removing it" + ); + } + // Rotate audit log partitions at startup: creates upcoming monthly partitions // and drops partitions older than retention_months. if let Err(e) = cleanup::rotate_audit_log(&state.db, state.config.audit.retention_months).await @@ -116,7 +133,10 @@ async fn main() -> anyhow::Result<()> { // exposition endpoint never sits behind the public reverse proxy. // docker-compose publishes this port on loopback only. let app = if state.config.metrics.enabled { - let metrics_addr = format!("{}:{}", state.config.server.host, state.config.metrics.port); + let metrics_addr = format!( + "{}:{}", + state.config.metrics.host, state.config.metrics.port + ); let (app, metrics_app) = handlers::router_with_metrics(state); let metrics_listener = tokio::net::TcpListener::bind(&metrics_addr).await?; diff --git a/src/middleware/client_ip.rs b/src/middleware/client_ip.rs index 3cbee59..3a1d6ab 100644 --- a/src/middleware/client_ip.rs +++ b/src/middleware/client_ip.rs @@ -51,7 +51,7 @@ pub(crate) fn resolve_client_ip( headers: &HeaderMap, trusted_proxy_cidrs: &[IpNetwork], ) -> Option { - let peer = peer?; + let peer = canonical(peer?); if !is_trusted_proxy(peer, trusted_proxy_cidrs) { return Some(peer); } @@ -59,9 +59,20 @@ pub(crate) fn resolve_client_ip( } fn is_trusted_proxy(ip: IpAddr, trusted_proxy_cidrs: &[IpNetwork]) -> bool { + // A dual-stack listener sees an IPv4 peer as `::ffff:a.b.c.d`: compared + // as the IPv4 address it is, or an IPv4 proxy would never be trusted. + let ip = canonical(ip); trusted_proxy_cidrs.iter().any(|cidr| cidr.contains(ip)) } +/// An IPv4-mapped IPv6 address as the IPv4 address it carries. +fn canonical(ip: IpAddr) -> IpAddr { + match ip { + IpAddr::V6(v6) => v6.to_ipv4_mapped().map_or(ip, IpAddr::V4), + v4 => v4, + } +} + /// The client named by the forwarding headers of a trusted proxy. /// /// `X-Forwarded-For` is read across every header line, as one list. Walking it @@ -162,6 +173,16 @@ mod tests { const PROXY: IpAddr = IpAddr::V4(std::net::Ipv4Addr::new(10, 0, 0, 2)); + #[test] + fn an_ipv4_mapped_proxy_is_trusted_as_its_ipv4_address() { + let mapped: IpAddr = match PROXY { + IpAddr::V4(v4) => IpAddr::V6(v4.to_ipv6_mapped()), + v6 => v6, + }; + let ip = resolve_client_ip(Some(mapped), &forwarded(&["1.2.3.4"]), &trusted()); + assert_eq!(ip, Some("1.2.3.4".parse().unwrap())); + } + #[test] fn every_forwarded_line_counts_as_one_list() { // A line the client sent, then the proxy's own: the proxy's hop wins. diff --git a/src/middleware/security_headers.rs b/src/middleware/security_headers.rs index 92015ee..48a0940 100644 --- a/src/middleware/security_headers.rs +++ b/src/middleware/security_headers.rs @@ -69,6 +69,14 @@ pub async fn layer( if !headers.contains_key("cache-control") { headers.insert("cache-control", HeaderValue::from_static("no-store")); } + // RFC 6749 section 5.1 pairs `no-store` with `Pragma: no-cache` for + // HTTP/1.0 caches on every response carrying tokens. + if headers + .get("cache-control") + .is_some_and(|value| value.as_bytes().starts_with(b"no-store")) + { + headers.insert("pragma", HeaderValue::from_static("no-cache")); + } res } diff --git a/src/openapi.rs b/src/openapi.rs index 34c0a89..3d82753 100644 --- a/src/openapi.rs +++ b/src/openapi.rs @@ -90,7 +90,6 @@ use utoipa::{ crate::handlers::two_factor::verify_totp_setup, crate::handlers::two_factor::disable_totp, crate::handlers::two_factor::regenerate_recovery_codes, - crate::handlers::two_factor::use_recovery_code, crate::handlers::two_factor::setup_email_otp, crate::handlers::two_factor::send_email_otp_code, crate::handlers::two_factor::verify_email_otp_setup, @@ -102,6 +101,7 @@ use utoipa::{ crate::handlers::admin::users::unlock, crate::handlers::admin::users::revoke_sessions, crate::handlers::admin::users::force_password_reset, + crate::handlers::admin::users::remove_access_factors, crate::handlers::admin::users::delete, crate::handlers::admin::roles::permissions, crate::handlers::admin::roles::list, @@ -172,7 +172,8 @@ impl Modify for CommonResponses { fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { use utoipa::openapi::{PathItem, RefOr, response::ResponseBuilder}; - for item in openapi.paths.paths.values_mut() { + for (path, item) in openapi.paths.paths.iter_mut() { + let first_party = requires_first_party(path); let PathItem { get, put, @@ -211,6 +212,12 @@ impl Modify for CommonResponses { if protected { common.push(("401", "Missing, invalid or revoked access token")); } + if first_party { + common.push(( + "403", + "`first_party_session_required`: a token delegated to a client or issued for a personal access token", + )); + } let responses = &mut operation.responses.responses; for (status, description) in common { @@ -239,6 +246,15 @@ impl Modify for CommonResponses { } } +/// Operations taking [`crate::handlers::extractors::FirstPartyUser`]: every +/// one of them may answer `first_party_session_required`. +pub fn requires_first_party(path: &str) -> bool { + path.starts_with("/users/me") + || path.starts_with("/admin/") + || path.starts_with("/oauth/authorization-requests/") + || path.starts_with("/oauth/device/") +} + fn error_content() -> utoipa::openapi::content::Content { utoipa::openapi::content::ContentBuilder::new() .schema(Some(utoipa::openapi::Ref::from_schema_name("ErrorBody"))) @@ -271,7 +287,17 @@ mod tests { /// Every `.route(...)` of the router, with its nesting prefix. fn routed_endpoints() -> BTreeSet<(String, String)> { + routes() + .into_iter() + .map(|(method, full, _)| (method, full)) + .collect() + } + + /// Every `.route(...)` of the router: `(method, full path, router function)`. + fn routes() -> Vec<(String, String, String)> { let source = include_str!("handlers/mod.rs"); + // The unit tests at the end build routers of their own. + let source = source.split("#[cfg(test)]").next().expect("source"); let functions: Vec<(usize, &str)> = source .match_indices("fn ") .filter_map(|(at, _)| { @@ -283,7 +309,7 @@ mod tests { }) .collect(); - let mut routes = BTreeSet::new(); + let mut routes = Vec::new(); for (at, _) in source.match_indices(".route(") { let call = &source[at..]; let open = call.find('"').expect("route path") + 1; @@ -305,7 +331,7 @@ mod tests { "auth_router" => "/auth", "me_router" | "me_strict_router" => "/users/me", "admin_router" => "/admin", - "oauth_router" => "/oauth", + "oauth_router" | "oauth_client_router" => "/oauth", _ => "", }; let full = match route { @@ -313,12 +339,81 @@ mod tests { _ => format!("{prefix}{route}"), }; if full != "/metrics" { - routes.insert((method, full)); + routes.push((method, full, enclosing.to_owned())); } } routes } + /// nginx limits strictly exactly the requests the API does: the patterns of + /// `$auth_limit_key` in `nginx/nginx.conf` against the routers holding the + /// strict bucket, method by method. + #[test] + fn nginx_limits_strictly_what_the_api_does() { + let conf = include_str!("../nginx/nginx.conf"); + let map = conf + .split("map \"$request_method $uri\" $auth_limit_key {") + .nth(1) + .and_then(|rest| rest.split("\n}").next()) + .expect("the $auth_limit_key map"); + let patterns: Vec<(regex::Regex, bool)> = map + .lines() + .map(str::trim) + .filter(|line| line.starts_with("\"~")) + .map(|line| { + let end = line[1..].find('"').expect("closing quote") + 1; + let pattern = regex::Regex::new(&line[2..end]).expect("a valid pattern"); + ( + pattern, + line[end + 1..].trim().trim_end_matches(';') != "\"\"", + ) + }) + .collect(); + assert!(patterns.len() >= 5, "found {} patterns", patterns.len()); + + let mut mismatches = Vec::new(); + for (method, path, router) in routes() { + let method = method + .rsplit("::") + .next() + .unwrap_or(&method) + .to_ascii_uppercase(); + let concrete: String = path + .split('/') + .map(|segment| { + if segment.starts_with('{') { + "x1" + } else { + segment + } + }) + .collect::>() + .join("/"); + let key = format!("{method} {concrete}"); + let nginx_strict = patterns + .iter() + .find(|(pattern, _)| pattern.is_match(&key)) + .is_some_and(|(_, strict)| *strict); + let api_strict = matches!( + router.as_str(), + "auth_router" | "oauth_router" | "me_strict_router" + ); + if nginx_strict != api_strict { + mismatches.push(format!("{key}: nginx {nginx_strict}, API {api_strict}")); + } + } + assert_eq!(mismatches, Vec::::new()); + + // Every proxied location but the probes and the discovery documents + // applies the strict zone too. + let catch_all = conf + .split("location / {") + .nth(1) + .and_then(|rest| rest.split('}').next()) + .expect("the catch-all location"); + assert!(catch_all.contains("zone=api_auth"), "{catch_all}"); + } + fn documented_endpoints() -> BTreeSet<(String, String)> { let document = serde_json::to_value(ApiDoc::openapi()).expect("document to JSON"); let mut endpoints = BTreeSet::new(); diff --git a/src/repositories/authorization_code.rs b/src/repositories/authorization_code.rs index 3d5980c..e8e4d6c 100644 --- a/src/repositories/authorization_code.rs +++ b/src/repositories/authorization_code.rs @@ -3,7 +3,7 @@ //! Redemption is one statement that consumes and returns the code, so two //! concurrent redemptions cannot both succeed. -use sqlx::PgPool; +use sqlx::{PgExecutor, PgPool}; use time::OffsetDateTime; use uuid::Uuid; @@ -19,6 +19,7 @@ pub struct AuthorizationCode { pub consumed_at: Option, pub session_id: Option, pub nonce: Option, + pub auth_time: OffsetDateTime, } pub struct NewAuthorizationCode<'a> { @@ -29,16 +30,20 @@ pub struct NewAuthorizationCode<'a> { pub code_challenge: &'a str, pub scopes: Option<&'a [String]>, pub nonce: Option<&'a str>, + pub auth_time: OffsetDateTime, pub expires_at: OffsetDateTime, } -const COLUMNS: &str = "id, user_id, client_id, redirect_uri, code_challenge, scopes, expires_at, consumed_at, session_id, nonce"; +const COLUMNS: &str = "id, user_id, client_id, redirect_uri, code_challenge, scopes, expires_at, consumed_at, session_id, nonce, auth_time"; -pub async fn create(pool: &PgPool, input: &NewAuthorizationCode<'_>) -> Result { +pub async fn create<'e>( + executor: impl PgExecutor<'e>, + input: &NewAuthorizationCode<'_>, +) -> Result { sqlx::query_scalar::<_, Uuid>( "INSERT INTO authorization_codes - (code_hash, user_id, client_id, redirect_uri, code_challenge, scopes, expires_at, nonce) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + (code_hash, user_id, client_id, redirect_uri, code_challenge, scopes, expires_at, nonce, auth_time) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id", ) .bind(input.code_hash) @@ -49,14 +54,15 @@ pub async fn create(pool: &PgPool, input: &NewAuthorizationCode<'_>) -> Result( + executor: impl sqlx::PgExecutor<'e>, code_hash: &[u8], ) -> Result, sqlx::Error> { sqlx::query_as::<_, AuthorizationCode>(&format!( @@ -66,7 +72,7 @@ pub async fn consume( RETURNING {COLUMNS}" )) .bind(code_hash) - .fetch_optional(pool) + .fetch_optional(executor) .await } @@ -84,11 +90,15 @@ pub async fn find( } /// Remember which session a code produced, so a replay can revoke it. -pub async fn attach_session(pool: &PgPool, id: Uuid, session_id: Uuid) -> Result<(), sqlx::Error> { +pub async fn attach_session<'e>( + executor: impl sqlx::PgExecutor<'e>, + id: Uuid, + session_id: Uuid, +) -> Result<(), sqlx::Error> { sqlx::query("UPDATE authorization_codes SET session_id = $2 WHERE id = $1") .bind(id) .bind(session_id) - .execute(pool) + .execute(executor) .await?; Ok(()) } diff --git a/src/repositories/email_2fa.rs b/src/repositories/email_2fa.rs index 0d962b1..fdc1570 100644 --- a/src/repositories/email_2fa.rs +++ b/src/repositories/email_2fa.rs @@ -34,7 +34,8 @@ pub async fn find_active_by_user( .await } -/// Finds the user's live (unused, unexpired) code matching `code_hash`. +/// Finds the user's live (unused, unexpired) code matching one of `code_hashes` +/// (the digest of the submitted code under each key of the keyring). /// /// Scoped to the user on purpose: a 6-digit code is not unique across accounts, /// and an unscoped lookup could return another user's row, fail a valid code, @@ -42,35 +43,48 @@ pub async fn find_active_by_user( pub async fn find_active_by_user_and_hash( pool: &PgPool, user_id: Uuid, - code_hash: &[u8], + code_hashes: &[Vec], ) -> Result, sqlx::Error> { sqlx::query_as::<_, Email2faCode>( "SELECT * FROM email_2fa_codes WHERE user_id = $1 - AND code_hash = $2 + AND code_hash = ANY($2) AND used_at IS NULL AND expires_at > now() ORDER BY created_at DESC LIMIT 1", ) .bind(user_id) - .bind(code_hash) + .bind(code_hashes) .fetch_optional(pool) .await } /// Marks a code as used. Returns true if the row was updated, false if it was /// already used or expired in the meantime. +/// Mark the code used, and the other live code of its account with it: the +/// previous code, kept alive by a resend, ends with the one that worked. pub async fn consume(pool: &PgPool, id: Uuid) -> Result { - let result = sqlx::query( + let mut tx = pool.begin().await?; + let user_id: Option = sqlx::query_scalar( "UPDATE email_2fa_codes SET used_at = now() - WHERE id = $1 AND used_at IS NULL AND expires_at > now()", + WHERE id = $1 AND used_at IS NULL AND expires_at > now() + RETURNING user_id", ) .bind(id) - .execute(pool) + .fetch_optional(&mut *tx) .await?; - - Ok(result.rows_affected() == 1) + let Some(user_id) = user_id else { + return Ok(false); + }; + sqlx::query( + "UPDATE email_2fa_codes SET used_at = now() WHERE user_id = $1 AND used_at IS NULL", + ) + .bind(user_id) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(true) } #[derive(Debug, sqlx::FromRow)] @@ -97,10 +111,22 @@ pub async fn replace_active_in_tx( tx: &mut Transaction<'_, Postgres>, input: &NewEmail2faCode<'_>, ) -> Result { - sqlx::query("DELETE FROM email_2fa_codes WHERE user_id = $1 AND used_at IS NULL") - .bind(input.user_id) - .execute(&mut **tx) - .await?; + // The latest unused code survives the new one: a resend, possibly asked + // by someone else holding the challenge, must not kill the code its owner + // is typing. Two codes at most are live, and both end when one is used. + sqlx::query( + "DELETE FROM email_2fa_codes + WHERE user_id = $1 AND used_at IS NULL + AND id <> COALESCE(( + SELECT id FROM email_2fa_codes + WHERE user_id = $1 AND used_at IS NULL + ORDER BY created_at DESC + LIMIT 1 + ), '00000000-0000-0000-0000-000000000000')", + ) + .bind(input.user_id) + .execute(&mut **tx) + .await?; let row: (Uuid,) = sqlx::query_as( "INSERT INTO email_2fa_codes (user_id, code_hash, expires_at) diff --git a/src/repositories/export.rs b/src/repositories/export.rs index 3b74aa6..45fc68a 100644 --- a/src/repositories/export.rs +++ b/src/repositories/export.rs @@ -1,7 +1,8 @@ //! Everything stored about one account, as one JSON document: what //! `GET /users/me/export` returns. Built in one statement, so the parts are //! consistent with each other. Secrets (password hash, TOTP secret, token and -//! code digests) are left out. Timestamps are Unix seconds, like the API. +//! code digests, passkey keys) are left out, and so is what identifies an +//! administrator who changed the account. Timestamps are Unix seconds, like the API. use sqlx::PgPool; use uuid::Uuid; @@ -79,13 +80,72 @@ SELECT jsonb_build_object( ) ORDER BY q.client_id) FROM user_client_quotas q WHERE q.user_id = $1 ), '[]'::jsonb), + 'passkeys', COALESCE(( + SELECT jsonb_agg(jsonb_build_object( + 'id', k.id, + 'name', k.name, + 'aaguid', k.aaguid, + 'backed_up', k.backed_up, + 'created_at', floor(extract(epoch FROM k.created_at))::bigint, + 'last_used_at', floor(extract(epoch FROM k.last_used_at))::bigint + ) ORDER BY k.created_at) + FROM passkeys k WHERE k.user_id = $1 + ), '[]'::jsonb), + 'personal_access_tokens', COALESCE(( + SELECT jsonb_agg(jsonb_build_object( + 'id', t.id, + 'name', t.name, + 'scopes', to_jsonb(t.scopes), + 'created_at', floor(extract(epoch FROM t.created_at))::bigint, + 'expires_at', floor(extract(epoch FROM t.expires_at))::bigint, + 'last_used_at', floor(extract(epoch FROM t.last_used_at))::bigint + ) ORDER BY t.created_at) + FROM personal_access_tokens t WHERE t.user_id = $1 + ), '[]'::jsonb), + 'external_identities', COALESCE(( + SELECT jsonb_agg(jsonb_build_object( + 'provider', x.provider, + 'subject', x.subject, + 'created_at', floor(extract(epoch FROM x.created_at))::bigint, + 'last_used_at', floor(extract(epoch FROM x.last_used_at))::bigint + ) ORDER BY x.created_at) + FROM external_identities x WHERE x.user_id = $1 + ), '[]'::jsonb), + -- Where each link mailed to the account was asked from. + 'mailed_link_requests', COALESCE(( + SELECT jsonb_agg(jsonb_build_object( + 'kind', r.kind, + 'requested_at', floor(extract(epoch FROM r.created_at))::bigint, + -- Anyone knowing the address can ask for a link: only the network + -- of the asker is shown, not a stranger's exact address. + 'ip_address', host(network(set_masklen(r.request_ip, + CASE WHEN family(r.request_ip) = 4 THEN 24 ELSE 48 END))), + 'user_agent', r.request_user_agent + ) ORDER BY r.created_at) + FROM ( + SELECT 'verification' AS kind, created_at, request_ip, request_user_agent + FROM email_verification_tokens WHERE user_id = $1 + UNION ALL + SELECT 'password_reset', created_at, request_ip, request_user_agent + FROM password_reset_tokens WHERE user_id = $1 + UNION ALL + SELECT 'sign_in_link', created_at, request_ip, request_user_agent + FROM magic_link_tokens WHERE user_id = $1 + ) r + ), '[]'::jsonb), + -- Failed attempts typed for the account include those of other people: + -- they are the account's security history and stay in its export. 'sign_in_attempts', COALESCE(( SELECT jsonb_agg(jsonb_build_object( 'attempted_at', floor(extract(epoch FROM a.attempted_at))::bigint, 'identifier', a.attempted_identifier, 'successful', a.was_successful, 'failure_reason', a.failure_reason, - 'ip_address', host(a.request_ip), + -- A failed attempt may be anyone's: its network only. A successful + -- one was the owner's. + 'ip_address', CASE WHEN a.was_successful THEN host(a.request_ip) + ELSE host(network(set_masklen(a.request_ip, + CASE WHEN family(a.request_ip) = 4 THEN 24 ELSE 48 END))) END, 'user_agent', a.request_user_agent ) ORDER BY a.attempted_at) FROM login_attempts a WHERE a.user_id = $1 @@ -95,9 +155,14 @@ SELECT jsonb_build_object( 'id', l.id, 'created_at', floor(extract(epoch FROM l.created_at))::bigint, 'action', l.action, - 'ip_address', host(l.ip_address), + -- An administrator's change names neither the administrator nor + -- the address they acted from: those are theirs, not the owner's. + 'ip_address', CASE WHEN l.metadata->>'by' IN ('administrator', 'command_line') + THEN NULL ELSE host(l.ip_address) END, 'request_id', l.request_id, - 'metadata', l.metadata + -- Nor the operator account and host of a command-line change + -- (domain::audit::OPERATOR_FIELDS). + 'metadata', l.metadata - 'administrator_id' - 'operator' - 'host' ) ORDER BY l.created_at, l.id) FROM audit_log l WHERE l.user_id = $1 AND l.created_at <= NOW() ), '[]'::jsonb) @@ -109,8 +174,34 @@ pub async fn account_document( pool: &PgPool, user_id: Uuid, ) -> Result, sqlx::Error> { - sqlx::query_scalar(EXPORT_SQL) + let document: Option = sqlx::query_scalar(EXPORT_SQL) .bind(user_id) .fetch_optional(pool) - .await + .await?; + Ok(document.map(coarsen_strangers_user_agents)) +} + +/// Link requests and failed sign-ins may be anyone's: like their address, +/// reduced to its network, their user agent is reduced to its family ("Firefox +/// on Linux"), which with the network no longer singles a stranger out. +fn coarsen_strangers_user_agents(mut document: serde_json::Value) -> serde_json::Value { + let coarsen = |entry: &mut serde_json::Value| { + let family = crate::domain::known_device::device_family( + entry.get("user_agent").and_then(|ua| ua.as_str()), + ) + .describe(); + if entry.get("user_agent").is_some_and(|ua| !ua.is_null()) { + entry["user_agent"] = serde_json::Value::String(family); + } + }; + if let Some(requests) = document["mailed_link_requests"].as_array_mut() { + requests.iter_mut().for_each(coarsen); + } + if let Some(attempts) = document["sign_in_attempts"].as_array_mut() { + attempts + .iter_mut() + .filter(|attempt| attempt["successful"] != serde_json::Value::Bool(true)) + .for_each(coarsen); + } + document } diff --git a/src/repositories/external_identity.rs b/src/repositories/external_identity.rs index af27ec7..f8a828b 100644 --- a/src/repositories/external_identity.rs +++ b/src/repositories/external_identity.rs @@ -42,8 +42,8 @@ pub async fn find_by_user( /// Link an identity to an account. A unique violation means the identity, or /// an identity at this provider for this account, is already linked. -pub async fn link( - pool: &PgPool, +pub async fn link<'e>( + executor: impl sqlx::PgExecutor<'e>, user_id: Uuid, provider: &str, subject: &str, @@ -56,7 +56,7 @@ pub async fn link( .bind(user_id) .bind(provider) .bind(subject) - .fetch_one(pool) + .fetch_one(executor) .await } diff --git a/src/repositories/login_attempt.rs b/src/repositories/login_attempt.rs index dcb686b..a266049 100644 --- a/src/repositories/login_attempt.rs +++ b/src/repositories/login_attempt.rs @@ -11,11 +11,22 @@ use uuid::Uuid; use crate::domain::login_attempt::LoginFailureReason; +/// Failures typed before the account last opened or was unlocked (a sign-in, +/// an administrator's unlock, a password reset) no longer count: whoever typed +/// them cannot keep the owner out past that. pub const COUNT_RECENT_FAILURES_BY_IDENTIFIER_SQL: &str = "SELECT COUNT(*) FROM ( SELECT 1 FROM login_attempts WHERE attempted_identifier = $1::citext AND was_successful = FALSE - AND attempted_at > $2 + AND attempted_at > GREATEST( + $2, + COALESCE( + (SELECT lockout_cleared_at FROM users + WHERE email = $1::citext OR lower(username) = lower($1) + LIMIT 1), + '-infinity'::timestamptz + ) + ) LIMIT $3 ) sub"; @@ -27,10 +38,13 @@ pub const COUNT_RECENT_FAILURES_BY_IP_SQL: &str = "SELECT COUNT(*) FROM ( LIMIT $3 ) sub"; -/// Consecutive wrong passwords since the last successful sign-in. Only -/// `invalid_password` counts: a failed second factor comes from someone who -/// already holds the password, and letting it lock the account would hand them -/// a way to shut the owner out. +/// Consecutive wrong passwords of the last day since the account last opened +/// or was unlocked: a completed sign-in by any method, an administrator's +/// unlock or a password reset, or the end of the previous lockout. Failures +/// during a lockout do not count toward the next one, and an old typo never +/// adds up with a new one. Only `invalid_password` counts: a failed second +/// factor comes from someone who already holds the password, and letting it +/// lock the account would hand them a way to shut the owner out. pub const COUNT_CONSECUTIVE_FAILURES_BY_USER_SQL: &str = "SELECT COUNT(*) FROM ( SELECT 1 FROM login_attempts WHERE user_id = $1 @@ -40,7 +54,8 @@ pub const COUNT_CONSECUTIVE_FAILURES_BY_USER_SQL: &str = "SELECT COUNT(*) FROM ( (SELECT MAX(attempted_at) FROM login_attempts WHERE user_id = $1 AND was_successful = TRUE), (SELECT lockout_cleared_at FROM users WHERE id = $1), - '1970-01-01'::TIMESTAMPTZ + (SELECT locked_until FROM users WHERE id = $1), + NOW() - INTERVAL '1 day' ) LIMIT $2 ) sub"; diff --git a/src/repositories/passkey.rs b/src/repositories/passkey.rs index f1356bc..fe7d494 100644 --- a/src/repositories/passkey.rs +++ b/src/repositories/passkey.rs @@ -102,11 +102,15 @@ pub async fn record_use( Ok(result.rows_affected() == 1) } -pub async fn delete_owned(pool: &PgPool, id: Uuid, user_id: Uuid) -> Result { +pub async fn delete_owned<'e>( + executor: impl sqlx::PgExecutor<'e>, + id: Uuid, + user_id: Uuid, +) -> Result { let result = sqlx::query("DELETE FROM passkeys WHERE id = $1 AND user_id = $2") .bind(id) .bind(user_id) - .execute(pool) + .execute(executor) .await?; Ok(result.rows_affected() == 1) } diff --git a/src/repositories/registered_client.rs b/src/repositories/registered_client.rs index fb53b52..426117c 100644 --- a/src/repositories/registered_client.rs +++ b/src/repositories/registered_client.rs @@ -16,13 +16,13 @@ pub struct NewRegisteredClient<'a> { } /// Find a registered client by its client_id. -pub async fn find_by_id( - pool: &PgPool, +pub async fn find_by_id<'e>( + executor: impl PgExecutor<'e>, client_id: &str, ) -> Result, sqlx::Error> { sqlx::query_as::<_, RegisteredClient>("SELECT * FROM registered_clients WHERE client_id = $1") .bind(client_id) - .fetch_optional(pool) + .fetch_optional(executor) .await } @@ -99,8 +99,8 @@ pub async fn lock_existing<'e>( /// Set the digest of the client's secret, or clear it to make the client /// public. Returns whether the client exists. -pub async fn set_secret_hash( - pool: &PgPool, +pub async fn set_secret_hash<'e>( + executor: impl sqlx::PgExecutor<'e>, client_id: &str, secret_hash: Option<&[u8]>, ) -> Result { @@ -113,11 +113,26 @@ pub async fn set_secret_hash( ) .bind(client_id) .bind(secret_hash) - .execute(pool) + .execute(executor) .await?; Ok(result.rows_affected() == 1) } +/// Make the client a resource server, or not. Returns whether it exists. +pub async fn set_introspection<'e>( + executor: impl PgExecutor<'e>, + client_id: &str, + allowed: bool, +) -> Result { + let result = + sqlx::query("UPDATE registered_clients SET allows_introspection = $2 WHERE client_id = $1") + .bind(client_id) + .bind(allowed) + .execute(executor) + .await?; + Ok(result.rows_affected() == 1) +} + /// Allow or forbid the client credentials grant. Returns whether the client /// exists. pub async fn set_client_credentials<'e>( @@ -134,3 +149,13 @@ pub async fn set_client_credentials<'e>( .await?; Ok(result.rows_affected() == 1) } + +/// The scopes some registered client may ask for: what the metadata offers. +/// Permissions no client can request stay unlisted. +pub async fn assignable_scopes(pool: &PgPool) -> Result, sqlx::Error> { + sqlx::query_scalar( + "SELECT DISTINCT scope FROM registered_clients, unnest(scopes) AS scope ORDER BY scope", + ) + .fetch_all(pool) + .await +} diff --git a/src/repositories/role.rs b/src/repositories/role.rs index 2190697..88d176e 100644 --- a/src/repositories/role.rs +++ b/src/repositories/role.rs @@ -128,8 +128,8 @@ pub async fn find_permissions_by_user( } /// Single-query permission check; avoids loading the full permission list. -pub async fn user_has_permission( - pool: &PgPool, +pub async fn user_has_permission<'e>( + executor: impl sqlx::PgExecutor<'e>, user_id: Uuid, permission_name: &str, ) -> Result { @@ -145,7 +145,7 @@ pub async fn user_has_permission( ) .bind(user_id) .bind(permission_name) - .fetch_one(pool) + .fetch_one(executor) .await?; Ok(row.0) } @@ -191,6 +191,27 @@ pub async fn find_all_with_permissions( .collect()) } +/// The names of the permissions the role grants, locking the role's row +/// until the transaction ends: its grants cannot change under a check. +pub async fn lock_permissions( + tx: &mut sqlx::PgConnection, + role_id: Uuid, +) -> Result, sqlx::Error> { + sqlx::query("SELECT id FROM roles WHERE id = $1 FOR UPDATE") + .bind(role_id) + .execute(&mut *tx) + .await?; + sqlx::query_scalar( + "SELECT p.name FROM role_permissions rp + JOIN permissions p ON p.id = rp.permission_id + WHERE rp.role_id = $1 + ORDER BY p.name", + ) + .bind(role_id) + .fetch_all(&mut *tx) + .await +} + pub async fn find_all_permissions(pool: &PgPool) -> Result, sqlx::Error> { sqlx::query_as::<_, Permission>("SELECT * FROM permissions ORDER BY name") .fetch_all(pool) @@ -277,10 +298,74 @@ pub async fn permission_held<'e>( SELECT 1 FROM user_roles ur JOIN role_permissions rp ON rp.role_id = ur.role_id JOIN permissions p ON p.id = rp.permission_id - WHERE p.name = $1 + JOIN users u ON u.id = ur.user_id + WHERE p.name = $1 AND u.status = 'active' )", ) .bind(permission) .fetch_one(executor) .await } + +/// Whether the role grants at least one administrative permission. +pub async fn grants_administration<'e>( + executor: impl sqlx::PgExecutor<'e>, + role_id: Uuid, +) -> Result { + sqlx::query_scalar( + "SELECT EXISTS ( + SELECT 1 FROM role_permissions rp + JOIN permissions p ON p.id = rp.permission_id + WHERE rp.role_id = $1 AND p.name = ANY($2) + )", + ) + .bind(role_id) + .bind(&crate::domain::role::ADMIN_PERMISSIONS[..]) + .fetch_one(executor) + .await +} + +/// Whether `user_id` holds the role. +pub async fn holds_role<'e>( + executor: impl sqlx::PgExecutor<'e>, + user_id: Uuid, + role_id: Uuid, +) -> Result { + sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM user_roles WHERE user_id = $1 AND role_id = $2)", + ) + .bind(user_id) + .bind(role_id) + .fetch_one(executor) + .await +} + +/// The accounts holding the role. +pub async fn holders<'e>( + executor: impl sqlx::PgExecutor<'e>, + role_id: Uuid, +) -> Result, sqlx::Error> { + sqlx::query_scalar("SELECT user_id FROM user_roles WHERE role_id = $1 ORDER BY user_id") + .bind(role_id) + .fetch_all(executor) + .await +} + +/// Whether the account holds any administrative permission. +pub async fn holds_administration<'e>( + executor: impl sqlx::PgExecutor<'e>, + user_id: Uuid, +) -> Result { + sqlx::query_scalar( + "SELECT EXISTS ( + SELECT 1 FROM user_roles ur + JOIN role_permissions rp ON rp.role_id = ur.role_id + JOIN permissions p ON p.id = rp.permission_id + WHERE ur.user_id = $1 AND p.name = ANY($2) + )", + ) + .bind(user_id) + .bind(&crate::domain::role::ADMIN_PERMISSIONS[..]) + .fetch_one(executor) + .await +} diff --git a/src/repositories/session.rs b/src/repositories/session.rs index e37c5e2..58607ca 100644 --- a/src/repositories/session.rs +++ b/src/repositories/session.rs @@ -13,8 +13,14 @@ use uuid::Uuid; use crate::domain::session::{Session, SessionType}; pub const FIND_BY_TOKEN_HASH_SQL: &str = "SELECT * FROM sessions WHERE token_hash = $1"; -pub const FIND_VALIDATION_BY_ID_SQL: &str = - "SELECT expires_at, revoked_at FROM sessions WHERE id = $1"; +/// What the per-request token check needs: whether the session still runs, +/// and whether it acts for the account itself (see [`SessionValidation::first_party`]). +pub const FIND_VALIDATION_BY_ID_SQL: &str = "SELECT s.expires_at, s.revoked_at, s.session_type, + s.scopes IS NOT NULL AS scoped, s.client_id IS NOT NULL AS for_client, + COALESCE(c.is_primary, FALSE) AS primary_client + FROM sessions s + LEFT JOIN registered_clients c ON c.client_id = s.client_id + WHERE s.id = $1"; pub const FIND_ACTIVE_BY_USER_SQL: &str = "SELECT * FROM sessions WHERE user_id = $1 AND revoked_at IS NULL ORDER BY last_used_at DESC"; @@ -42,12 +48,22 @@ pub struct NewSession<'a> { pub family_created_at: Option, /// Consented client scopes; `None` for an unrestricted session. pub scopes: Option<&'a [String]>, + /// The sign-in proved a second factor. A rotation inherits it whatever + /// this says. + pub mfa: bool, } #[derive(Debug, Clone, sqlx::FromRow)] pub struct SessionValidation { pub expires_at: OffsetDateTime, pub revoked_at: Option, + pub session_type: SessionType, + /// The session carries consented scopes. + pub scoped: bool, + /// The session was issued to a client application. + pub for_client: bool, + /// That client is the instance's own application. + pub primary_client: bool, } #[derive(Debug, Clone, sqlx::FromRow)] @@ -67,6 +83,17 @@ impl SessionValidation { pub fn is_active(&self, now: OffsetDateTime) -> bool { self.revoked_at.is_none() && self.expires_at > now } + + /// Whether the session acts for the account itself rather than for a + /// client it delegated to: see [`crate::domain::session::is_first_party`]. + pub fn first_party(&self) -> bool { + crate::domain::session::is_first_party( + self.session_type, + self.scoped, + self.for_client, + self.primary_client, + ) + } } // Writes @@ -77,8 +104,8 @@ pub async fn create<'e>( ) -> Result { sqlx::query_as::<_, Session>( "INSERT INTO sessions - (user_id, session_family_id, expires_at, ip_address, device_name, remember_me, token_hash, user_agent, session_type, client_id, family_created_at, scopes) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, COALESCE($11, NOW()), $12) + (user_id, session_family_id, expires_at, ip_address, device_name, remember_me, token_hash, user_agent, session_type, client_id, family_created_at, scopes, mfa) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, COALESCE($11, NOW()), $12, $13) RETURNING *", ) .bind(input.user_id) @@ -93,6 +120,7 @@ pub async fn create<'e>( .bind(input.client_id) .bind(input.family_created_at) .bind(input.scopes) + .bind(input.mfa) .fetch_one(executor) .await } @@ -119,8 +147,8 @@ pub async fn rotate( let new_session = sqlx::query_as::<_, Session>( "INSERT INTO sessions - (user_id, session_family_id, expires_at, ip_address, device_name, remember_me, token_hash, user_agent, session_type, client_id, family_created_at, scopes) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + (user_id, session_family_id, expires_at, ip_address, device_name, remember_me, token_hash, user_agent, session_type, client_id, family_created_at, scopes, mfa, auth_time) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING *", ) .bind(input.user_id) @@ -135,6 +163,8 @@ pub async fn rotate( .bind(input.client_id) .bind(input.family_created_at.unwrap_or(old_session.family_created_at)) .bind(input.scopes.map(<[String]>::to_vec).or(old_session.scopes)) + .bind(old_session.mfa) + .bind(old_session.auth_time) .fetch_one(&mut *tx) .await?; @@ -154,10 +184,10 @@ pub async fn rotate( Ok(new_session) } -pub async fn revoke(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error> { +pub async fn revoke<'e>(executor: impl PgExecutor<'e>, id: Uuid) -> Result<(), sqlx::Error> { sqlx::query("UPDATE sessions SET revoked_at = NOW() WHERE id = $1 AND revoked_at IS NULL") .bind(id) - .execute(pool) + .execute(executor) .await?; Ok(()) } @@ -175,6 +205,37 @@ pub async fn revoke_all_by_user<'e>( Ok(result.rows_affected()) } +/// Revoke the sessions of a user's personal access tokens and return them. +pub async fn revoke_personal_access_sessions<'e>( + executor: impl PgExecutor<'e>, + user_id: Uuid, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, Session>( + "UPDATE sessions SET revoked_at = NOW() + WHERE user_id = $1 AND session_type = 'personal_access_token' AND revoked_at IS NULL + RETURNING *", + ) + .bind(user_id) + .fetch_all(executor) + .await +} + +/// Revoke every active session of a user and return them: the sessions to +/// forget in caches are exactly those this statement revoked. +pub async fn revoke_all_by_user_returning<'e>( + executor: impl PgExecutor<'e>, + user_id: Uuid, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, Session>( + "UPDATE sessions SET revoked_at = NOW() + WHERE user_id = $1 AND revoked_at IS NULL + RETURNING *", + ) + .bind(user_id) + .fetch_all(executor) + .await +} + /// Revoke every active session of a user except `keep` (the one making the request). pub async fn revoke_others<'e>( executor: impl PgExecutor<'e>, @@ -204,6 +265,17 @@ pub async fn revoke_family(pool: &PgPool, session_id: Uuid) -> Result Result, sqlx::Error> { + sqlx::query_scalar( + "SELECT id FROM sessions + WHERE session_family_id = (SELECT session_family_id FROM sessions WHERE id = $1)", + ) + .bind(session_id) + .fetch_all(pool) + .await +} + pub async fn find_by_token_hash( pool: &PgPool, token_hash: &[u8], @@ -214,6 +286,18 @@ pub async fn find_by_token_hash( .await } +/// Whether the sign-in of the session proved a second factor; `false` for an +/// unknown session. +pub async fn proved_second_factor(pool: &PgPool, id: Uuid) -> Result { + Ok( + sqlx::query_scalar::<_, bool>("SELECT mfa FROM sessions WHERE id = $1") + .bind(id) + .fetch_optional(pool) + .await? + .unwrap_or(false), + ) +} + pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result, sqlx::Error> { sqlx::query_as::<_, Session>("SELECT * FROM sessions WHERE id = $1") .bind(id) @@ -232,13 +316,13 @@ pub async fn find_validation_by_id( } /// Returns non-revoked sessions ordered by most recently used. -pub async fn find_active_by_user( - pool: &PgPool, +pub async fn find_active_by_user<'e>( + executor: impl PgExecutor<'e>, user_id: Uuid, ) -> Result, sqlx::Error> { sqlx::query_as::<_, Session>(FIND_ACTIVE_BY_USER_SQL) .bind(user_id) - .fetch_all(pool) + .fetch_all(executor) .await } @@ -299,3 +383,17 @@ pub async fn revoke_by_client<'e>( .fetch_all(executor) .await } + +/// Record when the password was proved for the consent a session came from. +pub async fn set_auth_time<'e>( + executor: impl PgExecutor<'e>, + id: Uuid, + auth_time: OffsetDateTime, +) -> Result<(), sqlx::Error> { + sqlx::query("UPDATE sessions SET auth_time = $2 WHERE id = $1") + .bind(id) + .bind(auth_time) + .execute(executor) + .await?; + Ok(()) +} diff --git a/src/repositories/token.rs b/src/repositories/token.rs index 8045fa6..070add4 100644 --- a/src/repositories/token.rs +++ b/src/repositories/token.rs @@ -10,7 +10,9 @@ use sqlx::{PgExecutor, PgPool}; use time::OffsetDateTime; use uuid::Uuid; -use crate::domain::token::{EmailVerificationToken, MagicLinkToken, PasswordResetToken}; +use crate::domain::token::{ + EmailVerificationToken, MagicLinkToken, PasswordResetToken, PendingCredentials, +}; // Input types @@ -21,6 +23,8 @@ pub struct NewEmailVerificationToken<'a> { pub request_ip: Option, pub request_user_agent: Option<&'a str>, pub target_email: &'a str, + /// Credentials of a registration on a pending account. + pub credentials: Option<&'a PendingCredentials>, } pub struct NewPasswordResetToken<'a> { @@ -39,8 +43,9 @@ pub async fn create_verification<'e>( ) -> Result { sqlx::query_as::<_, EmailVerificationToken>( "INSERT INTO email_verification_tokens - (user_id, token_hash, expires_at, request_ip, request_user_agent, target_email) - VALUES ($1, $2, $3, $4, $5, $6) + (user_id, token_hash, expires_at, request_ip, request_user_agent, target_email, + password_hash, username, preferred_locale) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *", ) .bind(input.user_id) @@ -49,6 +54,9 @@ pub async fn create_verification<'e>( .bind(input.request_ip) .bind(input.request_user_agent) .bind(input.target_email) + .bind(input.credentials.map(|c| c.password_hash.as_str())) + .bind(input.credentials.map(|c| c.username.as_str())) + .bind(input.credentials.map(|c| c.preferred_locale.as_str())) .fetch_one(executor) .await } @@ -81,7 +89,7 @@ pub async fn consume_verification<'e>( Ok(result.rows_affected() == 1) } -/// Invalidates any active token before issuing a new one. +/// Invalidates every active token of the account. pub async fn revoke_active_verification_by_user<'e>( executor: impl sqlx::PgExecutor<'e>, user_id: Uuid, @@ -97,6 +105,31 @@ pub async fn revoke_active_verification_by_user<'e>( Ok(()) } +/// Invalidates every live sign-in link of the account. +pub async fn revoke_active_magic_links_by_user<'e>( + executor: impl sqlx::PgExecutor<'e>, + user_id: Uuid, +) -> Result<(), sqlx::Error> { + sqlx::query( + "UPDATE magic_link_tokens SET used_at = NOW() WHERE user_id = $1 AND used_at IS NULL", + ) + .bind(user_id) + .execute(executor) + .await?; + Ok(()) +} + +/// Invalidates every live link that would open the account from its mailbox: +/// password resets and sign-in links. Run when the mailbox or the password +/// changes hands. +pub async fn revoke_mailbox_links( + tx: &mut sqlx::PgConnection, + user_id: Uuid, +) -> Result<(), sqlx::Error> { + revoke_active_password_reset_by_user(&mut *tx, user_id).await?; + revoke_active_magic_links_by_user(&mut *tx, user_id).await +} + // Password reset pub async fn create_password_reset( @@ -146,7 +179,7 @@ pub async fn consume_password_reset<'e>( Ok(result.rows_affected() == 1) } -/// Invalidates any active token before issuing a new one. +/// Invalidates every active token of the account. pub async fn revoke_active_password_reset_by_user<'e>( executor: impl PgExecutor<'e>, user_id: Uuid, @@ -164,8 +197,10 @@ pub async fn revoke_active_password_reset_by_user<'e>( // Magic links -/// Replace the pending links of the account with a new one. -pub async fn replace_magic_link( +/// Add a sign-in link. Earlier links stay usable until one of them is used: +/// a request from someone else must not revoke the link the owner is about to +/// click. The per-account budget bounds how many are alive. +pub async fn add_magic_link( pool: &PgPool, user_id: Uuid, token_hash: &[u8], @@ -173,13 +208,6 @@ pub async fn replace_magic_link( request_ip: Option, request_user_agent: Option<&str>, ) -> Result<(), sqlx::Error> { - let mut tx = pool.begin().await?; - sqlx::query( - "UPDATE magic_link_tokens SET used_at = NOW() WHERE user_id = $1 AND used_at IS NULL", - ) - .bind(user_id) - .execute(&mut *tx) - .await?; sqlx::query( "INSERT INTO magic_link_tokens (user_id, token_hash, expires_at, request_ip, request_user_agent) @@ -190,9 +218,9 @@ pub async fn replace_magic_link( .bind(expires_at) .bind(request_ip) .bind(request_user_agent) - .execute(&mut *tx) + .execute(pool) .await?; - tx.commit().await + Ok(()) } pub async fn find_magic_link_by_hash( @@ -205,13 +233,27 @@ pub async fn find_magic_link_by_hash( .await } -/// Marks the link as used. Returns false if it was already consumed. +/// Marks the link as used, and the other pending links of its account with +/// it. Returns false if it was already consumed. pub async fn consume_magic_link(pool: &PgPool, id: Uuid) -> Result { - let result = sqlx::query( - "UPDATE magic_link_tokens SET used_at = NOW() WHERE id = $1 AND used_at IS NULL", + let mut tx = pool.begin().await?; + let user_id: Option = sqlx::query_scalar( + "UPDATE magic_link_tokens SET used_at = NOW() + WHERE id = $1 AND used_at IS NULL + RETURNING user_id", ) .bind(id) - .execute(pool) + .fetch_optional(&mut *tx) .await?; - Ok(result.rows_affected() == 1) + let Some(user_id) = user_id else { + return Ok(false); + }; + sqlx::query( + "UPDATE magic_link_tokens SET used_at = NOW() WHERE user_id = $1 AND used_at IS NULL", + ) + .bind(user_id) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(true) } diff --git a/src/repositories/two_factor.rs b/src/repositories/two_factor.rs index 0eac4f5..ac1cdc7 100644 --- a/src/repositories/two_factor.rs +++ b/src/repositories/two_factor.rs @@ -100,13 +100,11 @@ pub struct RemovedMethod { /// /// Returns `None` when the user has no method with this id and type. pub async fn remove_method( - pool: &PgPool, + tx: &mut sqlx::PgConnection, id: Uuid, user_id: Uuid, method_type: TwoFactorType, ) -> Result, sqlx::Error> { - let mut tx = pool.begin().await?; - let removed: Option<(bool,)> = sqlx::query_as( "DELETE FROM two_factor_methods WHERE id = $1 AND user_id = $2 AND method_type = $3 @@ -151,7 +149,6 @@ pub async fn remove_method( .await?; } - tx.commit().await?; Ok(Some(RemovedMethod { was_primary, remaining_verified, @@ -200,9 +197,13 @@ pub async fn find_primary_by_user( /// Returns (id, totp_secret) for every verified TOTP method that has a secret. /// Used exclusively during encryption key rotation. -pub async fn find_all_totp_secrets(pool: &PgPool) -> Result, sqlx::Error> { - let rows: Vec<(Uuid, String)> = sqlx::query_as( - "SELECT id, totp_secret +/// A stored TOTP secret: method id, account id, ciphertext. +pub type StoredTotpSecret = (Uuid, Uuid, String); + +/// Every stored TOTP secret. +pub async fn find_all_totp_secrets(pool: &PgPool) -> Result, sqlx::Error> { + let rows: Vec = sqlx::query_as( + "SELECT id, user_id, totp_secret FROM two_factor_methods WHERE method_type = 'totp' AND totp_secret IS NOT NULL", ) @@ -261,6 +262,11 @@ pub async fn find_all_by_type( // TOTP replay guard (used_totp_codes) +/// How long a consumed TOTP code stays refused: every step it can be accepted +/// in (the current one and `MAX_TOTP_SKEW` on each side) plus one step of +/// margin, so no clock drift reopens it. +pub const TOTP_REPLAY_WINDOW_SECS: i64 = (2 * crate::config::MAX_TOTP_SKEW as i64 + 2) * 30; + /// Atomically consume a TOTP code for a user: purges the user's expired /// entries, then records the code hash. Returns `false` when the exact code /// was already consumed within the validity window (replay). @@ -272,26 +278,27 @@ pub async fn try_consume_totp_code( pool: &PgPool, user_id: Uuid, code_hash: &[u8], + now: time::OffsetDateTime, ) -> Result { let mut tx = pool.begin().await?; // Self-cleaning: TOTP codes repeat naturally over time, so stale rows must - // never block a future legitimate login even if pg_cron/background cleanup - // lags. 90 s = one 30-second step on each side of the current one - // (TOTP_SKEW=1), matching cleanup_used_totp_codes(). - sqlx::query( - "DELETE FROM used_totp_codes WHERE user_id = $1 AND used_at < NOW() - INTERVAL '90 seconds'", - ) - .bind(user_id) - .execute(&mut *tx) - .await?; + // never block a future legitimate login even if background cleanup lags. + // Judged on the application's clock, like the code's validity: a database + // clock running ahead must not reopen a replay window. + sqlx::query("DELETE FROM used_totp_codes WHERE user_id = $1 AND used_at < $2") + .bind(user_id) + .bind(now - time::Duration::seconds(TOTP_REPLAY_WINDOW_SECS)) + .execute(&mut *tx) + .await?; let inserted = sqlx::query( - "INSERT INTO used_totp_codes (user_id, code_hash) VALUES ($1, $2) + "INSERT INTO used_totp_codes (user_id, code_hash, used_at) VALUES ($1, $2, $3) ON CONFLICT (user_id, code_hash) DO NOTHING", ) .bind(user_id) .bind(code_hash) + .bind(now) .execute(&mut *tx) .await? .rows_affected(); diff --git a/src/repositories/user.rs b/src/repositories/user.rs index e411761..154bed1 100644 --- a/src/repositories/user.rs +++ b/src/repositories/user.rs @@ -7,7 +7,8 @@ use uuid::Uuid; use crate::domain::user::{User, UserStatus}; pub const FIND_BY_EMAIL_SQL: &str = "SELECT * FROM users WHERE email = $1::citext"; -pub const FIND_BY_USERNAME_SQL: &str = "SELECT * FROM users WHERE username = $1::citext"; +/// Case-insensitive, like the uniqueness of usernames (`users_username_lower_key`). +pub const FIND_BY_USERNAME_SQL: &str = "SELECT * FROM users WHERE lower(username) = lower($1)"; // Input types @@ -37,6 +38,24 @@ pub async fn create<'e>( .await } +/// Replace the hash only while it is still `current`: a rehash racing a +/// password change never brings the old password back. +pub async fn replace_password_hash( + pool: &PgPool, + id: Uuid, + current: &str, + replacement: &str, +) -> Result { + let result = + sqlx::query("UPDATE users SET password_hash = $3 WHERE id = $1 AND password_hash = $2") + .bind(id) + .bind(current) + .bind(replacement) + .execute(pool) + .await?; + Ok(result.rows_affected() == 1) +} + pub async fn update_password_hash<'e>( executor: impl PgExecutor<'e>, id: Uuid, @@ -50,11 +69,15 @@ pub async fn update_password_hash<'e>( Ok(()) } -pub async fn update_username(pool: &PgPool, id: Uuid, username: &str) -> Result<(), sqlx::Error> { +pub async fn update_username<'e>( + executor: impl PgExecutor<'e>, + id: Uuid, + username: &str, +) -> Result<(), sqlx::Error> { sqlx::query("UPDATE users SET username = $2 WHERE id = $1") .bind(id) .bind(username) - .execute(pool) + .execute(executor) .await?; Ok(()) } @@ -68,25 +91,32 @@ pub async fn update_locale(pool: &PgPool, id: Uuid, locale: &str) -> Result<(), Ok(()) } +/// Lock the account's password until `locked_until`, unless a lock already +/// holds. Returns whether this call locked it: of concurrent failures reaching +/// the threshold, one locks, audits and tells the owner. pub async fn set_locked_until( pool: &PgPool, id: Uuid, locked_until: OffsetDateTime, -) -> Result<(), sqlx::Error> { - sqlx::query("UPDATE users SET locked_until = $2 WHERE id = $1") - .bind(id) - .bind(locked_until) - .execute(pool) - .await?; - Ok(()) +) -> Result { + let result = sqlx::query( + "UPDATE users SET locked_until = $2 + WHERE id = $1 AND (locked_until IS NULL OR locked_until <= NOW())", + ) + .bind(id) + .bind(locked_until) + .execute(pool) + .await?; + Ok(result.rows_affected() == 1) } -/// Stamp a completed sign-in: last login time, and the end of any expired lockout. +/// Stamp a completed sign-in, by any method: last login time, the end of any +/// lockout, and a fresh start for the count of wrong passwords. pub async fn record_sign_in<'e>( executor: impl sqlx::PgExecutor<'e>, id: Uuid, ) -> Result<(), sqlx::Error> { - sqlx::query("UPDATE users SET last_login_at = NOW(), locked_until = NULL WHERE id = $1") + sqlx::query("UPDATE users SET last_login_at = NOW(), locked_until = NULL, lockout_cleared_at = NOW() WHERE id = $1") .bind(id) .execute(executor) .await?; @@ -145,6 +175,166 @@ pub async fn mark_email_verified<'e>( /// Verify the address of a pending account and activate it; any other account /// is left untouched. Returns whether the account was pending. +/// Give a pending account the credentials its verification link carries. The +/// username changes only while no other account holds it; the password and the +/// locale always follow the link. Does nothing to an account already verified. +pub async fn adopt_pending_credentials<'e>( + executor: impl PgExecutor<'e>, + id: Uuid, + credentials: &crate::domain::token::PendingCredentials, +) -> Result<(), sqlx::Error> { + sqlx::query( + "UPDATE users u + SET password_hash = $2, + preferred_locale = $4, + username = CASE + WHEN EXISTS (SELECT 1 FROM users o WHERE lower(o.username) = lower($3) AND o.id <> u.id) + THEN u.username + ELSE $3 + END + WHERE u.id = $1 AND u.status = 'pending_verification'", + ) + .bind(id) + .bind(&credentials.password_hash) + .bind(&credentials.username) + .bind(&credentials.preferred_locale) + .execute(executor) + .await?; + Ok(()) +} + +/// Whether the account can prove a second factor: a verified TOTP or email +/// method, or a passkey. +pub async fn has_second_factor<'e>( + executor: impl PgExecutor<'e>, + id: Uuid, +) -> Result { + sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM two_factor_methods WHERE user_id = $1 AND is_verified) + OR EXISTS (SELECT 1 FROM passkeys WHERE user_id = $1)", + ) + .bind(id) + .fetch_one(executor) + .await +} + +/// Delete every way into the account other than its password: second factors, +/// recovery codes, passkeys, external identities and personal access tokens. +/// Run on a pending account taken back by its owner. +pub async fn drop_access_factors<'e>( + executor: impl PgExecutor<'e>, + id: Uuid, +) -> Result<(), sqlx::Error> { + sqlx::query( + "WITH methods AS (DELETE FROM two_factor_methods WHERE user_id = $1), + codes AS (DELETE FROM recovery_codes WHERE user_id = $1), + keys AS (DELETE FROM passkeys WHERE user_id = $1), + identities AS (DELETE FROM external_identities WHERE user_id = $1) + DELETE FROM personal_access_tokens WHERE user_id = $1", + ) + .bind(id) + .execute(executor) + .await?; + Ok(()) +} + +/// Delete the second factors, passkeys and external identities added since +/// `since`, and return what went as `(kind, name)`: `totp`, `email`, +/// `passkey` with its name, `identity` with its provider. The remaining +/// verified method becomes primary if the primary went, and recovery codes go +/// when no verified method remains. +/// +/// With `keep_a_second_factor`, an account left with no second factor older +/// than `since` keeps the oldest of its recent ones (a verified method or a +/// passkey): an administrator promoted in the window does not lose, to +/// whoever reads their mailbox, the factor the administration requires. +pub async fn drop_access_factors_since( + tx: &mut sqlx::PgConnection, + id: Uuid, + since: OffsetDateTime, + keep_a_second_factor: bool, +) -> Result, sqlx::Error> { + let kept: Option<(String, Uuid)> = if keep_a_second_factor { + sqlx::query_as( + "SELECT kind, factor_id FROM ( + SELECT 'method' AS kind, id AS factor_id, created_at FROM two_factor_methods + WHERE user_id = $1 AND is_verified AND created_at > $2 + UNION ALL + SELECT 'passkey', id, created_at FROM passkeys + WHERE user_id = $1 AND created_at > $2 + ) recent + WHERE NOT EXISTS (SELECT 1 FROM two_factor_methods + WHERE user_id = $1 AND is_verified AND created_at <= $2) + AND NOT EXISTS (SELECT 1 FROM passkeys WHERE user_id = $1 AND created_at <= $2) + ORDER BY created_at + LIMIT 1", + ) + .bind(id) + .bind(since) + .fetch_optional(&mut *tx) + .await? + } else { + None + }; + let kept_method = kept + .as_ref() + .filter(|(k, _)| k == "method") + .map(|(_, f)| *f); + let kept_passkey = kept + .as_ref() + .filter(|(k, _)| k == "passkey") + .map(|(_, f)| *f); + let mut removed: Vec<(String, String)> = sqlx::query_as( + "DELETE FROM two_factor_methods + WHERE user_id = $1 AND created_at > $2 AND id IS DISTINCT FROM $3 + RETURNING method_type::text, ''", + ) + .bind(id) + .bind(since) + .bind(kept_method) + .fetch_all(&mut *tx) + .await?; + removed.extend( + sqlx::query_as::<_, (String, String)>( + "DELETE FROM passkeys + WHERE user_id = $1 AND created_at > $2 AND id IS DISTINCT FROM $3 + RETURNING 'passkey', name::text", + ) + .bind(id) + .bind(since) + .bind(kept_passkey) + .fetch_all(&mut *tx) + .await?, + ); + removed.extend( + sqlx::query_as::<_, (String, String)>( + "DELETE FROM external_identities WHERE user_id = $1 AND created_at > $2 + RETURNING 'identity', provider::text", + ) + .bind(id) + .bind(since) + .fetch_all(&mut *tx) + .await?, + ); + sqlx::query( + "UPDATE two_factor_methods SET is_primary = TRUE + WHERE id = (SELECT id FROM two_factor_methods + WHERE user_id = $1 AND is_verified ORDER BY created_at LIMIT 1) + AND NOT EXISTS (SELECT 1 FROM two_factor_methods WHERE user_id = $1 AND is_primary)", + ) + .bind(id) + .execute(&mut *tx) + .await?; + sqlx::query( + "DELETE FROM recovery_codes WHERE user_id = $1 + AND NOT EXISTS (SELECT 1 FROM two_factor_methods WHERE user_id = $1 AND is_verified)", + ) + .bind(id) + .execute(&mut *tx) + .await?; + Ok(removed) +} + pub async fn verify_if_pending<'e>( executor: impl PgExecutor<'e>, id: Uuid, @@ -162,10 +352,13 @@ pub async fn verify_if_pending<'e>( // Reads -pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result, sqlx::Error> { +pub async fn find_by_id<'e>( + executor: impl PgExecutor<'e>, + id: Uuid, +) -> Result, sqlx::Error> { sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1") .bind(id) - .fetch_optional(pool) + .fetch_optional(executor) .await } @@ -187,9 +380,12 @@ pub async fn find_by_identifier( } } -/// Forget what the account leaves outside its own rows: client addresses in its -/// audit entries and its sign-in attempts. Call it in the deletion's transaction. -pub async fn forget_traces<'e>(executor: impl PgExecutor<'e>, id: Uuid) -> Result<(), sqlx::Error> { +/// Delete the account and forget what it leaves outside its own rows: client +/// addresses in its audit entries and its sign-in attempts. One function, with +/// the owner's privileges, so it can never rewrite the traces of an account +/// that stays. Call it in the deletion's transaction, after the entries that +/// announce it. +pub async fn erase<'e>(executor: impl PgExecutor<'e>, id: Uuid) -> Result<(), sqlx::Error> { sqlx::query("SELECT forget_account_traces($1)") .bind(id) .execute(executor) @@ -207,6 +403,66 @@ pub async fn delete<'e>(executor: impl PgExecutor<'e>, id: Uuid) -> Result<(), s Ok(()) } +/// Whether an account holds the username, or a registration reserved it +/// (see `reserve_username`), whatever the case. +pub async fn username_unavailable(pool: &PgPool, username: &str) -> Result { + sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM users WHERE lower(username) = lower($1)) + OR EXISTS (SELECT 1 FROM username_reservations + WHERE lower(username) = lower($1) AND expires_at > NOW())", + ) + .bind(username) + .fetch_one(pool) + .await +} + +/// Reserve the username for a registration on `account`'s address until +/// `expires_at`, replacing the account's previous reservation. +pub async fn reserve_username( + pool: &PgPool, + username: &str, + account: Uuid, + expires_at: time::OffsetDateTime, +) -> Result<(), sqlx::Error> { + let mut tx = pool.begin().await?; + sqlx::query("DELETE FROM username_reservations WHERE reserved_for = $1") + .bind(account) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO username_reservations (username, expires_at, reserved_for) VALUES ($1, $2, $3) + ON CONFLICT (lower(username)) DO NOTHING", + ) + .bind(username) + .bind(expires_at) + .bind(account) + .execute(&mut *tx) + .await?; + tx.commit().await +} + +/// When a reset activates a pending account, the username and locale of the +/// latest registration that sent the account a link, if its username is free. +pub async fn adopt_latest_registration_identity( + tx: &mut sqlx::PgConnection, + id: Uuid, +) -> Result<(), sqlx::Error> { + sqlx::query( + "UPDATE users u + SET username = latest.username, preferred_locale = latest.preferred_locale + FROM (SELECT username, preferred_locale FROM email_verification_tokens + WHERE user_id = $1 AND username IS NOT NULL AND used_at IS NULL + ORDER BY created_at DESC LIMIT 1) latest + WHERE u.id = $1 AND u.status = 'pending_verification' + AND NOT EXISTS (SELECT 1 FROM users o + WHERE lower(o.username) = lower(latest.username) AND o.id <> $1)", + ) + .bind(id) + .execute(&mut *tx) + .await?; + Ok(()) +} + pub async fn find_by_username(pool: &PgPool, username: &str) -> Result, sqlx::Error> { sqlx::query_as::<_, User>(FIND_BY_USERNAME_SQL) .bind(username) @@ -271,10 +527,23 @@ pub async fn reactivate<'e>(executor: impl PgExecutor<'e>, id: Uuid) -> Result Result<(), sqlx::Error> { +pub async fn clear_lockout<'e>( + executor: impl sqlx::PgExecutor<'e>, + id: Uuid, +) -> Result<(), sqlx::Error> { sqlx::query("UPDATE users SET locked_until = NULL, lockout_cleared_at = NOW() WHERE id = $1") .bind(id) - .execute(pool) + .execute(executor) + .await?; + Ok(()) +} + +/// Lock the account's row until the transaction ends: checks made after it +/// (its second factors, its roles) cannot change underneath. +pub async fn lock_row<'e>(executor: impl PgExecutor<'e>, id: Uuid) -> Result<(), sqlx::Error> { + sqlx::query("SELECT 1 FROM users WHERE id = $1 FOR UPDATE") + .bind(id) + .execute(executor) .await?; Ok(()) } diff --git a/src/repositories/webhook.rs b/src/repositories/webhook.rs index df792c0..b08dad8 100644 --- a/src/repositories/webhook.rs +++ b/src/repositories/webhook.rs @@ -52,33 +52,37 @@ pub struct ClaimedDelivery { pub payload: Value, pub occurred_at: OffsetDateTime, pub attempts: i32, + pub endpoint_id: Uuid, pub url: String, pub secret: String, } // Endpoints -pub async fn create_endpoint( - pool: &PgPool, +/// `id` is chosen by the caller: the secret is encrypted bound to it. +pub async fn create_endpoint<'e>( + executor: impl sqlx::PgExecutor<'e>, + id: Uuid, settings: &EndpointSettings<'_>, encrypted_secret: &str, ) -> Result { sqlx::query_as::<_, WebhookEndpoint>( - "INSERT INTO webhook_endpoints (url, description, events, enabled, secret) - VALUES ($1, $2, $3, $4, $5) + "INSERT INTO webhook_endpoints (id, url, description, events, enabled, secret) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING *", ) + .bind(id) .bind(settings.url) .bind(settings.description) .bind(settings.events) .bind(settings.enabled) .bind(encrypted_secret) - .fetch_one(pool) + .fetch_one(executor) .await } -pub async fn update_endpoint( - pool: &PgPool, +pub async fn update_endpoint<'e>( + executor: impl sqlx::PgExecutor<'e>, id: Uuid, settings: &EndpointSettings<'_>, ) -> Result, sqlx::Error> { @@ -93,27 +97,30 @@ pub async fn update_endpoint( .bind(settings.description) .bind(settings.events) .bind(settings.enabled) - .fetch_optional(pool) + .fetch_optional(executor) .await } -pub async fn replace_secret( - pool: &PgPool, +pub async fn replace_secret<'e>( + executor: impl sqlx::PgExecutor<'e>, id: Uuid, encrypted_secret: &str, ) -> Result { let result = sqlx::query("UPDATE webhook_endpoints SET secret = $2 WHERE id = $1") .bind(id) .bind(encrypted_secret) - .execute(pool) + .execute(executor) .await?; Ok(result.rows_affected() == 1) } -pub async fn delete_endpoint(pool: &PgPool, id: Uuid) -> Result { +pub async fn delete_endpoint<'e>( + executor: impl sqlx::PgExecutor<'e>, + id: Uuid, +) -> Result { let result = sqlx::query("DELETE FROM webhook_endpoints WHERE id = $1") .bind(id) - .execute(pool) + .execute(executor) .await?; Ok(result.rows_affected() == 1) } @@ -203,7 +210,7 @@ pub async fn claim_due( FROM due WHERE d.id = due.id RETURNING d.id, d.endpoint_id, d.event_id, d.event_name, d.payload, d.occurred_at, d.attempts ) - SELECT c.id, c.event_id, c.event_name, c.payload, c.occurred_at, c.attempts, e.url, e.secret + SELECT c.id, c.event_id, c.event_name, c.payload, c.occurred_at, c.attempts, e.id AS endpoint_id, e.url, e.secret FROM claimed c JOIN webhook_endpoints e ON e.id = c.endpoint_id", ) .bind(limit) @@ -271,7 +278,11 @@ pub async fn find_recent_deliveries( } /// Queue a delivery again now, with a fresh attempt budget. -pub async fn redeliver(pool: &PgPool, endpoint_id: Uuid, id: Uuid) -> Result { +pub async fn redeliver<'e>( + executor: impl sqlx::PgExecutor<'e>, + endpoint_id: Uuid, + id: Uuid, +) -> Result { let result = sqlx::query( "UPDATE webhook_deliveries SET attempts = 0, failed_at = NULL, delivered_at = NULL, next_attempt_at = NOW() @@ -279,7 +290,7 @@ pub async fn redeliver(pool: &PgPool, endpoint_id: Uuid, id: Uuid) -> Result, allows_client_credentials: Option, + allows_introspection: Option, ) -> Result<(RegisteredClient, bool), AppError> { client_domain::check_settings( client.client_id, @@ -59,6 +60,14 @@ pub async fn save( let mut tx = state.db.begin().await?; let existed = client_repo::lock_existing(&mut *tx, client.client_id).await?; + let previous = client_repo::find_by_id(&mut *tx, client.client_id).await?; + // The primary client's sessions are first-party and skip consent: an + // administrator holding `clients:manage` could otherwise point it, or make + // their own client primary, at redirect URIs of their choosing. It is + // designated and changed from the command line, on the server. + if client.is_primary || previous.as_ref().is_some_and(|p| p.is_primary) { + return Err(AppError::Conflict("primary_client_managed_by_command_line")); + } let mut saved = client_repo::upsert(&mut *tx, client).await.map_err(|e| { let scoped = matches!(&e, sqlx::Error::Database(db) if db.constraint() == Some("registered_clients_client_credentials_scoped")); @@ -81,6 +90,10 @@ pub async fn save( client_repo::set_client_credentials(&mut *tx, &saved.client_id, allowed).await?; saved.allows_client_credentials = allowed; } + if let Some(allowed) = allows_introspection { + client_repo::set_introspection(&mut *tx, &saved.client_id, allowed).await?; + saved.allows_introspection = allowed; + } audit::append( &mut *tx, &entry( @@ -90,7 +103,7 @@ pub async fn save( } else { AuditAction::ClientRegistered }, - json!({ "client_id": saved.client_id }), + client_domain::audit_changes(previous.as_ref(), &saved), ), ) .await?; @@ -98,9 +111,22 @@ pub async fn save( Ok((saved, !existed)) } -/// Remove the client and end every session it holds. +/// Remove the client and end every session it holds, after a recent +/// re-authentication: removing the instance's own application signs every one +/// of its users out. pub async fn delete(state: &AppState, actor: &Actor, client_id: &str) -> Result<(), AppError> { + reauth_svc::require_recent_reauth_or_password( + state, + actor.user_id, + actor.session_id, + None, + actor.ip, + actor.request_id, + "admin_delete_client", + ) + .await?; let mut tx = state.db.begin().await?; + refuse_primary(&mut tx, client_id).await?; let revoked = session_repo::revoke_by_client(&mut *tx, client_id).await?; if !client_repo::delete(&mut *tx, client_id).await? { return Err(AppError::NotFound); @@ -131,14 +157,26 @@ pub async fn rotate_secret( actor: &Actor, client_id: &str, ) -> Result { + reauth_svc::require_recent_reauth_or_password( + state, + actor.user_id, + actor.session_id, + None, + actor.ip, + actor.request_id, + "admin_client_secret", + ) + .await?; let secret = crate::domain::oauth::format_client_secret(&crate::utils::crypto::generate_token()); let digest = crate::utils::crypto::sha256(secret.as_bytes()); - if !client_repo::set_secret_hash(&state.db, client_id, Some(&digest)).await? { + let mut tx = state.db.begin().await?; + refuse_primary(&mut tx, client_id).await?; + if !client_repo::set_secret_hash(&mut *tx, client_id, Some(&digest)).await? { return Err(AppError::NotFound); } audit::append( - &state.db, + &mut *tx, &entry( actor, AuditAction::ClientSecretRotated, @@ -146,20 +184,35 @@ pub async fn rotate_secret( ), ) .await?; + tx.commit().await?; Ok(secret) } -/// Remove the client's secret, making it a public client. +/// Remove the client's secret, making it a public client: anyone knowing its +/// id can then run its flows. Needs a recent re-authentication, like giving it +/// a secret. pub async fn remove_secret( state: &AppState, actor: &Actor, client_id: &str, ) -> Result<(), AppError> { - if !client_repo::set_secret_hash(&state.db, client_id, None).await? { + reauth_svc::require_recent_reauth_or_password( + state, + actor.user_id, + actor.session_id, + None, + actor.ip, + actor.request_id, + "admin_client_secret", + ) + .await?; + let mut tx = state.db.begin().await?; + refuse_primary(&mut tx, client_id).await?; + if !client_repo::set_secret_hash(&mut *tx, client_id, None).await? { return Err(AppError::NotFound); } audit::append( - &state.db, + &mut *tx, &entry( actor, AuditAction::ClientSecretRotated, @@ -167,6 +220,7 @@ pub async fn remove_secret( ), ) .await?; + tx.commit().await?; Ok(()) } @@ -179,3 +233,17 @@ fn entry(actor: &Actor, action: AuditAction, extra: serde_json::Value) -> NewAud metadata: actor.metadata(extra), } } + +/// The primary client's sessions are first-party: removing it, or changing how +/// it authenticates, is the command line's, like every change to it. Locks +/// the client's row; a missing client passes, for the caller's 404. +async fn refuse_primary(tx: &mut sqlx::PgConnection, client_id: &str) -> Result<(), AppError> { + client_repo::lock_existing(&mut *tx, client_id).await?; + if client_repo::find_by_id(&mut *tx, client_id) + .await? + .is_some_and(|client| client.is_primary) + { + return Err(AppError::Conflict("primary_client_managed_by_command_line")); + } + Ok(()) +} diff --git a/src/services/admin/mod.rs b/src/services/admin/mod.rs index ddbb39e..57179d7 100644 --- a/src/services/admin/mod.rs +++ b/src/services/admin/mod.rs @@ -13,6 +13,8 @@ use ipnetwork::IpNetwork; use serde_json::{Value, json}; use uuid::Uuid; +use crate::{repositories::user as user_repo, services::email, state::AppState}; + /// The administrator making a request. #[derive(Debug, Clone, Copy)] pub struct Actor { @@ -33,6 +35,60 @@ impl Actor { } } +/// Record, in the administrator's own history, that they read accounts or the +/// audit log: personal data read with a stolen administrator token leaves a +/// trace, like the owner's own export does. The read fails if it cannot be +/// recorded. +pub(crate) async fn record_read( + state: &AppState, + actor: &Actor, + extra: Value, +) -> Result<(), crate::error::AppError> { + crate::repositories::audit::append( + &state.db, + &crate::repositories::audit::NewAuditEntry { + user_id: Some(actor.user_id), + request_id: actor.request_id, + action: crate::domain::audit::AuditAction::AdminDataRead, + ip_address: actor.ip, + metadata: actor.metadata(extra), + }, + ) + .await?; + Ok(()) +} + +/// Tell the owner an administrator changed their account: a compromised +/// administrator acting on it must not go unnoticed by the person it affects. +/// Best effort, after the change committed. +pub(crate) async fn notify_owner( + state: &AppState, + user_id: Uuid, + change: &'static str, + role: Option<&str>, +) { + let Ok(Some(user)) = user_repo::find_by_id(&state.db, user_id).await else { + return; + }; + let mailer = state.mailer.clone(); + let templates = state.templates.clone(); + let mail_cfg = state.config.mail.clone(); + let role = role.map(str::to_owned); + email::dispatch_best_effort("changed_by_administrator_email", async move { + email::send_changed_by_administrator( + &mailer, + templates.as_ref(), + &mail_cfg, + &user.email, + &user.username, + &user.preferred_locale, + change, + role.as_deref(), + ) + .await + }); +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/services/admin/roles.rs b/src/services/admin/roles.rs index d9e6353..9a2c527 100644 --- a/src/services/admin/roles.rs +++ b/src/services/admin/roles.rs @@ -47,6 +47,7 @@ pub async fn create( require_reauth(state, actor, "admin_create_role").await?; let mut tx = state.db.begin().await?; + ensure_actor_holds(&mut tx, actor, &permissions).await?; let role = role_repo::create(&mut *tx, name, description) .await .map_err(|e| AppError::from_unique_violation(e, &[("roles_name_key", "role_exists")]))?; @@ -73,9 +74,59 @@ pub async fn set_permissions( ) -> Result<(Role, Vec), AppError> { let role = find(state, name).await?; let permissions = known_permissions(state, permissions).await?; + // Every account holds the default role: administration in it would make + // every new account an administrator. + if role.is_default + && permissions + .iter() + .any(|p| role_domain::is_admin_permission(p)) + { + return Err(AppError::Conflict("default_role_administration")); + } require_reauth(state, actor, "admin_change_role").await?; let mut tx = state.db.begin().await?; + // Nobody delegates what they do not hold, nor withdraws it: a role manager + // adding to any role a permission they lack could grant it to an + // accomplice, who would grant it back, and one removing it could strip the + // administrators above them. Checked on the grants read under the role's + // lock. + let current = role_repo::lock_permissions(&mut tx, role.id).await?; + let changed: Vec = permissions + .iter() + .filter(|p| !current.contains(p)) + .chain(current.iter().filter(|p| !permissions.contains(p))) + .cloned() + .collect(); + ensure_actor_holds(&mut tx, actor, &changed).await?; + let added: Vec = permissions + .iter() + .filter(|p| !current.contains(p)) + .cloned() + .collect(); + let removed: Vec = current + .iter() + .filter(|p| !permissions.contains(p)) + .cloned() + .collect(); + let holders = role_repo::holders(&mut *tx, role.id).await?; + // A role gaining administration makes administrators of its holders: each + // must meet what granting such a role asks (an active account with a + // second factor), read under their locks. + let grants_administration = added.iter().any(|p| role_domain::is_admin_permission(p)); + if grants_administration { + for holder in &holders { + user_repo::lock_row(&mut *tx, *holder).await?; + let user = user_repo::find_by_id(&mut *tx, *holder) + .await? + .ok_or(AppError::NotFound)?; + if user.status != crate::domain::user::UserStatus::Active + || !user_repo::has_second_factor(&mut *tx, user.id).await? + { + return Err(AppError::Conflict("holders_without_second_factor")); + } + } + } role_repo::set_permissions(&mut tx, role.id, &permissions).await?; keep_an_administrator(&mut tx).await?; audit::append( @@ -87,7 +138,33 @@ pub async fn set_permissions( ), ) .await?; + // Each holder's history records what their role now grants: changing a + // role someone holds is granting or withdrawing it to them. + if !added.is_empty() || !removed.is_empty() { + for holder in &holders { + audit::append( + &mut *tx, + &NewAuditEntry { + user_id: Some(*holder), + request_id: actor.request_id, + action: AuditAction::RolePermissionsChanged, + ip_address: actor.ip, + metadata: actor.metadata(json!({ + "role": role.name, + "added": added, + "removed": removed, + })), + }, + ) + .await?; + } + } tx.commit().await?; + if grants_administration { + for holder in holders { + super::notify_owner(state, holder, "role_extended", Some(&role.name)).await; + } + } Ok((role, permissions)) } @@ -96,8 +173,14 @@ pub async fn delete(state: &AppState, actor: &Actor, name: &str) -> Result<(), A if role.is_default { return Err(AppError::Conflict("default_role")); } + require_reauth(state, actor, "admin_delete_role").await?; let mut tx = state.db.begin().await?; + // Deleting a role withdraws its permissions from every holder. + let granted = role_repo::lock_permissions(&mut tx, role.id).await?; + ensure_actor_holds(&mut tx, actor, &granted).await?; + // Each holder's history records the role going, like a withdrawal. + let holders = role_repo::holders(&mut *tx, role.id).await?; role_repo::delete(&mut *tx, role.id).await?; keep_an_administrator(&mut tx).await?; audit::append( @@ -105,11 +188,21 @@ pub async fn delete(state: &AppState, actor: &Actor, name: &str) -> Result<(), A &own_entry( actor, AuditAction::RoleDeleted, - json!({ "role": role.name }), + json!({ "role": role.name, "holders": holders.len() }), ), ) .await?; + for holder in &holders { + audit::append( + &mut *tx, + &target_entry(actor, *holder, AuditAction::RoleRevoked, &role.name), + ) + .await?; + } tx.commit().await?; + for holder in holders { + super::notify_owner(state, holder, "role_revoked", Some(&role.name)).await; + } Ok(()) } @@ -120,13 +213,27 @@ pub async fn assign( user_id: Uuid, name: &str, ) -> Result<(), AppError> { + refuse_own_account(actor, user_id)?; let role = find(state, name).await?; + // A missing account answers 404 before the re-authentication is asked. user_repo::find_by_id(&state.db, user_id) .await? .ok_or(AppError::NotFound)?; require_reauth(state, actor, "admin_assign_role").await?; let mut tx = state.db.begin().await?; + // Checked under the account's lock: its last second factor cannot go + // between the check and the grant. + user_repo::lock_row(&mut *tx, user_id).await?; + // Read again under the lock: the status may have changed since. + let user = user_repo::find_by_id(&mut *tx, user_id) + .await? + .ok_or(AppError::NotFound)?; + // Granting a role delegates every permission it holds: a role manager + // could otherwise give `admin` to an account they control. + let granted = role_repo::lock_permissions(&mut tx, role.id).await?; + ensure_actor_holds(&mut tx, actor, &granted).await?; + ensure_can_administer(&mut tx, &user, &role).await?; match role_repo::assign_to_user(&mut *tx, user_id, role.id, Some(actor.user_id)).await { Ok(_) => {} // ON CONFLICT DO NOTHING returns no row: the role was already held. @@ -139,6 +246,7 @@ pub async fn assign( ) .await?; tx.commit().await?; + super::notify_owner(state, user_id, "role_granted", Some(&role.name)).await; Ok(()) } @@ -150,8 +258,15 @@ pub async fn unassign( name: &str, ) -> Result<(), AppError> { let role = find(state, name).await?; + // Withdrawing roles is how a stolen administrator token would push the + // other administrators out. + require_reauth(state, actor, "admin_revoke_role").await?; let mut tx = state.db.begin().await?; + // Withdrawing a role withdraws its permissions: only someone holding them + // all may, so a lesser administrator cannot strip a greater one. + let granted = role_repo::lock_permissions(&mut tx, role.id).await?; + ensure_actor_holds(&mut tx, actor, &granted).await?; if !role_repo::unassign(&mut *tx, user_id, role.id).await? { return Ok(()); } @@ -162,6 +277,55 @@ pub async fn unassign( ) .await?; tx.commit().await?; + if actor.user_id != user_id { + super::notify_owner(state, user_id, "role_revoked", Some(&role.name)).await; + } + Ok(()) +} + +/// An administrator never grants a role to their own account: holding +/// `roles:manage` must not be a way to give oneself every permission. Stepping +/// down (taking one's own role back) stays possible, within the guard that +/// keeps someone able to manage roles. +fn refuse_own_account(actor: &Actor, user_id: Uuid) -> Result<(), AppError> { + if actor.user_id == user_id { + return Err(AppError::Forbidden); + } + Ok(()) +} + +/// A role granting administration goes only to an active account that can +/// prove a second factor: the administration refuses any session that did not, +/// and an account without one would hold administrative permissions in its +/// tokens behind its password alone. +pub(crate) async fn ensure_can_administer( + tx: &mut sqlx::PgConnection, + user: &crate::domain::user::User, + role: &Role, +) -> Result<(), AppError> { + if !role_repo::grants_administration(&mut *tx, role.id).await? { + return Ok(()); + } + if user.status != crate::domain::user::UserStatus::Active + || !user_repo::has_second_factor(&mut *tx, user.id).await? + { + return Err(AppError::Conflict("administrator_without_second_factor")); + } + Ok(()) +} + +/// Refuse to grant or withdraw a permission the administrator does not hold. +/// Read in the caller's transaction, after its locks. +async fn ensure_actor_holds( + tx: &mut sqlx::PgConnection, + actor: &Actor, + permissions: &[String], +) -> Result<(), AppError> { + for permission in permissions { + if !role_repo::user_has_permission(&mut *tx, actor.user_id, permission).await? { + return Err(AppError::Forbidden); + } + } Ok(()) } @@ -208,9 +372,16 @@ async fn require_reauth( .await } -/// Refuse a change that would leave nobody able to manage roles; the -/// transaction is then rolled back. -async fn keep_an_administrator(tx: &mut sqlx::PgConnection) -> Result<(), AppError> { +/// Refuse a change that would leave no active account able to manage roles; +/// the transaction is then rolled back. +/// +/// Every such change takes the same transaction-scoped lock before checking, +/// so two of them cannot each see the other's holder and both commit: the +/// second one checks once the first has committed. +pub(crate) async fn keep_an_administrator(tx: &mut sqlx::PgConnection) -> Result<(), AppError> { + sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended('roles_manage_guard', 0))") + .execute(&mut *tx) + .await?; if role_repo::permission_held(&mut *tx, role_domain::ROLES_MANAGE).await? { Ok(()) } else { diff --git a/src/services/admin/users.rs b/src/services/admin/users.rs index c9bfa8c..beed412 100644 --- a/src/services/admin/users.rs +++ b/src/services/admin/users.rs @@ -62,6 +62,7 @@ pub async fn detail(state: &AppState, user_id: Uuid) -> Result Result<(), AppError> { refuse_own_account(actor, user_id)?; + require_reauth(state, actor, "admin_suspend_account").await?; let user = find(state, user_id).await?; if user.status == UserStatus::PendingVerification { return Err(AppError::Validation( @@ -69,12 +70,21 @@ pub async fn suspend(state: &AppState, actor: &Actor, user_id: Uuid) -> Result<( )); } - let active = session_repo::find_active_by_user(&state.db, user_id).await?; let mut tx = state.db.begin().await?; if !user_repo::suspend(&mut *tx, user_id).await? { return Ok(()); } - session_repo::revoke_all_by_user(&mut *tx, user_id).await?; + // Read in the transaction, after the row is locked by the update: a role + // granted meanwhile is seen. + let manages_roles = + role_repo::user_has_permission(&mut *tx, user_id, crate::domain::role::ROLES_MANAGE) + .await?; + if manages_roles { + super::roles::keep_an_administrator(&mut tx).await?; + } + // Read in the transaction: a session opened meanwhile is revoked and + // forgotten like the others. + let active = session_repo::revoke_all_by_user_returning(&mut *tx, user_id).await?; audit::append( &mut *tx, &entry( @@ -101,11 +111,14 @@ pub async fn suspend(state: &AppState, actor: &Actor, user_id: Uuid) -> Result<( events::wake(); forget_sessions(state, &active).await; + super::notify_owner(state, user_id, "suspended", None).await; Ok(()) } /// Lift a suspension. Reactivating an active account changes nothing. pub async fn reactivate(state: &AppState, actor: &Actor, user_id: Uuid) -> Result<(), AppError> { + // Reopening an account a stolen token wants back needs the administrator. + require_reauth(state, actor, "admin_reactivate_account").await?; find(state, user_id).await?; let mut tx = state.db.begin().await?; if !user_repo::reactivate(&mut *tx, user_id).await? { @@ -124,19 +137,34 @@ pub async fn reactivate(state: &AppState, actor: &Actor, user_id: Uuid) -> Resul .await?; tx.commit().await?; events::wake(); + super::notify_owner(state, user_id, "reactivated", None).await; Ok(()) } /// End a lockout: the sign-in lockout and the re-authentication one. +/// Not on one's own account, and after a recent re-authentication: unlocking +/// in a loop would otherwise let a stolen token guess the password freely. pub async fn unlock(state: &AppState, actor: &Actor, user_id: Uuid) -> Result<(), AppError> { + refuse_own_account(actor, user_id)?; + require_reauth(state, actor, "admin_unlock_account").await?; find(state, user_id).await?; - user_repo::clear_lockout(&state.db, user_id).await?; - redis_counter::reset(&state.redis, &[&user_svc::reauth_fail_key(user_id)]).await; + let mut tx = state.db.begin().await?; + user_repo::clear_lockout(&mut *tx, user_id).await?; audit::append( - &state.db, + &mut *tx, &entry(actor, user_id, AuditAction::AccountUnlocked, json!({})), ) .await?; + tx.commit().await?; + redis_counter::reset( + &state.redis, + &[ + &user_svc::reauth_fail_key(user_id), + &format!("login_try:{user_id}"), + ], + ) + .await; + super::notify_owner(state, user_id, "unlocked", None).await; Ok(()) } @@ -147,11 +175,14 @@ pub async fn revoke_sessions( user_id: Uuid, ) -> Result { refuse_own_account(actor, user_id)?; + // Signing others out in a loop is how a stolen administrator token would + // push the other administrators out. + require_reauth(state, actor, "admin_revoke_sessions").await?; find(state, user_id).await?; - let active = session_repo::find_active_by_user(&state.db, user_id).await?; let mut tx = state.db.begin().await?; - let count = session_repo::revoke_all_by_user(&mut *tx, user_id).await?; + let active = session_repo::revoke_all_by_user_returning(&mut *tx, user_id).await?; + let count = active.len() as u64; audit::append( &mut *tx, &entry( @@ -172,6 +203,9 @@ pub async fn revoke_sessions( events::wake(); forget_sessions(state, &active).await; + if count > 0 { + super::notify_owner(state, user_id, "sessions_revoked", None).await; + } Ok(count) } @@ -181,13 +215,18 @@ pub async fn force_password_reset( state: &AppState, actor: &Actor, user_id: Uuid, + revoke_access_factors: bool, ) -> Result<(), AppError> { refuse_own_account(actor, user_id)?; + require_reauth(state, actor, "admin_force_password_reset").await?; let user = find(state, user_id).await?; - let active = session_repo::find_active_by_user(&state.db, user_id).await?; + // One transaction: when the access factors cannot go (an administrator + // keeps a second factor), nothing is revoked either. let mut tx = state.db.begin().await?; - let count = session_repo::revoke_all_by_user(&mut *tx, user_id).await?; + user_repo::lock_row(&mut *tx, user_id).await?; + let active = session_repo::revoke_all_by_user_returning(&mut *tx, user_id).await?; + let count = active.len(); audit::append( &mut *tx, &entry( @@ -198,6 +237,11 @@ pub async fn force_password_reset( ), ) .await?; + // Whoever knew the password may have added their own ways in: on request, + // they go with it. + if revoke_access_factors { + drop_access_factors_in(&mut tx, actor, user_id).await?; + } events::enqueue( &mut *tx, "user.sessions_revoked", @@ -208,28 +252,66 @@ pub async fn force_password_reset( events::wake(); forget_sessions(state, &active).await; - auth_svc::send_reset_link(state, &user, actor.ip, None).await + // The link records no address: it would show the administrator's in the + // owner's export. + auth_svc::send_reset_link(state, &user, None, None, true).await } -/// Delete the account like its owner would, after a recent re-authentication of -/// the administrator. -pub async fn delete( +/// Remove every way into the account other than its password: second +/// factors, recovery codes, passkeys, external identities and personal access +/// tokens. For an account whose password was known to someone else, after a +/// forced reset: what they planted goes too. An account holding +/// administrative permissions keeps its second factor. +pub async fn remove_access_factors( state: &AppState, actor: &Actor, user_id: Uuid, - current_password: Option<&str>, ) -> Result<(), AppError> { refuse_own_account(actor, user_id)?; - reauth_svc::require_recent_reauth_or_password( - state, - actor.user_id, - actor.session_id, - current_password, - actor.ip, - actor.request_id, - "admin_delete_account", + require_reauth(state, actor, "admin_remove_access_factors").await?; + find(state, user_id).await?; + let mut tx = state.db.begin().await?; + user_repo::lock_row(&mut *tx, user_id).await?; + // Every session goes, not only those of personal access tokens: whoever + // planted a factor may be signed in through the browser or a device. + let revoked = session_repo::revoke_all_by_user_returning(&mut *tx, user_id).await?; + drop_access_factors_in(&mut tx, actor, user_id).await?; + events::enqueue( + &mut *tx, + "user.sessions_revoked", + &events::UserSessionsRevoked { user_id }, ) .await?; + tx.commit().await?; + events::wake(); + forget_sessions(state, &revoked).await; + super::notify_owner(state, user_id, "access_removed", None).await; + Ok(()) +} + +/// Drop the account's access factors in the caller's transaction, which holds +/// the account's lock and revokes its sessions. +async fn drop_access_factors_in( + tx: &mut sqlx::PgConnection, + actor: &Actor, + user_id: Uuid, +) -> Result<(), AppError> { + user_repo::drop_access_factors(&mut *tx, user_id).await?; + crate::services::user::keep_a_second_factor_for_administrators(&mut *tx, user_id).await?; + audit::append( + &mut *tx, + &entry(actor, user_id, AuditAction::AccessFactorsRemoved, json!({})), + ) + .await?; + Ok(()) +} + +/// Delete the account like its owner would, after a recent re-authentication of +/// the administrator (`POST /users/me/reauth`, whose attempts are budgeted +/// like every password route). +pub async fn delete(state: &AppState, actor: &Actor, user_id: Uuid) -> Result<(), AppError> { + refuse_own_account(actor, user_id)?; + require_reauth(state, actor, "admin_delete_account").await?; find(state, user_id).await?; user_svc::erase_account( state, @@ -241,6 +323,26 @@ pub async fn delete( .await } +/// Suspending an account or forcing its reset locks its owner out: like +/// deleting it, it needs the administrator's recent re-authentication, so a +/// stolen administrator token alone cannot shut accounts out. +async fn require_reauth( + state: &AppState, + actor: &Actor, + reason: &'static str, +) -> Result<(), AppError> { + reauth_svc::require_recent_reauth_or_password( + state, + actor.user_id, + actor.session_id, + None, + actor.ip, + actor.request_id, + reason, + ) + .await +} + async fn find(state: &AppState, user_id: Uuid) -> Result { user_repo::find_by_id(&state.db, user_id) .await? diff --git a/src/services/auth/guards.rs b/src/services/auth/guards.rs index 2de8e91..5512c95 100644 --- a/src/services/auth/guards.rs +++ b/src/services/auth/guards.rs @@ -6,6 +6,136 @@ use crate::domain::token::{OneTimeToken, TokenVerdict}; /// Every one-time token submission takes at least this long, found or not. const ONE_TIME_TOKEN_MIN_DURATION: std::time::Duration = std::time::Duration::from_millis(100); +/// Redis holds the challenge between a password and its second factor: when +/// it cannot be reached, the sign-in is unavailable, not broken. +pub(super) fn redis_unavailable(error: impl std::fmt::Display) -> AppError { + tracing::warn!(error = %error, "sign-in challenge store unavailable"); + AppError::ServiceUnavailable("redis_unavailable") +} + +/// Resends of an e-mail code: twice per challenge, ten times an hour per +/// account. Refused past either, fail closed like the code budgets. +pub(crate) async fn budget_email_resend( + state: &AppState, + pre_auth_token: &str, + user_id: Uuid, +) -> Result<(), AppError> { + let challenge_key = format!("email2fa_resend:{}", challenge_id(pre_auth_token)); + let account_key = format!("email2fa_resend_user:{user_id}"); + let attempt = redis_counter::consume( + &state.redis, + &[ + Budget { + key: &challenge_key, + limit: MAX_EMAIL_RESENDS_PER_CHALLENGE, + window_secs: PRE_AUTH_TTL_SECS, + }, + Budget { + key: &account_key, + limit: MAX_EMAIL_RESENDS_PER_ACCOUNT, + window_secs: SECOND_FACTOR_USER_WINDOW_SECS, + }, + ], + ) + .await?; + if attempt.exceeded { + return Err(AppError::RateLimitExceeded); + } + let user = user_repo::find_by_id(&state.db, user_id) + .await? + .ok_or(AppError::TokenInvalid)?; + ensure_account_usable(&user) +} + +/// Mail the owner that their second factor is being guessed, once an hour at +/// most: an account budget exhausted means someone holds the password. +pub(crate) async fn notify_second_factor_pressure(state: &AppState, user_id: Uuid) { + let key = format!("2fa_pressure_notice:{user_id}"); + if !redis_counter::claim_cooldown(&state.redis, &key, SECOND_FACTOR_USER_WINDOW_SECS).await { + return; + } + let Ok(Some(user)) = user_repo::find_by_id(&state.db, user_id).await else { + return; + }; + let mailer = state.mailer.clone(); + let templates = state.templates.clone(); + let mail_cfg = state.config.mail.clone(); + email::dispatch_best_effort("second_factor_attempts_email", async move { + email::send_second_factor_attempts( + &mailer, + templates.as_ref(), + &mail_cfg, + &user.email, + &user.username, + &user.preferred_locale, + ) + .await + }); +} + +/// The failure budgets of a second factor at sign-in, beside the budget of +/// the challenge itself: `limit` failures per window from one client address, +/// and `SECOND_FACTOR_ACCOUNT_FACTOR` times as many for the account from every +/// address (30 an hour for TOTP codes: a search of the code space stays +/// hopeless, and the owner is mailed when it is spent). Someone holding the password and guessing from their own address +/// exhausts only their share, not the owner's. +pub(crate) fn second_factor_budget_keys( + prefix: &str, + user_id: Uuid, + ip: Option, + limit: i64, +) -> Vec<(String, i64)> { + let mut keys = vec![( + format!("{prefix}{user_id}"), + limit * SECOND_FACTOR_ACCOUNT_FACTOR, + )]; + if let Some(ip) = ip { + keys.push((format!("{prefix}{user_id}:{}", ip_bucket(ip.ip())), limit)); + } + keys +} + +/// Whether the account's budget (the first of `second_factor_budget_keys`, +/// behind the challenge's) is the one exceeded. +pub(crate) fn account_budget_exceeded(counts: &[i64], account_limit: i64) -> bool { + counts.get(1).is_some_and(|count| *count > account_limit) +} + +/// The budget of links mailed to `user_id` (`prefix` names the kind): per +/// client address, then for the account as a whole. +/// +/// The window is the lifetime of the links: once someone else has spent the +/// budget, the links their requests mailed to the owner stay valid until it +/// opens again, so the owner is never left without one. +pub(super) async fn mailbox_budget_exhausted( + state: &AppState, + prefix: &str, + user_id: Uuid, + ip: Option, + window_secs: u64, +) -> bool { + if let Some(ip) = ip { + let key = format!("{prefix}:{user_id}:{}", ip_bucket(ip.ip())); + if budget_exhausted( + state, + &key, + MAX_MAILBOX_LINKS_BY_ACCOUNT_AND_IP, + window_secs, + ) + .await + { + return true; + } + } + budget_exhausted( + state, + &format!("{prefix}:{user_id}"), + MAX_MAILBOX_LINKS_BY_ACCOUNT, + window_secs, + ) + .await +} + /// Consume one attempt of an abuse-control budget. /// /// Fails open: these budgets bound volume (mail floods, token scanning) rather @@ -36,8 +166,9 @@ pub(super) async fn budget_exhausted( } /// Throttle submissions of one-time tokens (email verification, password -/// reset): per IP, and per token hash across every IP. Tokens carry 256 bits, -/// so this is volume control, not the security boundary; it fails open. +/// reset, magic link): per IP, and per token hash across every IP. Fails +/// closed: the budget per token hash is what the guarantee on guessing a +/// token rests on while Redis is under pressure. pub(super) async fn guard_token_submission( state: &AppState, kind: &str, @@ -61,14 +192,11 @@ pub(super) async fn guard_token_submission( }); } - match redis_counter::consume(&state.redis, &budgets).await { - Ok(attempt) if attempt.exceeded => Err(AppError::RateLimitExceeded), - Ok(_) => Ok(()), - Err(error) => { - tracing::warn!(error = %error, "token submission budget unavailable, failing open"); - Ok(()) - } + let attempt = redis_counter::consume(&state.redis, &budgets).await?; + if attempt.exceeded { + return Err(AppError::RateLimitExceeded); } + Ok(()) } /// Add the attempted identifier to the per-IP HyperLogLog for credential-stuffing detection. @@ -85,8 +213,15 @@ pub(super) async fn track_credential_stuffing( match state.redis.get().await { Ok(mut conn) => { let key = format!("{}{}", CS_HLL_PREFIX, ip_bucket(ip_val.ip())); - let _: Result<(), _> = conn.pfadd(&key, identifier).await; - let _: Result<(), _> = conn.expire(&key, CS_WINDOW_SECS as i64).await; + // One atomic pipeline: the key never lives without its expiry. + let _: Result<(), _> = deadpool_redis::redis::pipe() + .atomic() + .pfadd(&key, identifier) + .ignore() + .expire(&key, CS_WINDOW_SECS as i64) + .ignore() + .query_async(&mut *conn) + .await; } Err(e) => { tracing::warn!(ip = %ip_val.ip(), error = %e, "credential-stuffing tracking skipped: Redis unavailable"); @@ -106,7 +241,7 @@ pub(super) async fn record_failure( db, &NewLoginAttempt { user_id, - attempted_identifier: identifier, + attempted_identifier: crate::domain::login_attempt::storable_identifier(identifier), was_successful: false, failure_reason: Some(reason), request_ip: ip, @@ -168,17 +303,13 @@ pub(crate) fn ensure_status_allows_sign_in(user: &User) -> Result<(), AppError> } } -/// [`ensure_status_allows_sign_in`], then the lockout: for flows completing -/// after the password was proven (second factors, client flows). -pub(crate) fn ensure_account_usable( - user: &User, - now: ::time::OffsetDateTime, -) -> Result<(), AppError> { - ensure_status_allows_sign_in(user)?; - if user.is_locked(now) { - return Err(AppError::AccountLocked); - } - Ok(()) +/// [`ensure_status_allows_sign_in`], for flows that do not use the password +/// (second factors after it, passkeys, links, identities, client flows). The +/// lockout guards the password alone: none of these can be guessed, and +/// letting it block them would let anyone who knows the identifier shut the +/// owner out. +pub(crate) fn ensure_account_usable(user: &User) -> Result<(), AppError> { + ensure_status_allows_sign_in(user) } /// Look a one-time token up (email verification, password reset) and judge it. @@ -249,17 +380,12 @@ mod tests { } #[test] - fn a_usable_account_is_allowed_and_unlocked() { + fn a_locked_password_does_not_block_the_other_ways_in() { let now = ::time::OffsetDateTime::UNIX_EPOCH + ::time::Duration::days(1); - let later = now + ::time::Duration::seconds(1); - assert!(ensure_account_usable(&user(UserStatus::Active, None), now).is_ok()); - assert!(ensure_account_usable(&user(UserStatus::Active, Some(now)), now).is_ok()); - assert!(matches!( - ensure_account_usable(&user(UserStatus::Active, Some(later)), now), - Err(AppError::AccountLocked) - )); + let later = now + ::time::Duration::seconds(60); + assert!(ensure_account_usable(&user(UserStatus::Active, Some(later))).is_ok()); assert!(matches!( - ensure_account_usable(&user(UserStatus::Inactive, Some(later)), now), + ensure_account_usable(&user(UserStatus::Inactive, Some(later))), Err(AppError::AccountInactive) )); } diff --git a/src/services/auth/login.rs b/src/services/auth/login.rs index cac3f0d..0449046 100644 --- a/src/services/auth/login.rs +++ b/src/services/auth/login.rs @@ -64,10 +64,12 @@ pub async fn login( None => Ok(0), } }; + // Counted under the value recorded for a failure: an identifier that is + // neither an address nor a username shares the `` budget. let identifier_failures_fut = async { login_attempt::count_recent_failures_by_identifier( &state.db, - identifier, + crate::domain::login_attempt::storable_identifier(identifier), brute_force_cutoff, MAX_FAILURES_BY_IDENTIFIER, ) @@ -83,10 +85,25 @@ pub async fn login( distinct_identifiers_by_ip: distinct_identifiers, by_identifier: failures, }; + // With a CAPTCHA required, every attempt already costs a solved challenge: + // the budget of an identifier stops refusing, so that guessing someone's + // password does not also keep them from signing in. The lockout still + // bounds the guesses. + let captcha_enforced = state + .config + .captcha + .secret + .as_deref() + .is_some_and(|secret| !secret.is_empty()); + let identifier_ceiling = if captcha_enforced { + i64::MAX + } else { + MAX_FAILURES_BY_IDENTIFIER + }; let ceilings = FailureCeilings { by_ip: MAX_FAILURES_BY_IP, distinct_identifiers_by_ip: CS_MAX_DISTINCT_IDENTIFIERS, - by_identifier: MAX_FAILURES_BY_IDENTIFIER, + by_identifier: identifier_ceiling, }; if recent.reach(&ceilings) { return Err(AppError::RateLimitExceeded); @@ -95,6 +112,19 @@ pub async fn login( // User lookup stays index-friendly by branching on email vs username format. let user_opt = user_repo::find_by_identifier(&state.db, identifier).await?; + // The counts above are read before the hash and written after it: many + // attempts sent at once would all pass them. Reserve this attempt + // atomically first, against the address and the account; it is given + // back if the password is right. Without Redis the database counts above + // still apply, as they did before. + let reserved = reserve_attempt( + state, + user_opt.as_ref().map(|u| u.id), + ip, + identifier_ceiling, + ) + .await?; + // Always verify a password hash to prevent timing-based enumeration. let (user, password_ok) = match user_opt { Some(u) => { @@ -103,12 +133,25 @@ pub async fn login( .await .map_err(|e| AppError::Internal(e.into()))?; - // A locked account answers the same whatever the password, after the - // same Argon2 work. Checking the lock only after a correct password - // turned the lockout into an oracle confirming the guess. + // A locked password answers like a wrong one, whatever was typed, + // after the same Argon2 work, and is recorded like one: a distinct + // answer, or a budget that stopped counting, would tell anyone which + // identifiers have an account. The owner learns of the lock by email. if u.is_locked(state.clock.now()) { + tokio::join!( + record_failure( + &state.db, + Some(u.id), + identifier, + LoginFailureReason::AccountLocked, + ip, + user_agent, + ), + track_credential_stuffing(state, ip, identifier), + ); metrics::counter!("auth_logins_total", "outcome" => "locked").increment(1); - return Err(AppError::AccountLocked); + apply_backoff(failures + 1).await; + return Err(AppError::InvalidCredentials); } (Some(u), ok) } @@ -137,6 +180,14 @@ pub async fn login( ), track_credential_stuffing(state, ip, identifier), ); + // The lockout count an existing account runs, run here too: the + // answer takes the same database work whether the account exists. + let _ = login_attempt::count_consecutive_failures_by_user( + &state.db, + Uuid::nil(), + i64::from(state.config.security.lockout_threshold), + ) + .await; metrics::counter!("auth_logins_total", "outcome" => "invalid_credentials").increment(1); apply_backoff(failures + 1).await; return Err(AppError::InvalidCredentials); @@ -156,20 +207,39 @@ pub async fn login( // After recording the failure, check if the lockout threshold is reached. let threshold = i64::from(state.config.security.lockout_threshold); + // The lockout is best effort on the sign-in path, but never silent: + // a failure here leaves brute force limited by the budgets alone. + let lockout_failed = |step: &'static str, error: &dyn std::fmt::Display| { + tracing::error!(user_id = %u.id, step, error = %error, "lockout could not be applied"); + metrics::counter!("auth_lockout_failures_total", "step" => step).increment(1); + }; let consecutive = - login_attempt::count_consecutive_failures_by_user(&state.db, u.id, threshold) + match login_attempt::count_consecutive_failures_by_user(&state.db, u.id, threshold) .await - .unwrap_or(0); + { + Ok(consecutive) => consecutive, + Err(e) => { + lockout_failed("count", &e); + 0 + } + }; if let Some(locked_until) = lockout_until( consecutive, state.config.security.lockout_threshold, state.config.security.lockout_duration_secs, state.clock.now(), ) { - metrics::counter!("auth_lockouts_total").increment(1); - // Best-effort: lockout and audit must not leak timing information on the login path. - let _ = user_repo::set_locked_until(&state.db, u.id, locked_until).await; - let _ = audit::append( + let locked = match user_repo::set_locked_until(&state.db, u.id, locked_until).await + { + Ok(locked) => locked, + Err(e) => { + lockout_failed("lock", &e); + false + } + }; + if locked { + metrics::counter!("auth_lockouts_total").increment(1); + if let Err(e) = audit::append( &state.db, &NewAuditEntry { user_id: Some(u.id), @@ -179,16 +249,51 @@ pub async fn login( metadata: json!({"reason": "lockout", "locked_until": locked_until.unix_timestamp()}), }, ) - .await; + .await + { + lockout_failed("audit", &e); + } + notify_locked(state, &u, locked_until); + } } metrics::counter!("auth_logins_total", "outcome" => "invalid_credentials").increment(1); apply_backoff(failures + 1).await; return Err(AppError::InvalidCredentials); } - (Some(u), true) => u, + (Some(u), true) => { + let keys: Vec<&str> = reserved.iter().map(String::as_str).collect(); + redis_counter::release(&state.redis, &keys).await; + rehash_if_weaker(state, &u, password_plaintext); + u + } }; + // A pending account answers like a wrong password, even to the right one. + // Registering an address creates such an account only when the address + // was free: a distinct answer here would tell anyone who registered it + // and then signed in with their own password whether it had an account. + // Its owner gets a new verification link instead, within its budget. + if user.status == UserStatus::PendingVerification { + record_failure( + &state.db, + Some(user.id), + identifier, + LoginFailureReason::EmailNotVerified, + ip, + user_agent, + ) + .await; + if let Err(error) = + super::register::issue_verification(state, &user, None, ip, user_agent, request_id) + .await + { + tracing::warn!(%error, "verification link for a pending sign-in not sent"); + } + metrics::counter!("auth_logins_total", "outcome" => "invalid_credentials").increment(1); + return Err(AppError::InvalidCredentials); + } + // Account status checks ensure_status_allows_sign_in(&user)?; @@ -206,6 +311,29 @@ pub async fn login( .await } +/// Hash the password again, in the background, when its stored hash is weaker +/// than the configured parameters: raising `ARGON2_*` then protects existing +/// accounts as they sign in. Best effort: a failure keeps the old hash. +fn rehash_if_weaker(state: &AppState, user: &User, password_plaintext: &str) { + if !password::needs_rehash(&user.password_hash, &state.config.crypto) { + return; + } + let db = state.db.clone(); + let cfg = state.config.crypto.clone(); + let (id, current) = (user.id, user.password_hash.clone()); + let plaintext = password_plaintext.to_owned(); + crate::utils::background::spawn(async move { + let Ok(replacement) = password::hash_async(&plaintext, &cfg).await else { + return; + }; + match user_repo::replace_password_hash(&db, id, ¤t, &replacement).await { + Ok(true) => metrics::counter!("auth_password_rehashes_total").increment(1), + Ok(false) => {} + Err(error) => tracing::warn!(%error, "could not store a rehashed password"), + } + }); +} + /// The account proved its first factor (password, sign-in link): pause for the /// second factor when one is enrolled, sign in otherwise. #[allow(clippy::too_many_arguments)] @@ -241,30 +369,43 @@ pub(crate) async fn first_factor_proven( let serialized = serde_json::to_string(&pre_auth_state).map_err(|e| AppError::Internal(e.into()))?; - let mut conn = state - .redis - .get() - .await - .map_err(|e| AppError::Internal(e.into()))?; + let mut conn = state.redis.get().await.map_err(redis_unavailable)?; conn.set_ex::<_, _, ()>(&redis_key, serialized, PRE_AUTH_TTL_SECS) .await - .map_err(|e| AppError::Internal(e.into()))?; + .map_err(redis_unavailable)?; // Maintain a per-user index so reset_password (and other revocation // hooks) can purge active pre-auth tokens without SCAN. Best-effort: // a stale entry is harmless because the token itself expires after - // PRE_AUTH_TTL_SECS, and DEL on a missing key is a no-op. + // PRE_AUTH_TTL_SECS, and DEL on a missing key is a no-op. At most + // MAX_OPEN_CHALLENGES stay open per account: someone holding the + // password cannot open challenges without end to feed guesses or fill + // Redis; one of the open ones goes to make room. let user_index_key = user_pre_auth_index_key(user.id); + let open: i64 = conn.scard(&user_index_key).await.unwrap_or(0); + for _ in MAX_OPEN_CHALLENGES - 1..open { + let dropped: Option = conn.spop(&user_index_key).await.unwrap_or(None); + let Some(dropped) = dropped else { break }; + let _: Result<(), _> = conn.del(&challenge_keys_by_id(&dropped)[..]).await; + } let _: Result<(), _> = conn - .sadd::<_, _, ()>(&user_index_key, &pre_auth_token) + .sadd::<_, _, ()>(&user_index_key, challenge_id(&pre_auth_token)) .await; let _: Result<(), _> = conn .expire::<_, ()>(&user_index_key, PRE_AUTH_TTL_SECS as i64) .await; // For Email 2FA, dispatch the code as soon as the challenge is issued. + // A code sent less than a minute ago is still valid: the challenge + // goes on without a new one rather than failing after it was stored. if method == ChallengeMethod::Email { - email_2fa::send_code(state, user.id).await?; + match email_2fa::send_code(state, user.id, Some(&pre_auth_token)).await { + Ok(()) => {} + Err(AppError::RateLimitExceeded) => { + tracing::info!(user_id = %user.id, "email code not resent within its cooldown"); + } + Err(e) => return Err(e), + } } metrics::counter!("auth_logins_total", "outcome" => "two_factor_required").increment(1); @@ -288,6 +429,7 @@ pub(crate) async fn first_factor_proven( identifier, request_id, audit_metadata, + second_factor: false, }), ) .await?; @@ -296,6 +438,74 @@ pub(crate) async fn first_factor_proven( Ok(LoginResult::Complete(tokens)) } +/// Reserve one password attempt against the budgets of the client address +/// and of the account, atomically, before the hash is computed. Returns the +/// keys to give back on success. A budget past its limit refuses the attempt; +/// Redis being unavailable reserves nothing (the database counts still hold). +async fn reserve_attempt( + state: &AppState, + account: Option, + ip: Option, + identifier_ceiling: i64, +) -> Result, AppError> { + // An unknown identifier cannot sign in: the database counts bound it. + let mut keys: Vec<(String, i64)> = account + .map(|id| (format!("login_try:{id}"), identifier_ceiling)) + .into_iter() + .collect(); + if let Some(ip) = ip { + keys.push(( + format!("login_try_ip:{}", ip_bucket(ip.ip())), + MAX_FAILURES_BY_IP, + )); + } + let budgets: Vec = keys + .iter() + .map(|(key, limit)| Budget { + key, + limit: *limit, + window_secs: BRUTE_FORCE_WINDOW_SECS as u64, + }) + .collect(); + match redis_counter::consume(&state.redis, &budgets).await { + Ok(consumed) if consumed.exceeded => { + let reserved: Vec<&str> = keys.iter().map(|(key, _)| key.as_str()).collect(); + redis_counter::release(&state.redis, &reserved).await; + Err(AppError::RateLimitExceeded) + } + Ok(_) => Ok(keys.into_iter().map(|(key, _)| key).collect()), + Err(error) => { + tracing::warn!(error = %error, "sign-in attempt budget unavailable, database counts only"); + Ok(Vec::new()) + } + } +} + +/// Tell the owner their password is locked, and until when: a lock they did +/// not cause means someone is guessing it. Sent once per lock, as a lock is +/// set only on an unlocked account. +fn notify_locked(state: &AppState, user: &User, locked_until: ::time::OffsetDateTime) { + let mailer = state.mailer.clone(); + let templates = state.templates.clone(); + let mail_cfg = state.config.mail.clone(); + let email_to = user.email.clone(); + let username = user.username.clone(); + let locale = user.preferred_locale.clone(); + let minutes = ((locked_until - state.clock.now()).whole_seconds().max(0) + 59) / 60; + email::dispatch_best_effort("account_locked_email", async move { + email::send_account_locked( + &mailer, + templates.as_ref(), + &mail_cfg, + &email_to, + &username, + &locale, + minutes, + ) + .await + }); +} + /// When a sign-in locks the account: once `consecutive` wrong passwords reach /// `threshold`, for `duration_secs` from `now`. Saturates instead of wrapping, /// so an absurd duration locks for a long time rather than not at all. diff --git a/src/services/auth/magic_link.rs b/src/services/auth/magic_link.rs index 41b3a8c..ed6cc43 100644 --- a/src/services/auth/magic_link.rs +++ b/src/services/auth/magic_link.rs @@ -52,20 +52,12 @@ async fn send_link( user_agent: Option<&str>, request_id: Option, ) -> Result<(), AppError> { - let account_key = format!("ml_account:{}", user.id); - if budget_exhausted( - state, - &account_key, - MAX_MAGIC_LINKS_BY_ACCOUNT, - MAGIC_LINK_ACCOUNT_WINDOW_SECS, - ) - .await - { + if mailbox_budget_exhausted(state, "ml_account", user.id, ip, MAGIC_LINK_EXPIRY_SECS).await { return Ok(()); } let raw_token = crypto::generate_token(); - token::replace_magic_link( + token::add_magic_link( &state.db, user.id, &crypto::sha256(raw_token.as_bytes()), @@ -135,7 +127,7 @@ pub async fn complete_magic_link( let user = user_repo::find_by_id(&state.db, record.user_id) .await? .ok_or(AppError::TokenInvalid)?; - ensure_account_usable(&user, state.clock.now())?; + ensure_account_usable(&user)?; first_factor_proven( state, diff --git a/src/services/auth/mod.rs b/src/services/auth/mod.rs index aaac9ef..0482a27 100644 --- a/src/services/auth/mod.rs +++ b/src/services/auth/mod.rs @@ -65,7 +65,10 @@ mod session; mod tokens; use guards::*; -pub(crate) use guards::{ensure_account_usable, ensure_status_allows_sign_in}; +pub(crate) use guards::{ + account_budget_exceeded, budget_email_resend, ensure_account_usable, + ensure_status_allows_sign_in, notify_second_factor_pressure, second_factor_budget_keys, +}; pub use login::*; pub use magic_link::*; pub use password_reset::*; @@ -95,6 +98,9 @@ const MAX_FAILURES_BY_IP: i64 = 30; /// Lookback window for brute-force counting (15 minutes). const BRUTE_FORCE_WINDOW_SECS: i64 = 900; +/// Second-factor challenges open at once per account. +const MAX_OPEN_CHALLENGES: i64 = 5; + /// Redis key prefix for the credential-stuffing HyperLogLog counter (per IP). const CS_HLL_PREFIX: &str = "cs_hll:"; @@ -113,8 +119,9 @@ const REFRESH_FAILURE_WINDOW_SECS: u64 = 900; /// A rotated refresh token presented again within this window is treated as a /// concurrent refresh from the same client (two tabs, a retried request): it is /// refused without revoking the family. Later, it is a replay and the whole -/// family is revoked. Kept short: inside the window a replay goes undetected. -const REFRESH_REUSE_GRACE: TimeDuration = TimeDuration::seconds(2); +/// family is revoked. Kept short: inside the window a replay goes undetected +/// (it is counted in `auth_refresh_concurrent_total`). +const REFRESH_REUSE_GRACE: TimeDuration = TimeDuration::seconds(1); /// Pre-auth (2FA challenge) token TTL in Redis. const PRE_AUTH_TTL_SECS: u64 = 300; @@ -133,10 +140,21 @@ const MAX_TOTP_FAILURES: i64 = 5; /// Max TOTP code failures per account per window, across every pre-auth token. /// A new token only costs the password, so the per-token budget alone does not /// bound a search of the code space. -const MAX_TOTP_FAILURES_BY_USER: i64 = 20; +const MAX_TOTP_FAILURES_BY_USER: i64 = 10; /// Redis key prefix for the per-account TOTP failure budget. -const TOTP_USER_FAIL_PREFIX: &str = "totp_user_fail:"; +pub(crate) const TOTP_USER_FAIL_PREFIX: &str = "totp_user_fail:"; + +/// Redis key prefix for the per-account e-mail code failure budget. +pub(crate) const EMAIL_2FA_USER_FAIL_PREFIX: &str = "email2fa_user_fail:"; + +/// How many times the per-address second-factor budget the account's budget +/// allows, across every address. +pub(crate) const SECOND_FACTOR_ACCOUNT_FACTOR: i64 = 3; + +/// Resends of an e-mail code per challenge and per account (per hour). +const MAX_EMAIL_RESENDS_PER_CHALLENGE: i64 = 2; +const MAX_EMAIL_RESENDS_PER_ACCOUNT: i64 = 10; /// Rolling window of the per-account second-factor budgets (1 hour). const SECOND_FACTOR_USER_WINDOW_SECS: u64 = 3600; @@ -148,10 +166,11 @@ const TOTP_USED_PREFIX: &str = "totp_used:"; const MAX_RECOVERY_FAILURES: i64 = 5; /// Max recovery code failures per user in a rolling window (cross-session protection). -pub(crate) const MAX_RECOVERY_FAILURES_BY_USER: i64 = 10; +pub(crate) const MAX_RECOVERY_FAILURES_BY_USER: i64 = 5; -/// Rolling window for the per-user recovery code failure counter (24 hours). -pub(crate) const RECOVERY_FAILURE_USER_WINDOW_SECS: u64 = 86400; +/// Rolling window for the per-user recovery code failure counter (1 hour): +/// short, so exhausting it cannot keep the owner out for a day. +pub(crate) const RECOVERY_FAILURE_USER_WINDOW_SECS: u64 = 3600; /// Redis key prefix for the per-user recovery code failure counter. Recovery /// codes guessed at sign-in and through the authenticated route share it. @@ -188,11 +207,12 @@ const MAX_FORGOT_PASSWORD_BY_IP: i64 = 5; /// Window of the per-IP forgot-password budget (15 minutes). const FORGOT_PASSWORD_IP_WINDOW_SECS: u64 = 900; -/// Reset emails per account per window, across every IP. -const MAX_FORGOT_PASSWORD_BY_ACCOUNT: i64 = 3; - -/// Window of the per-account forgot-password budget (1 hour). -const FORGOT_PASSWORD_ACCOUNT_WINDOW_SECS: u64 = 3600; +/// Links mailed to an account (reset or sign-in) per window from one client +/// address (IPv6 /64), and from every address together. Counted per address +/// first: someone asking again and again from their own address spends only +/// their own share, and the owner asking from theirs still gets a link. +const MAX_MAILBOX_LINKS_BY_ACCOUNT_AND_IP: i64 = 3; +const MAX_MAILBOX_LINKS_BY_ACCOUNT: i64 = 10; /// Verification e-mail requests per client address (IPv6 /64) per window. const MAX_VERIFICATION_RESENDS_BY_IP: i64 = 5; @@ -210,9 +230,9 @@ const MAGIC_LINK_EXPIRY_SECS: u64 = 60 * 15; const MAX_MAGIC_LINKS_BY_IP: i64 = 5; const MAGIC_LINK_IP_WINDOW_SECS: u64 = 900; -/// Sign-in links per account per window, across every address. -const MAX_MAGIC_LINKS_BY_ACCOUNT: i64 = 3; -const MAGIC_LINK_ACCOUNT_WINDOW_SECS: u64 = 3600; +/// "Someone tried to register with your address" notices per account per window. +const MAX_ACCOUNT_EXISTS_NOTICES: i64 = 3; +const ACCOUNT_EXISTS_NOTICE_WINDOW_SECS: u64 = 3600; /// Every forgot-password response takes at least this long, known address or not. const FORGOT_PASSWORD_MIN_DURATION: std::time::Duration = std::time::Duration::from_millis(250); diff --git a/src/services/auth/password_reset.rs b/src/services/auth/password_reset.rs index 4ceba69..0db6870 100644 --- a/src/services/auth/password_reset.rs +++ b/src/services/auth/password_reset.rs @@ -49,21 +49,11 @@ pub(super) async fn issue_password_reset( return Ok(()); }; - // Cap resets per account across every IP, so nobody can flood a mailbox or - // keep invalidating its pending link from many addresses. - let account_key = format!("fp_account:{}", user.id); - if budget_exhausted( - state, - &account_key, - MAX_FORGOT_PASSWORD_BY_ACCOUNT, - FORGOT_PASSWORD_ACCOUNT_WINDOW_SECS, - ) - .await - { + if mailbox_budget_exhausted(state, "fp_account", user.id, ip, RESET_TOKEN_EXPIRY_SECS).await { return Ok(()); } - send_reset_link(state, &user, ip, user_agent).await?; + send_reset_link(state, &user, ip, user_agent, false).await?; audit::append( &state.db, @@ -81,18 +71,22 @@ pub(super) async fn issue_password_reset( Ok(()) } -/// Issue a reset link for `user`, replacing any pending one, and mail it. No -/// budget: callers apply their own. +/// Issue a reset link for `user` and mail it. No budget: callers apply their +/// own. A request by the owner leaves the earlier links usable (someone else +/// asking must not revoke the link the owner is about to click; using one ends +/// the others); an administrator forcing a reset replaces them. pub(crate) async fn send_reset_link( state: &AppState, user: &User, ip: Option, user_agent: Option<&str>, + replace_pending: bool, ) -> Result<(), AppError> { - // Revoke any previous pending reset before issuing a new one - token::revoke_active_password_reset_by_user(&state.db, user.id) - .await - .map_err(|e| AppError::Internal(e.into()))?; + if replace_pending { + token::revoke_active_password_reset_by_user(&state.db, user.id) + .await + .map_err(|e| AppError::Internal(e.into()))?; + } let raw_token = crypto::generate_token(); let hash = crypto::sha256(raw_token.as_bytes()); @@ -172,21 +166,44 @@ pub async fn reset_password( } user_repo::update_password_hash(&mut *tx, record.user_id, &new_hash).await?; + // Whoever holds the mailbox holds the account: the new password is not + // locked by the guesses that locked the old one. + user_repo::clear_lockout(&mut *tx, record.user_id).await?; + + // Ways in added shortly before the reset was asked for may have been + // planted by whoever held the password: they go, and the mail lists them. + let window = state.config.security.reset_revokes_factors_added_hours; + let removed = if window > 0 { + let since = record.created_at - ::time::Duration::hours(i64::from(window)); + // An account holding administration keeps one second factor: the + // mailbox alone must not undo what the administration requires. + user_repo::lock_row(&mut *tx, record.user_id).await?; + let administrator = + crate::repositories::role::holds_administration(&mut *tx, record.user_id).await?; + user_repo::drop_access_factors_since(&mut tx, record.user_id, since, administrator).await? + } else { + Vec::new() + }; // Invalidate all active sessions to force re-login with the new password session_repo::revoke_all_by_user(&mut *tx, record.user_id).await?; - // Also purge pending reset tokens - token::revoke_active_password_reset_by_user(&mut *tx, record.user_id).await?; + // Also purge pending reset and sign-in links + token::revoke_mailbox_links(&mut tx, record.user_id).await?; // The reset link went to the account's address: whoever used it owns the // address. A pending account is verified with the password its owner just // chose, which also takes back an address someone else registered. + // The account then carries the username the owner last asked for, not + // the one chosen by whoever registered the address first. + user_repo::adopt_latest_registration_identity(&mut tx, record.user_id).await?; if user_repo::verify_if_pending(&mut *tx, record.user_id) .await .map_err(|e| AppError::Internal(e.into()))? { token::revoke_active_verification_by_user(&mut *tx, record.user_id).await?; + // Nothing enrolled before the owner proved the address is theirs. + user_repo::drop_access_factors(&mut *tx, record.user_id).await?; } audit::append( @@ -230,5 +247,39 @@ pub async fn reset_password( // Best-effort: Redis failures here must not fail the reset. purge_user_pre_auth_and_email_change(state, record.user_id).await; + // The account's second-factor budgets start afresh: someone who held the + // old password and spent them must not keep the owner out after the reset. + let user_id = record.user_id; + redis_counter::reset( + &state.redis, + &[ + &format!("{TOTP_USER_FAIL_PREFIX}{user_id}"), + &format!("{RC_USER_FAIL_PREFIX}{user_id}"), + &format!("{EMAIL_2FA_USER_FAIL_PREFIX}{user_id}"), + // The sign-in budget too: failures typed by whoever made the + // owner reset do not keep them from signing in afterwards. + &format!("login_try:{user_id}"), + ], + ) + .await; + + // The owner learns what still opens the account: a passkey or token added + // by whoever held the old password survives the reset. + if let Ok(Some(user)) = user_repo::find_by_id(&state.db, record.user_id).await { + let removed = removed + .into_iter() + .map(|(kind, name)| crate::services::email::AccessItem { + kind: match kind.as_str() { + "totp" => "totp", + "email" => "email", + "passkey" => "passkey", + _ => "identity", + }, + name, + }) + .collect(); + crate::services::user::notify_password_changed(state, &user, removed).await; + } + Ok(()) } diff --git a/src/services/auth/pre_auth.rs b/src/services/auth/pre_auth.rs index 9bb1abd..949d980 100644 --- a/src/services/auth/pre_auth.rs +++ b/src/services/auth/pre_auth.rs @@ -7,27 +7,28 @@ pub async fn resolve_pre_auth( pre_auth_token: &str, ) -> Result { let redis_key = pre_auth_key(pre_auth_token); - let mut conn = state - .redis - .get() - .await - .map_err(|e| AppError::Internal(e.into()))?; + let mut conn = state.redis.get().await.map_err(redis_unavailable)?; load_pre_auth_state_from_redis(&mut conn, &redis_key).await } +/// The id of a challenge in Redis: the token's digest, never the token. +pub(crate) fn challenge_id(pre_auth_token: &str) -> String { + crypto::token_id(pre_auth_token) +} + pub(super) fn pre_auth_key(pre_auth_token: &str) -> String { - format!("{}{}", PRE_AUTH_PREFIX, pre_auth_token) + format!("{}{}", PRE_AUTH_PREFIX, challenge_id(pre_auth_token)) } -/// Every Redis key a pre-auth token owns: its state and its per-challenge -/// failure budgets. Purging a challenge deletes them all. -pub(super) fn challenge_keys(pre_auth_token: &str) -> [String; 4] { +/// Every Redis key a challenge owns, by its id: its state and its +/// per-challenge failure budgets. Purging a challenge deletes them all. +pub(super) fn challenge_keys_by_id(id: &str) -> [String; 4] { [ - pre_auth_key(pre_auth_token), - format!("{TOTP_FAIL_PREFIX}{pre_auth_token}"), - format!("{RC_FAIL_PREFIX}{pre_auth_token}"), - format!("{EMAIL_2FA_FAIL_PREFIX}{pre_auth_token}"), + format!("{PRE_AUTH_PREFIX}{id}"), + format!("{TOTP_FAIL_PREFIX}{id}"), + format!("{RC_FAIL_PREFIX}{id}"), + format!("{EMAIL_2FA_FAIL_PREFIX}{id}"), ] } @@ -53,22 +54,22 @@ pub async fn purge_user_pre_auth_and_email_change(state: &AppState, user_id: Uui // 1. Purge pre-auth (2FA challenge) tokens via the per-user index. let index_key = user_pre_auth_index_key(user_id); - let tokens: Vec = conn.smembers(&index_key).await.unwrap_or_default(); - for token in &tokens { - let _: Result<(), _> = conn.del(challenge_keys(token).to_vec()).await; + let ids: Vec = conn.smembers(&index_key).await.unwrap_or_default(); + for id in &ids { + let _: Result<(), _> = conn.del(challenge_keys_by_id(id).to_vec()).await; } let _: Result<(), _> = conn.del(&index_key).await; // 2. Purge any in-progress email-change flow for this user. The flow keeps - // its current flow_token in `email_change_active:{user_id}`, so we don't + // its current flow id in `email_change_active:{user_id}`, so we don't // need to scan. let active_key = format!("email_change_active:{}", user_id); - let active_token: Option = conn.get(&active_key).await.unwrap_or(None); - if let Some(flow_token) = active_token { + let active_id: Option = conn.get(&active_key).await.unwrap_or(None); + if let Some(flow_id) = active_id { let _: Result<(), _> = conn .del(vec![ - format!("email_change_flow:{}", flow_token), - format!("email_change_fail:{}", flow_token), + format!("email_change_flow:{flow_id}"), + format!("email_change_fail:{flow_id}"), active_key, ]) .await; @@ -79,10 +80,7 @@ pub(super) async fn load_pre_auth_state_from_redis( conn: &mut crate::utils::redis_pool::RedisConnection, redis_key: &str, ) -> Result { - let raw: Option = conn - .get(redis_key) - .await - .map_err(|e| AppError::Internal(e.into()))?; + let raw: Option = conn.get(redis_key).await.map_err(redis_unavailable)?; let raw = raw.ok_or(AppError::TokenInvalid)?; parse_pre_auth_state(&raw) @@ -107,7 +105,7 @@ mod tests { #[test] fn a_challenge_owns_its_state_and_every_failure_budget() { assert_eq!( - challenge_keys("t"), + challenge_keys_by_id("t"), ["pre_auth:t", "totp_fail:t", "rc_fail:t", "email2fa_fail:t"] ); } diff --git a/src/services/auth/register.rs b/src/services/auth/register.rs index 9baf2d9..d762224 100644 --- a/src/services/auth/register.rs +++ b/src/services/auth/register.rs @@ -2,6 +2,9 @@ use super::*; +/// Register an account. Every answer takes at least +/// `FORGOT_PASSWORD_MIN_DURATION`: a taken address, a pending one and a new one +/// do different work, and the difference must not show in the response time. #[allow(clippy::too_many_arguments)] pub async fn register( state: &AppState, @@ -12,15 +15,60 @@ pub async fn register( ip: Option, user_agent: Option<&str>, request_id: Option, +) -> Result, AppError> { + // Before any work, and the same whatever the address: usernames are + // reserved from registration, so their pace from one address is bounded. + let per_hour = i64::from(state.config.security.registrations_per_ip_per_hour); + if let Some(ip_val) = ip + && per_hour > 0 + && budget_exhausted( + state, + &format!("rg_req:{}", ip_bucket(ip_val.ip())), + per_hour, + 3600, + ) + .await + { + return Err(AppError::RateLimitExceeded); + } + + let started = std::time::Instant::now(); + let result = register_account( + state, + username, + email, + password_plaintext, + locale, + ip, + user_agent, + request_id, + ) + .await; + if let Some(rest) = FORGOT_PASSWORD_MIN_DURATION.checked_sub(started.elapsed()) { + tokio::time::sleep(rest).await; + } + result +} + +#[allow(clippy::too_many_arguments)] +async fn register_account( + state: &AppState, + username: &str, + email: &str, + password_plaintext: &str, + locale: &str, + ip: Option, + user_agent: Option<&str>, + request_id: Option, ) -> Result, AppError> { // A taken username is reported: it is a public identifier the user picks // and must know to change. A taken email is not: answering differently // would let anyone test which addresses have an account. Its owner is told // by email instead, and the caller gets the same response as a new signup. - if user_repo::find_by_username(&state.db, username) - .await? - .is_some() - { + // A registration on a taken address reserves its username like a new + // account would, so asking for the same username again answers the same + // either way. + if user_repo::username_unavailable(&state.db, username).await? { return Err(AppError::Conflict("username_taken")); } @@ -34,11 +82,43 @@ pub async fn register( .map_err(|e| AppError::Internal(e.into()))?; if let Some(existing) = user_repo::find_by_email(&state.db, email).await? { - // A pending account gets its verification again, so an owner who lost - // the first e-mail can finish; an active one is told of the attempt. + user_repo::reserve_username( + &state.db, + username, + existing.id, + state.clock.in_secs(EMAIL_TOKEN_EXPIRY_SECS), + ) + .await?; + // A pending account belongs to nobody yet: this registration gets its + // own link, carrying the credentials it chose. The link activates the + // account only with the password of that registration, so neither + // registration can activate it with the other's password, whichever + // came first. An active account is told of the attempt. if existing.status == UserStatus::PendingVerification { - issue_verification(state, &existing, ip, user_agent, request_id).await?; - } else { + let credentials = crate::domain::token::PendingCredentials { + password_hash: hash, + username: username.to_owned(), + preferred_locale: locale.to_owned(), + }; + issue_verification( + state, + &existing, + Some(credentials), + ip, + user_agent, + request_id, + ) + .await?; + } else if !budget_exhausted( + state, + &format!("ae_account:{}", existing.id), + MAX_ACCOUNT_EXISTS_NOTICES, + ACCOUNT_EXISTS_NOTICE_WINDOW_SECS, + ) + .await + { + // At most a few notices an hour: registering the address again + // and again must not flood its owner. notify_existing_account(state, &existing); } return Ok(None); @@ -73,7 +153,7 @@ pub async fn register( e, &[ ("users_email_key", "email_taken"), - ("users_username_key", "username_taken"), + ("users_username_lower_key", "username_taken"), ], ) { AppError::Conflict("email_taken") => Ok(None), @@ -95,6 +175,7 @@ pub async fn register( request_ip: ip, request_user_agent: user_agent, target_email: email, + credentials: None, }, ) .await @@ -174,7 +255,7 @@ pub async fn resend_verification( let started = std::time::Instant::now(); let result = match user_repo::find_by_email(&state.db, email).await? { Some(user) if user.status == UserStatus::PendingVerification => { - issue_verification(state, &user, ip, user_agent, request_id).await + issue_verification(state, &user, None, ip, user_agent, request_id).await } _ => Ok(()), }; @@ -185,20 +266,42 @@ pub async fn resend_verification( result } -/// Replace the pending verification link of `user` and e-mail it, within the -/// per-account budget. -async fn issue_verification( +/// E-mail `user`, a pending account, a new verification link within the +/// per-account budget. The link carries `credentials` when a registration sent +/// it; a resend carries none, so it asks for the password the account was +/// created with and never repeats another registration's. Earlier links stay +/// valid until one of them verifies the account: a later registration or +/// resend must not revoke the link its owner is about to click. +pub(super) async fn issue_verification( state: &AppState, user: &User, + credentials: Option, ip: Option, user_agent: Option<&str>, request_id: Option, ) -> Result<(), AppError> { + // Budgeted per client address first, then for the account as a whole with + // more room: registrations by someone else on a pending address spend + // their own share, not the owner's. Links live a day, far longer than the + // window, so the owner always holds a valid one. + if let Some(ip) = ip { + let key = format!("vr_account:{}:{}", user.id, ip_bucket(ip.ip())); + if budget_exhausted( + state, + &key, + MAX_VERIFICATION_RESENDS_BY_ACCOUNT, + VERIFICATION_RESEND_ACCOUNT_WINDOW_SECS, + ) + .await + { + return Ok(()); + } + } let account_key = format!("vr_account:{}", user.id); if budget_exhausted( state, &account_key, - MAX_VERIFICATION_RESENDS_BY_ACCOUNT, + MAX_VERIFICATION_RESENDS_BY_ACCOUNT * 3, VERIFICATION_RESEND_ACCOUNT_WINDOW_SECS, ) .await @@ -209,9 +312,7 @@ async fn issue_verification( let raw_token = crypto::generate_token(); let hash_bytes = crypto::sha256(raw_token.as_bytes()); - // The previous link stops working when the new one is issued. let mut tx = state.db.begin().await?; - token::revoke_active_verification_by_user(&mut *tx, user.id).await?; token::create_verification( &mut *tx, &NewEmailVerificationToken { @@ -221,6 +322,7 @@ async fn issue_verification( request_ip: ip, request_user_agent: user_agent, target_email: &user.email, + credentials: credentials.as_ref(), }, ) .await @@ -239,12 +341,18 @@ async fn issue_verification( .map_err(|e| AppError::Internal(e.into()))?; tx.commit().await?; + // The e-mail names the account the link activates. + let (username, locale) = match &credentials { + Some(credentials) => ( + credentials.username.clone(), + credentials.preferred_locale.clone(), + ), + None => (user.username.clone(), user.preferred_locale.clone()), + }; let mailer = state.mailer.clone(); let templates = state.templates.clone(); let mail_cfg = state.config.mail.clone(); let email_to = user.email.clone(); - let username = user.username.clone(); - let locale = user.preferred_locale.clone(); let frontend_url = state.config.server.frontend_url.clone(); email::dispatch_best_effort("verification_email", async move { email::send_verification_email( @@ -262,9 +370,14 @@ async fn issue_verification( Ok(()) } +/// Verify an address with a link and the password of the registration that +/// sent it (the account's own password for a link without credentials). A +/// wrong password leaves the link unused: it may be another registration's +/// link, and its owner can still use it. pub async fn verify_email( state: &AppState, raw_token: &str, + password_plaintext: &str, ip: Option, request_id: Option, ) -> Result<(), AppError> { @@ -274,6 +387,23 @@ pub async fn verify_email( let record = check_one_time_token(state, token::find_verification_by_hash(&state.db, &hash)).await?; + let expected_hash = match &record.password_hash { + Some(carried) => carried.clone(), + None => { + user_repo::find_by_id(&state.db, record.user_id) + .await? + .ok_or(AppError::TokenInvalid)? + .password_hash + } + }; + let matches = password::verify_async(password_plaintext, &expected_hash, &state.config.crypto) + .await + .map_err(|e| AppError::Internal(e.into()))?; + if !matches { + tracing::info!("verification link used with another registration's password"); + return Err(AppError::InvalidCredentials); + } + // Consuming the token, verifying the address and announcing it commit together. let mut tx = state.db.begin().await?; @@ -282,6 +412,26 @@ pub async fn verify_email( return Err(AppError::TokenInvalid); } + // A link sent by a registration on a pending account activates it with + // that registration's credentials; the other links of the account die. + if let (Some(password_hash), Some(username), Some(preferred_locale)) = ( + record.password_hash.clone(), + record.username.clone(), + record.preferred_locale.clone(), + ) { + user_repo::adopt_pending_credentials( + &mut *tx, + record.user_id, + &crate::domain::token::PendingCredentials { + password_hash, + username, + preferred_locale, + }, + ) + .await?; + } + token::revoke_active_verification_by_user(&mut *tx, record.user_id).await?; + user_repo::mark_email_verified(&mut *tx, record.user_id).await?; audit::append( diff --git a/src/services/auth/second_factor.rs b/src/services/auth/second_factor.rs index 7ed6820..6cfcadb 100644 --- a/src/services/auth/second_factor.rs +++ b/src/services/auth/second_factor.rs @@ -12,13 +12,9 @@ pub async fn complete_two_factor_login( request_id: Option, ) -> Result { let redis_key = pre_auth_key(pre_auth_token); - let fail_key = format!("{TOTP_FAIL_PREFIX}{pre_auth_token}"); + let fail_key = format!("{TOTP_FAIL_PREFIX}{}", challenge_id(pre_auth_token)); - let mut conn = state - .redis - .get() - .await - .map_err(|e| AppError::Internal(e.into()))?; + let mut conn = state.redis.get().await.map_err(redis_unavailable)?; let pre_auth_state = load_pre_auth_state_from_redis(&mut conn, &redis_key).await?; drop(conn); pre_auth_state.expect_method(ChallengeMethod::Totp)?; @@ -27,28 +23,31 @@ pub async fn complete_two_factor_login( // within the attempt budget. let user_id = pre_auth_state.user_id; let remember_me = pre_auth_state.remember_me; - let user_fail_key = format!("{TOTP_USER_FAIL_PREFIX}{user_id}"); - - // Reserve the attempt before checking the code, atomically, against both - // the token and the account: concurrent guesses cannot all read the same - // counter and slip under the limit together. - let attempt = redis_counter::consume( - &state.redis, - &[ - Budget { - key: &fail_key, - limit: MAX_TOTP_FAILURES, - window_secs: PRE_AUTH_TTL_SECS, - }, - Budget { - key: &user_fail_key, - limit: MAX_TOTP_FAILURES_BY_USER, - window_secs: SECOND_FACTOR_USER_WINDOW_SECS, - }, - ], - ) - .await?; + let account_keys = second_factor_budget_keys( + TOTP_USER_FAIL_PREFIX, + user_id, + ip, + MAX_TOTP_FAILURES_BY_USER, + ); + + // Reserve the attempt before checking the code, atomically, against the + // token, the client address and the account: concurrent guesses cannot all + // read the same counter and slip under the limit together. + let mut budgets = vec![Budget { + key: &fail_key, + limit: MAX_TOTP_FAILURES, + window_secs: PRE_AUTH_TTL_SECS, + }]; + budgets.extend(account_keys.iter().map(|(key, limit)| Budget { + key, + limit: *limit, + window_secs: SECOND_FACTOR_USER_WINDOW_SECS, + })); + let attempt = redis_counter::consume(&state.redis, &budgets).await?; if attempt.exceeded { + if account_budget_exceeded(&attempt.counts, account_keys[0].1) { + notify_second_factor_pressure(state, user_id).await; + } return Err(AppError::RateLimitExceeded); } @@ -57,7 +56,7 @@ pub async fn complete_two_factor_login( .map_err(|e| AppError::Internal(e.into()))? .ok_or(AppError::Unauthorized)?; - ensure_account_usable(&user, state.clock.now())?; + ensure_account_usable(&user)?; // The primary method may have changed since the challenge was issued. let method = tf_repo::find_primary_by_user(&state.db, user.id) @@ -73,6 +72,7 @@ pub async fn complete_two_factor_login( let valid = totp::verify_code( encrypted_secret, + user_id, code, &state.keyring, state.config.crypto.totp_skew, @@ -93,9 +93,14 @@ pub async fn complete_two_factor_login( }; let consumed = !cached_replay - && tf_repo::try_consume_totp_code(&state.db, user_id, &crypto::sha256(code.as_bytes())) - .await - .map_err(|e| AppError::Internal(e.into()))?; + && tf_repo::try_consume_totp_code( + &state.db, + user_id, + &crypto::sha256(code.as_bytes()), + state.clock.now(), + ) + .await + .map_err(|e| AppError::Internal(e.into()))?; if consumed && let Ok(mut c) = state.redis.get().await { let _: Result<(), _> = c.set_ex(&used_key, 1u8, 60u64).await; @@ -112,16 +117,12 @@ pub async fn complete_two_factor_login( return Err(AppError::TwoFactorFailed); } - redis_counter::reset(&state.redis, &[&user_fail_key]).await; + let reset: Vec<&str> = account_keys.iter().map(|(key, _)| key.as_str()).collect(); + redis_counter::reset(&state.redis, &reset).await; - // Consume the pre-auth token now that verification succeeded. - if let Ok(mut c) = state.redis.get().await { - let _: Result<(), _> = c.del(&redis_key).await; - let _: Result<(), _> = c.del(&fail_key).await; - let _: Result<(), _> = c - .srem::<_, _, ()>(user_pre_auth_index_key(user_id), pre_auth_token) - .await; - } + // Consume the pre-auth token now that verification succeeded: of + // concurrent completions, only the one that removes it goes on. + take_pre_auth(state, &redis_key, user_id, pre_auth_token, &[&fail_key]).await?; let tokens = issue_tokens( state, @@ -137,6 +138,7 @@ pub async fn complete_two_factor_login( identifier: None, request_id, audit_metadata: json!({"two_factor": true}), + second_factor: true, }), ) .await?; @@ -157,11 +159,7 @@ pub async fn complete_email_2fa_login( ) -> Result { let redis_key = pre_auth_key(pre_auth_token); - let mut conn = state - .redis - .get() - .await - .map_err(|e| AppError::Internal(e.into()))?; + let mut conn = state.redis.get().await.map_err(redis_unavailable)?; let pre_auth_state = load_pre_auth_state_from_redis(&mut conn, &redis_key).await?; drop(conn); pre_auth_state.expect_method(ChallengeMethod::Email)?; @@ -174,9 +172,9 @@ pub async fn complete_email_2fa_login( .map_err(|e| AppError::Internal(e.into()))? .ok_or(AppError::Unauthorized)?; - ensure_account_usable(&user, state.clock.now())?; + ensure_account_usable(&user)?; - if let Err(e) = email_2fa::verify_login_code(state, user_id, pre_auth_token, code).await { + if let Err(e) = email_2fa::verify_login_code(state, user_id, pre_auth_token, code, ip).await { if matches!(e, AppError::TwoFactorFailed) { record_second_factor_failure(state, &user, ip, user_agent, request_id).await; } @@ -184,12 +182,7 @@ pub async fn complete_email_2fa_login( } // Consume the pre-auth token on success. - if let Ok(mut c) = state.redis.get().await { - let _: Result<(), _> = c.del(&redis_key).await; - let _: Result<(), _> = c - .srem::<_, _, ()>(user_pre_auth_index_key(user_id), pre_auth_token) - .await; - } + take_pre_auth(state, &redis_key, user_id, pre_auth_token, &[]).await?; let tokens = issue_tokens( state, @@ -205,6 +198,7 @@ pub async fn complete_email_2fa_login( identifier: None, request_id, audit_metadata: json!({"two_factor": "email"}), + second_factor: true, }), ) .await?; @@ -223,13 +217,9 @@ pub async fn complete_login_with_recovery( request_id: Option, ) -> Result { let redis_key = pre_auth_key(pre_auth_token); - let fail_key = format!("{RC_FAIL_PREFIX}{pre_auth_token}"); + let fail_key = format!("{RC_FAIL_PREFIX}{}", challenge_id(pre_auth_token)); - let mut conn = state - .redis - .get() - .await - .map_err(|e| AppError::Internal(e.into()))?; + let mut conn = state.redis.get().await.map_err(redis_unavailable)?; let pre_auth_state = load_pre_auth_state_from_redis(&mut conn, &redis_key).await?; drop(conn); @@ -241,25 +231,28 @@ pub async fn complete_login_with_recovery( } let user_id = pre_auth_state.user_id; let remember_me = pre_auth_state.remember_me; - let user_fail_key = format!("{}{}", RC_USER_FAIL_PREFIX, user_id); - - let attempt = redis_counter::consume( - &state.redis, - &[ - Budget { - key: &fail_key, - limit: MAX_RECOVERY_FAILURES, - window_secs: PRE_AUTH_TTL_SECS, - }, - Budget { - key: &user_fail_key, - limit: MAX_RECOVERY_FAILURES_BY_USER, - window_secs: RECOVERY_FAILURE_USER_WINDOW_SECS, - }, - ], - ) - .await?; + let account_keys = second_factor_budget_keys( + RC_USER_FAIL_PREFIX, + user_id, + ip, + MAX_RECOVERY_FAILURES_BY_USER, + ); + + let mut budgets = vec![Budget { + key: &fail_key, + limit: MAX_RECOVERY_FAILURES, + window_secs: PRE_AUTH_TTL_SECS, + }]; + budgets.extend(account_keys.iter().map(|(key, limit)| Budget { + key, + limit: *limit, + window_secs: RECOVERY_FAILURE_USER_WINDOW_SECS, + })); + let attempt = redis_counter::consume(&state.redis, &budgets).await?; if attempt.exceeded { + if account_budget_exceeded(&attempt.counts, account_keys[0].1) { + notify_second_factor_pressure(state, user_id).await; + } return Err(AppError::RateLimitExceeded); } @@ -268,7 +261,7 @@ pub async fn complete_login_with_recovery( .map_err(|e| AppError::Internal(e.into()))? .ok_or(AppError::Unauthorized)?; - ensure_account_usable(&user, state.clock.now())?; + ensure_account_usable(&user)?; // The lookup refuses used and expired codes, and the UPDATE re-checks both: // a code sitting on its deadline can cross it between the two statements. @@ -293,14 +286,9 @@ pub async fn complete_login_with_recovery( } // Consume the pre-auth token now that recovery succeeded. - if let Ok(mut c) = state.redis.get().await { - let _: Result<(), _> = c.del(&redis_key).await; - let _: Result<(), _> = c.del(&fail_key).await; - let _: Result<(), _> = c.del(&user_fail_key).await; - let _: Result<(), _> = c - .srem::<_, _, ()>(user_pre_auth_index_key(user_id), pre_auth_token) - .await; - } + take_pre_auth(state, &redis_key, user_id, pre_auth_token, &[&fail_key]).await?; + let reset: Vec<&str> = account_keys.iter().map(|(key, _)| key.as_str()).collect(); + redis_counter::reset(&state.redis, &reset).await; let tokens = issue_tokens( state, @@ -316,6 +304,7 @@ pub async fn complete_login_with_recovery( identifier: None, request_id, audit_metadata: json!({"two_factor": "recovery_code"}), + second_factor: true, }), ) .await?; @@ -342,3 +331,31 @@ pub async fn complete_login_with_recovery( metrics::counter!("auth_2fa_success_total", "method" => "recovery_code").increment(1); Ok(tokens) } + +/// Consume a pre-auth token before issuing the session it stands for. The +/// removal is required, not best effort: a token left behind by a Redis error +/// could complete the sign-in again, and of concurrent completions only the one +/// that removes it may go on. +async fn take_pre_auth( + state: &AppState, + redis_key: &str, + user_id: Uuid, + pre_auth_token: &str, + budgets: &[&str], +) -> Result<(), AppError> { + let mut conn = state.redis.get().await.map_err(redis_unavailable)?; + let removed: i64 = conn.del(redis_key).await.map_err(redis_unavailable)?; + if removed != 1 { + return Err(AppError::TokenInvalid); + } + for key in budgets { + let _: Result<(), _> = conn.del(*key).await; + } + let _: Result<(), _> = conn + .srem::<_, _, ()>( + user_pre_auth_index_key(user_id), + challenge_id(pre_auth_token), + ) + .await; + Ok(()) +} diff --git a/src/services/auth/session.rs b/src/services/auth/session.rs index da3df67..35975ee 100644 --- a/src/services/auth/session.rs +++ b/src/services/auth/session.rs @@ -19,14 +19,12 @@ pub async fn refresh_token( // abuse budgets: without Redis the refresh goes on to the database, the // durable authority on revocation. if let Some(ip_val) = ip { - let key = format!("refresh_fail:{}", ip_bucket(ip_val.ip())); - match state.redis.get().await { - Ok(mut conn) => { - let failures: i64 = conn.get(&key).await.unwrap_or(0); - if failures >= MAX_REFRESH_FAILURES_BY_IP { - return Err(AppError::RateLimitExceeded); - } + let key = refresh_failure_key(ip_val); + match redis_counter::peek(&state.redis, &key).await { + Ok(failures) if failures >= MAX_REFRESH_FAILURES_BY_IP => { + return Err(AppError::RateLimitExceeded); } + Ok(_) => {} Err(error) => { tracing::warn!(error = %error, "refresh budget unavailable, failing open"); } @@ -56,20 +54,13 @@ pub async fn refresh_token( { Some(s) => s, None => { - // Increment failure counter on unknown token. - if let Some(ip_val) = ip { - let key = format!("refresh_fail:{}", ip_bucket(ip_val.ip())); - if let Ok(mut conn) = state.redis.get().await { - let _: Result<(), _> = conn.incr(&key, 1i64).await; - let _: Result<(), _> = - conn.expire(&key, REFRESH_FAILURE_WINDOW_SECS as i64).await; - } - } + note_refresh_failure(state, ip).await; return Err(AppError::TokenInvalid); } }; if session.client_id.as_deref() != client_id { + note_refresh_failure(state, ip).await; return Err(AppError::TokenInvalid); } @@ -80,12 +71,42 @@ pub async fn refresh_token( }; match session.refresh_verdict(state.clock.now(), ip.map(|n| n.ip()), &policy) { RefreshVerdict::Rotate => {} - RefreshVerdict::ConcurrentRefresh => return Err(AppError::TokenInvalid), + RefreshVerdict::ConcurrentRefresh + if rotated_by_this_client(state, &session, ip, user_agent).await => + { + // Two tabs, or a retried request, from the client that rotated + // the session: refused, the family left alive. + metrics::counter!("auth_refresh_concurrent_total").increment(1); + tracing::info!(session_id = %session.id, "rotated refresh token presented within the grace window"); + return Err(AppError::TokenInvalid); + } + RefreshVerdict::ConcurrentRefresh => { + // Within the grace window but from another network or client: the + // token is in two hands, and the family goes like on any replay. + note_refresh_failure(state, ip).await; + revoke_family(state, session.id).await?; + metrics::counter!("auth_session_replays_total").increment(1); + audit::append( + &state.db, + &NewAuditEntry { + user_id: Some(session.user_id), + request_id, + action: AuditAction::SessionReplayDetected, + ip_address: ip, + metadata: json!({ + "session_id": session.id, + "reason": "reused_by_another_client", + }), + }, + ) + .await + .map_err(|e| AppError::Internal(e.into()))?; + return Err(AppError::TokenInvalid); + } RefreshVerdict::Expired => return Err(AppError::TokenExpired), RefreshVerdict::Replay => { - session_repo::revoke_family(&state.db, session.id) - .await - .map_err(|e| AppError::Internal(e.into()))?; + note_refresh_failure(state, ip).await; + revoke_family(state, session.id).await?; audit::append( &state.db, @@ -103,6 +124,7 @@ pub async fn refresh_token( return Err(AppError::TokenInvalid); } RefreshVerdict::AddressMismatch => { + note_refresh_failure(state, ip).await; metrics::counter!("auth_session_replays_total").increment(1); audit::append( &state.db, @@ -114,15 +136,18 @@ pub async fn refresh_token( metadata: json!({ "reason": "ip_mismatch", "session_id": session.id, - "expected_ip": session.ip_address.map(|n| n.ip().to_string()), - "actual_ip": ip.map(|n| n.ip().to_string()), + // No address in the metadata, which is never coarsened + // nor forgotten: the row's own address is the one used. + "same_network": same_network(session.ip_address, ip), }), }, ) .await .map_err(|e| AppError::Internal(e.into()))?; - return Err(AppError::Unauthorized); + // Answered like any refused token: a distinct answer would tell + // whoever stole it that it is still alive elsewhere. + return Err(AppError::TokenInvalid); } } @@ -159,6 +184,7 @@ pub async fn refresh_token( client_id: session.client_id.as_deref(), family_created_at: Some(session.family_created_at), scopes: None, + mfa: session.mfa, }, ) .await @@ -169,12 +195,11 @@ pub async fn refresh_token( // lock. Moments ago: the same client refreshing twice. if let Ok(Some(current)) = session_repo::find_by_id(&state.db, session.id).await && current.rotated_within(REFRESH_REUSE_GRACE, state.clock.now()) + && rotated_by_this_client(state, ¤t, ip, user_agent).await { return Err(AppError::TokenInvalid); } - session_repo::revoke_family(&state.db, session.id) - .await - .map_err(|e| AppError::Internal(e.into()))?; + revoke_family(state, session.id).await?; metrics::counter!("auth_session_replays_total").increment(1); @@ -200,6 +225,8 @@ pub async fn refresh_token( user.id, new_session.id, new_session.scopes.as_deref(), + new_session.client_id.as_deref(), + &new_session.session_type, state, ) .await?; @@ -211,6 +238,29 @@ pub async fn refresh_token( }) } +fn refresh_failure_key(ip: IpNetwork) -> String { + format!("refresh_fail:{}", ip_bucket(ip.ip())) +} + +/// Count a refused refresh (unknown token, another client's session, a replay, +/// another address) against the address, atomically with its window. +async fn note_refresh_failure(state: &AppState, ip: Option) { + let Some(ip) = ip else { return }; + let key = refresh_failure_key(ip); + if let Err(error) = redis_counter::consume( + &state.redis, + &[Budget { + key: &key, + limit: MAX_REFRESH_FAILURES_BY_IP, + window_secs: REFRESH_FAILURE_WINDOW_SECS, + }], + ) + .await + { + tracing::warn!(%error, "could not count a refused refresh"); + } +} + pub async fn logout( state: &AppState, session_id: Uuid, @@ -231,7 +281,7 @@ pub async fn logout( // Invalidate the Redis session cache so revocation propagates immediately // without waiting for SESSION_CACHE_TTL_SECS to expire. - invalidate_session_cache(state, session_id); + invalidate_session_cache(state, session_id).await; blocklist_jti(state, jti, token_exp).await; @@ -255,3 +305,62 @@ pub async fn logout( Ok(()) } + +/// Whether the rotation that replaced `session` came from the client now +/// presenting its token again: the same network and the same user agent. Only +/// then is a second use within the grace window a concurrent refresh; the +/// comparison does not depend on how precisely the clocks agree. +async fn rotated_by_this_client( + state: &AppState, + session: &Session, + ip: Option, + user_agent: Option<&str>, +) -> bool { + let Some(next_id) = session.replaced_by_session_id else { + return false; + }; + let Ok(Some(next)) = session_repo::find_by_id(&state.db, next_id).await else { + return false; + }; + let network_matches = match (next.ip_address, ip) { + (None, None) => true, + (a, b) => same_network(a, b) == Some(true), + }; + network_matches && next.user_agent.as_deref() == user_agent +} + +/// Whether two client addresses fall in the same network (/24 for IPv4, /48 +/// for IPv6): what an investigation needs of a replay, without the addresses. +fn same_network(a: Option, b: Option) -> Option { + let (a, b) = (a?.ip(), b?.ip()); + let prefix = |ip: std::net::IpAddr| -> Option { + let len = if ip.is_ipv4() { 24 } else { 48 }; + ipnetwork::IpNetwork::new(ip, len) + .ok() + .and_then(|n| ipnetwork::IpNetwork::new(n.network(), len).ok()) + }; + Some(prefix(a)? == prefix(b)?) +} + +#[cfg(test)] +mod same_network_tests { + use super::same_network; + + #[test] + fn addresses_compare_by_network() { + let net = |s: &str| Some(s.parse::().unwrap()); + assert_eq!( + same_network(net("203.0.113.7"), net("203.0.113.200")), + Some(true) + ); + assert_eq!( + same_network(net("203.0.113.7"), net("198.51.100.7")), + Some(false) + ); + assert_eq!( + same_network(net("2001:db8:1::1"), net("2001:db8:1:ff::2")), + Some(true) + ); + assert_eq!(same_network(None, net("203.0.113.7")), None); + } +} diff --git a/src/services/auth/tokens.rs b/src/services/auth/tokens.rs index 35f9088..fe61c18 100644 --- a/src/services/auth/tokens.rs +++ b/src/services/auth/tokens.rs @@ -9,6 +9,9 @@ pub(crate) struct SignIn<'a> { pub identifier: Option<&'a str>, pub request_id: Option, pub audit_metadata: serde_json::Value, + /// The sign-in proved a second factor (TOTP, email code, recovery code, + /// passkey with user verification). + pub second_factor: bool, } #[allow(clippy::too_many_arguments)] @@ -36,6 +39,9 @@ pub(crate) async fn issue_tokens( ); let device_name = device_name.and_then(crate::domain::session::device_label); + let mfa = sign_in + .as_ref() + .is_some_and(|sign_in| sign_in.second_factor); // The session and the sign-in records commit together: one round of // fsync instead of four, and no session without its audit trail. @@ -60,6 +66,7 @@ pub(crate) async fn issue_tokens( client_id, family_created_at: None, scopes, + mfa, }, ) .await @@ -137,7 +144,15 @@ pub(crate) async fn issue_tokens( // password login, an approved device, a 2FA challenge) has not re-proven // knowledge of the password for sensitive actions. Only an explicit // `POST /users/me/reauth` or a `current_password` in the request does. - let access_token = build_access_token(user_id, session.id, scopes, state).await?; + let access_token = build_access_token( + user_id, + session.id, + scopes, + session.client_id.as_deref(), + &session.session_type, + state, + ) + .await?; Ok(AuthTokens { access_token, @@ -190,6 +205,8 @@ pub(crate) async fn build_access_token( user_id: Uuid, session_id: uuid::Uuid, scopes: Option<&[String]>, + client_id: Option<&str>, + session_type: &SessionType, state: &AppState, ) -> Result { let issued_at = state.clock.now(); @@ -207,11 +224,36 @@ pub(crate) async fn build_access_token( // that client, re-evaluated against the user's current permissions on every // issue and refresh. Roles are dropped: a resource server authorizing by // role would otherwise grant more than the consent covered. - let (role_names, permission_names) = - crate::domain::registered_client::restrict_to_consent(role_names, permission_names, scopes); + // + // The consent is also narrowed to what the client may ask for today: an + // administrator taking a scope back from a client takes it back from the + // sessions it already holds, at their next refresh. + let client = match client_id { + Some(client_id) => crate::repositories::registered_client::find_by_id(&state.db, client_id) + .await + .map_err(|e| AppError::Internal(e.into()))?, + None => None, + }; + let current = client.as_ref().map(|client| client.scopes.clone()); + let narrowed = crate::domain::oauth::within_client_scopes(scopes, current.as_deref()); + let (role_names, permission_names) = crate::domain::registered_client::restrict_to_consent( + role_names, + permission_names, + narrowed.as_deref(), + ); + // A third-party client never carries the account's roles, even + // unrestricted: resource servers authorizing by role would treat it as the + // account. Only the instance's own application (the primary client) does. + let role_names = if client.as_ref().is_some_and(|client| !client.is_primary) { + Vec::new() + } else { + role_names + }; let mut claims = Claims::new(user_id, session_id, issued_at.unix_timestamp(), exp) .with_rbac(role_names, permission_names); + claims.client_id = client_id.map(str::to_owned); + claims.session_type = Some(session_type.as_str().to_owned()); // Stamp iss/aud so downstream resource servers can pin // the token to this issuer and to themselves. `aud` is emitted as a JSON // array so a single token can be accepted by multiple downstream services. @@ -299,7 +341,8 @@ pub async fn is_jti_blocked(state: &AppState, jti: Uuid) -> Result Result<(), AppError> { +) -> Result { let blocklist_key = format!("{JTI_BLOCKLIST_PREFIX}{jti}"); let cache_key = format!("{SESSION_CACHE_PREFIX}{session_id}"); @@ -335,10 +378,10 @@ pub async fn verify_token_state( AppError::ServiceUnavailable("redis_query_failed") })?; - let active = match token_state(blocked, cached) { + let (active, first_party) = match token_state(blocked, cached) { TokenState::Revoked => return Err(AppError::TokenInvalid), - TokenState::Active => true, - TokenState::Ended => false, + TokenState::Active { first_party } => (true, first_party), + TokenState::Ended => (false, false), TokenState::Unknown => { // A database failure is an outage (503), not a revoked session: a // 401 would sign the user out and hide the incident from the @@ -348,31 +391,36 @@ pub async fn verify_token_state( .map_err(|e| AppError::Internal(e.into()))? .ok_or(AppError::Unauthorized)?; let active = session.is_active(state.clock.now()); - let _: Result<(), _> = conn - .set_ex(&cache_key, u8::from(active), SESSION_CACHE_TTL_SECS) + let first_party = session.first_party(); + // NX: a revocation that raced this read left an "ended" marker, + // which must not be overwritten by the stale "active" just read. + let _: Result, _> = deadpool_redis::redis::cmd("SET") + .arg(&cache_key) + .arg(crate::domain::session::cached_value(active, first_party)) + .arg("NX") + .arg("EX") + .arg(SESSION_CACHE_TTL_SECS) + .query_async(&mut *conn) .await; - active + (active, first_party) } }; if active { - Ok(()) + Ok(first_party) } else { Err(AppError::Unauthorized) } } -/// Immediately invalidate the session validity cache entry. -/// Call this on explicit logout to ensure revocation takes effect without waiting for TTL expiry. -/// Best-effort: if Redis is unavailable, the cache expires naturally within SESSION_CACHE_TTL_SECS. -pub fn invalidate_session_cache(state: &AppState, session_id: Uuid) { - let redis = state.redis.clone(); - let key = format!("{SESSION_CACHE_PREFIX}{session_id}"); - crate::utils::background::spawn(async move { - if let Ok(mut conn) = redis.get().await { - let _: Result<(), _> = conn.del(&key).await; - } - }); +/// Mark a revoked session as ended in the validity cache, at once. An "ended" +/// marker rather than a deletion: a token check that read the session from the +/// database just before the revocation writes its result only if no marker is +/// there (`SET NX`), so it cannot put back a stale "active" for the cache TTL. +/// Best-effort: if Redis is unavailable, the cache expires within +/// SESSION_CACHE_TTL_SECS. +pub async fn invalidate_session_cache(state: &AppState, session_id: Uuid) { + invalidate_session_caches(state, &[session_id]).await; } pub async fn invalidate_session_caches(state: &AppState, session_ids: &[Uuid]) { @@ -381,10 +429,29 @@ pub async fn invalidate_session_caches(state: &AppState, session_ids: &[Uuid]) { } if let Ok(mut conn) = state.redis.get().await { - let keys: Vec = session_ids - .iter() - .map(|id| format!("{SESSION_CACHE_PREFIX}{id}")) - .collect(); - let _: Result<(), _> = conn.del(keys).await; + let mut pipe = deadpool_redis::redis::pipe(); + for id in session_ids { + pipe.set_ex( + format!("{SESSION_CACHE_PREFIX}{id}"), + crate::domain::session::CACHED_ENDED, + SESSION_CACHE_TTL_SECS, + ) + .ignore(); + } + let _: Result<(), _> = pipe.query_async(&mut *conn).await; } } + +/// Revoke every session of the family of `session_id` (a replayed refresh +/// token or authorization code) and mark them ended in the validity cache, so +/// their access tokens stop working now rather than when the cache expires. +pub async fn revoke_family(state: &AppState, session_id: Uuid) -> Result<(), AppError> { + session_repo::revoke_family(&state.db, session_id) + .await + .map_err(|e| AppError::Internal(e.into()))?; + let family = session_repo::family_session_ids(&state.db, session_id) + .await + .map_err(|e| AppError::Internal(e.into()))?; + invalidate_session_caches(state, &family).await; + Ok(()) +} diff --git a/src/services/authorize.rs b/src/services/authorize.rs index dc860e5..d137325 100644 --- a/src/services/authorize.rs +++ b/src/services/authorize.rs @@ -14,9 +14,10 @@ use ipnetwork::IpNetwork; use uuid::Uuid; use crate::{ - domain::{registered_client::RegisteredClient, session::SessionType}, + domain::{audit::AuditAction, registered_client::RegisteredClient, session::SessionType}, error::AppError, repositories::{ + audit::{self, NewAuditEntry}, authorization_code::{self as code_repo, NewAuthorizationCode}, client_quota as quota_repo, registered_client as client_repo, role as role_repo, session as session_repo, user as user_repo, @@ -56,6 +57,8 @@ pub struct Approval<'a> { pub requested: Option<&'a [String]>, /// OpenID Connect nonce, echoed in the ID token. pub nonce: Option<&'a str>, + /// When the approving user last proved their password. + pub auth_time: ::time::OffsetDateTime, pub current_password: Option<&'a str>, pub ip: Option, pub request_id: Option, @@ -102,12 +105,8 @@ pub async fn describe( } /// Whether approving for `client` needs a recent re-authentication first. -pub async fn requires_reauthentication( - state: &AppState, - session_id: Uuid, - client: &RegisteredClient, -) -> bool { - !client.is_primary && !reauth_svc::has_recent_reauth(state, session_id).await +pub async fn requires_reauthentication(state: &AppState, session_id: Uuid) -> bool { + !reauth_svc::has_recent_reauth(state, session_id).await } /// Mint a single-use code for an approval the user has just given. Returns the @@ -115,23 +114,22 @@ pub async fn requires_reauthentication( pub async fn approve(state: &AppState, approval: &Approval<'_>) -> Result { let client = approval.client; - // A third-party client obtains a long-lived session on the user's behalf: - // consenting to one requires a fresh proof of the password, exactly like - // other sensitive actions. The primary client is the instance's own app. - if !client.is_primary { - reauth_svc::require_recent_reauth_or_password( - state, - approval.user_id, - approval.session_id, - approval.current_password, - approval.ip, - approval.request_id, - "authorize_client", - ) - .await?; - } + // Every client obtains a long-lived session on the user's behalf: consenting + // requires a fresh proof of the password, the instance's own application + // included, exactly like other sensitive actions. + reauth_svc::require_recent_reauth_or_password( + state, + approval.user_id, + approval.session_id, + approval.current_password, + approval.ip, + approval.request_id, + "authorize_client", + ) + .await?; ensure_account_usable(state, approval.user_id).await?; + ensure_second_factor_for_primary(state, approval.user_id, approval.session_id, client).await?; // Scopes are frozen at consent: a later widening of the client's // registration must not widen what this approval grants. @@ -141,8 +139,15 @@ pub async fn approve(state: &AppState, approval: &Approval<'_>) -> Result) -> Result) -> Result, held: &[String]) -> Option> { +pub(crate) fn consent(requested: Option<&[String]>, held: &[String]) -> Option> { requested.map(|requested| { requested .iter() @@ -178,6 +202,11 @@ fn consent(requested: Option<&[String]>, held: &[String]) -> Option> /// The code is consumed before anything else is checked, so a failed attempt /// (wrong verifier, wrong redirect) ends the code instead of leaving it open to /// further guesses. Every refusal answers identically. +/// +/// Consumption, the session limit and the link from the code to its session +/// share one transaction, which holds the code's row until the session is +/// linked: a replay of the code waits for it, then finds the session to +/// revoke instead of a code with none yet. /// Returns the tokens and the OpenID Connect nonce of the request, if any. pub async fn redeem( state: &AppState, @@ -185,11 +214,17 @@ pub async fn redeem( ) -> Result<(auth_svc::AuthTokens, Option), AppError> { let hash = crypto::sha256(request.code.as_bytes()); - let Some(entry) = code_repo::consume(&state.db, &hash) + let mut tx = state + .db + .begin() + .await + .map_err(|e| AppError::Internal(e.into()))?; + let Some(entry) = code_repo::consume(&mut *tx, &hash) .await .map_err(|e| AppError::Internal(e.into()))? else { - revoke_on_replay(state, &hash).await; + drop(tx); + revoke_on_replay(state, &hash, request.client_id, request.verifier).await; return Err(AppError::InvalidAuthorizationCode); }; @@ -197,17 +232,33 @@ pub async fn redeem( || entry.redirect_uri != request.redirect_uri || !verifier_matches(&entry.code_challenge, request.verifier) { + // The failed attempt still burns the code. + tx.commit() + .await + .map_err(|e| AppError::Internal(e.into()))?; return Err(AppError::InvalidAuthorizationCode); } - let client = load_client(state, &entry.client_id).await?; - ensure_account_usable(state, entry.user_id).await?; - - let lock = lock_client_sessions(state, entry.user_id, &entry.client_id).await?; - - let (used, allowed) = session_allowance(state, entry.user_id, &client).await?; - if allowed.is_some_and(|allowed| used >= allowed) { - return Err(AppError::DeviceSessionLimitReached); + let checks = async { + let client = load_client(state, &entry.client_id).await?; + // A redirect URI removed from the client since the approval no longer + // redeems its code. + validate_redirect(&client, &entry.redirect_uri) + .map_err(|_| AppError::InvalidAuthorizationCode)?; + ensure_account_usable(state, entry.user_id).await?; + lock_client_sessions_in(&mut tx, entry.user_id, &entry.client_id).await?; + let (used, allowed) = session_allowance(state, entry.user_id, &client).await?; + if allowed.is_some_and(|allowed| used >= allowed) { + return Err(AppError::DeviceSessionLimitReached); + } + Ok(()) + } + .await; + if let Err(error) = checks { + tx.commit() + .await + .map_err(|e| AppError::Internal(e.into()))?; + return Err(error); } let tokens = auth_svc::issue_tokens( @@ -220,44 +271,114 @@ pub async fn redeem( SessionType::Device, Some(&entry.client_id), entry.scopes.as_deref(), - None, + // Handing a session to a client is a sign-in on the user's behalf: + // recorded, and announced like one from a new device. + Some(auth_svc::SignIn { + identifier: None, + request_id: None, + audit_metadata: serde_json::json!({ + "method": "authorization_code", + "client_id": entry.client_id, + }), + second_factor: false, + }), ) .await?; - lock.commit() + code_repo::attach_session(&mut *tx, entry.id, tokens.session.id) + .await + .map_err(|e| AppError::Internal(e.into()))?; + session_repo::set_auth_time(&mut *tx, tokens.session.id, entry.auth_time) + .await + .map_err(|e| AppError::Internal(e.into()))?; + let mut tokens = tokens; + tokens.session.auth_time = Some(entry.auth_time); + tx.commit() .await .map_err(|e| AppError::Internal(e.into()))?; - - if let Err(e) = code_repo::attach_session(&state.db, entry.id, tokens.session.id).await { - tracing::warn!(error = %e, "could not link the authorization code to its session"); - } Ok((tokens, entry.nonce)) } /// RFC 6749 section 4.1.2: a code presented again after redemption means it /// leaked; the tokens issued from it are revoked. -async fn revoke_on_replay(state: &AppState, code_hash: &[u8]) { +/// +/// Only when the code's own client presents it: a code seen in a Referer or a +/// history, replayed under another public client's id, must not end the +/// session of the client that redeemed it. Such a replay is logged. +async fn revoke_on_replay(state: &AppState, code_hash: &[u8], presented_by: &str, verifier: &str) { let Ok(Some(seen)) = code_repo::find(&state.db, code_hash).await else { return; }; if seen.consumed_at.is_none() { return; } + if seen.client_id != presented_by { + tracing::warn!( + client_id = %seen.client_id, + presented_by, + "authorization code replayed by another client; session left alone" + ); + return; + } + // A public client's id is anyone's to claim: a code leaked after its + // redemption (a Referer, a history) must not let a stranger sign its owner + // out. From a public client, only a replay proving the verifier - the + // client's own secret for this code - revokes. + let authenticated = client_repo::find_by_id(&state.db, presented_by) + .await + .ok() + .flatten() + .is_some_and(|client| client.is_confidential()); + if !authenticated && !verifier_matches(&seen.code_challenge, verifier) { + tracing::warn!( + client_id = %seen.client_id, + "authorization code replayed without its verifier; session left alone" + ); + return; + } tracing::warn!(client_id = %seen.client_id, "authorization code replayed after redemption"); if let Some(session_id) = seen.session_id - && let Err(e) = session_repo::revoke_family(&state.db, session_id).await + && let Err(e) = auth_svc::revoke_family(state, session_id).await { tracing::error!(error = %e, "could not revoke the session of a replayed code"); } } +/// The instance's own application receives first-party sessions: the most +/// rewarding approval to phish or to take over a session with. For an account +/// with a second factor, it is approved only from a session that proved one, +/// whatever the flow (authorization code or device). +pub(crate) async fn ensure_second_factor_for_primary( + state: &AppState, + user_id: Uuid, + session_id: Uuid, + client: &RegisteredClient, +) -> Result<(), AppError> { + if !client.is_primary + || !user_repo::has_second_factor(&state.db, user_id) + .await + .map_err(|e| AppError::Internal(e.into()))? + { + return Ok(()); + } + let proven = session_repo::find_by_id(&state.db, session_id) + .await + .map_err(|e| AppError::Internal(e.into()))? + .is_some_and(|session| session.mfa); + if proven { + Ok(()) + } else { + Err(AppError::SecondFactorSessionRequired) + } +} + async fn ensure_account_usable(state: &AppState, user_id: Uuid) -> Result<(), AppError> { let user = user_repo::find_by_id(&state.db, user_id) .await .map_err(|e| AppError::Internal(e.into()))? .ok_or(AppError::Unauthorized)?; - auth_svc::ensure_account_usable(&user, state.clock.now()) + auth_svc::ensure_account_usable(&user) } /// RFC 7636 section 4.2: S256 only, a 43-character base64url SHA-256 digest. @@ -295,7 +416,7 @@ pub(crate) fn verifier_matches(challenge: &str, verifier: &str) -> bool { } fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { - a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 + crypto::constant_time_eq(a, b) } /// Accept a redirect URI registered for the client, exactly; or, for a client @@ -361,12 +482,22 @@ pub(crate) async fn lock_client_sessions( .begin() .await .map_err(|e| AppError::Internal(e.into()))?; + lock_client_sessions_in(&mut lock, user_id, client_id).await?; + Ok(lock) +} + +/// [`lock_client_sessions`] inside a transaction the caller already holds. +async fn lock_client_sessions_in( + tx: &mut sqlx::PgConnection, + user_id: Uuid, + client_id: &str, +) -> Result<(), AppError> { sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))") .bind(format!("client_sessions:{user_id}:{client_id}")) - .execute(&mut *lock) + .execute(&mut *tx) .await .map_err(|e| AppError::Internal(e.into()))?; - Ok(lock) + Ok(()) } /// Sessions the user holds for the client, and how many the client allows. @@ -394,7 +525,10 @@ pub(crate) async fn load_client( .ok_or(AppError::DeviceClientUnknown) } -async fn permission_names(state: &AppState, user_id: Uuid) -> Result, AppError> { +pub(crate) async fn permission_names( + state: &AppState, + user_id: Uuid, +) -> Result, AppError> { Ok(role_repo::find_permissions_by_user(&state.db, user_id) .await .map_err(|e| AppError::Internal(e.into()))? @@ -419,6 +553,7 @@ mod tests { default_max_sessions: 5, client_secret_hash: None, allows_client_credentials: false, + allows_introspection: false, } } diff --git a/src/services/captcha.rs b/src/services/captcha.rs index 061c08e..cf93055 100644 --- a/src/services/captcha.rs +++ b/src/services/captcha.rs @@ -18,12 +18,19 @@ use crate::{ #[derive(Deserialize)] struct HCaptchaResponse { success: bool, + /// Site the challenge was solved on. + #[serde(default)] + hostname: Option, } /// Verifies a CAPTCHA token against the hCaptcha API. /// Returns Ok(()) if verification succeeds or if CAPTCHA is not configured. /// Returns Err(AppError::CaptchaFailed) if the token is invalid. -pub async fn verify(state: &AppState, token: &str) -> Result<(), AppError> { +pub async fn verify( + state: &AppState, + token: &str, + remote_ip: Option, +) -> Result<(), AppError> { let config = &state.config.captcha; let secret = match config.secret.as_deref() { @@ -36,7 +43,7 @@ pub async fn verify(state: &AppState, token: &str) -> Result<(), AppError> { return Err(AppError::CaptchaFailed); } - let upstream = ask_upstream(state, secret, token).await; + let upstream = ask_upstream(state, secret, token, remote_ip).await; match captcha_verdict(upstream, config.fail_open_on_error) { CaptchaVerdict::Accepted => { if !matches!(upstream, CaptchaUpstream::Answered { .. }) { @@ -49,12 +56,54 @@ pub async fn verify(state: &AppState, token: &str) -> Result<(), AppError> { } } -/// Ask the verification endpoint about `token`. -async fn ask_upstream(state: &AppState, secret: &str, token: &str) -> CaptchaUpstream { +/// Whether a challenge solved on `hostname` counts for this deployment: one of +/// `CAPTCHA_EXPECTED_HOSTNAMES`, or the host of `FRONTEND_URL` when none is +/// set. A provider that names no hostname is taken at its word. +fn solved_here(state: &AppState, hostname: Option<&str>) -> bool { + let Some(hostname) = hostname else { + return true; + }; + let expected = &state.config.captcha.expected_hostnames; + let accepted = if expected.is_empty() { + reqwest::Url::parse(&state.config.server.frontend_url) + .ok() + .and_then(|url| { + url.host_str() + .map(|host| host.eq_ignore_ascii_case(hostname)) + }) + .unwrap_or(false) + } else { + expected + .iter() + .any(|host| host.eq_ignore_ascii_case(hostname)) + }; + if !accepted { + tracing::warn!(hostname, "captcha solved on an unexpected hostname"); + } + accepted +} + +/// Ask the verification endpoint about `token`, with the client's address and +/// the widget's site key: a token solved elsewhere, or for another site key, +/// is refused. +async fn ask_upstream( + state: &AppState, + secret: &str, + token: &str, + remote_ip: Option, +) -> CaptchaUpstream { + let remote_ip = remote_ip.map(|ip| ip.ip().to_string()); + let mut form = vec![("secret", secret), ("response", token)]; + if let Some(ip) = remote_ip.as_deref() { + form.push(("remoteip", ip)); + } + if let Some(site_key) = state.config.captcha.site_key.as_deref() { + form.push(("sitekey", site_key)); + } let response = match state .http_client .post(&state.config.captcha.verify_url) - .form(&[("secret", secret), ("response", token)]) + .form(&form) .send() .await { @@ -72,7 +121,7 @@ async fn ask_upstream(state: &AppState, secret: &str, token: &str) -> CaptchaUps match response.json::().await { Ok(body) => CaptchaUpstream::Answered { - success: body.success, + success: body.success && solved_here(state, body.hostname.as_deref()), }, Err(error) => { tracing::warn!(%error, "captcha response could not be parsed"); diff --git a/src/services/cleanup.rs b/src/services/cleanup.rs index c112780..43d55f7 100644 --- a/src/services/cleanup.rs +++ b/src/services/cleanup.rs @@ -120,12 +120,13 @@ async fn run_all(conn: &mut PgConnection, config: &Config) { "SELECT cleanup_published_events($1::interval, $2)", "7 days".to_owned(), ), - // TOTP replay-guard rows live ~90 s (one step of skew on each side); - // the repository already self-cleans per user, this sweeps leftovers. + // TOTP replay-guard rows are needed for TOTP_REPLAY_WINDOW_SECS; the + // repository already self-cleans per user, this sweeps leftovers with + // a wide margin for clocks that disagree. ( "cleanup_used_totp_codes", "SELECT cleanup_used_totp_codes($1::interval, $2)", - "90 seconds".to_owned(), + "10 minutes".to_owned(), ), ]; diff --git a/src/services/device.rs b/src/services/device.rs index b35dcf4..bd3c99b 100644 --- a/src/services/device.rs +++ b/src/services/device.rs @@ -20,6 +20,7 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::{ + domain::audit::AuditAction, domain::{ device::{PollOutcome, poll_outcome}, registered_client::RegisteredClient, @@ -27,7 +28,10 @@ use crate::{ }, error::AppError, middleware::rate_limit::ip_bucket, - repositories::{registered_client as client_repo, user as user_repo}, + repositories::{ + audit::{self, NewAuditEntry}, + registered_client as client_repo, user as user_repo, + }, services::{auth as auth_svc, authorize as authorize_svc}, state::AppState, utils::{ @@ -81,6 +85,10 @@ struct DeviceAuthState { /// Scopes the client asked for (`None`: unrestricted). #[serde(default)] scopes: Option>, + /// When the approving session proved the password (unix seconds): the + /// `auth_time` of the session the device receives. + #[serde(default)] + auth_time: Option, } /// RFC 8628 section 3.2. @@ -106,6 +114,30 @@ pub struct DevicePreview { pub requested_from_ip: Option, pub user_agent: Option, pub created_at: i64, + /// Approving needs `current_password` (or a recent `POST /users/me/reauth`). + /// Every device approval does: typing a code someone handed over is how a + /// device flow is phished. + pub reauthentication_required: bool, + /// Permissions the approval would grant: the scopes asked for, intersected + /// with the user's. Empty with `unrestricted` when none were asked of a + /// client without scopes, in which case the device acts as the account. + pub scopes: Vec, + pub unrestricted: bool, + /// Scopes asked for that this user does not hold. + pub unavailable_scopes: Vec, + pub sessions_used: i64, + /// `None`: no session limit applies to this user and client. + pub sessions_allowed: Option, +} + +/// A signed-in user approving a device code. +pub struct Approval<'a> { + pub user_id: Uuid, + pub session_id: Uuid, + pub user_code: &'a str, + pub current_password: Option<&'a str>, + pub ip: Option, + pub request_id: Option, } fn device_hash_encoded(device_code: &str) -> String { @@ -124,18 +156,13 @@ fn uc_key(user_code: &str) -> String { /// Generate a short, human-readable user code in the format "XXXX-XXXX". /// Uses uppercase letters (excluding ambiguous O, I, L) and digits (excluding 0, 1). fn generate_user_code() -> String { - use rand::RngExt; - const LETTERS: &[u8] = b"ABCDEFGHJKMNPQRSTUVWXYZ"; const DIGITS: &[u8] = b"23456789"; - let mut rng = rand::rng(); - let part1: String = (0..4) - .map(|_| LETTERS[rng.random_range(0..LETTERS.len())] as char) - .collect(); - let part2: String = (0..4) - .map(|_| DIGITS[rng.random_range(0..DIGITS.len())] as char) - .collect(); + let pick = + |alphabet: &[u8]| alphabet[crypto::random_below(alphabet.len() as u32) as usize] as char; + let part1: String = (0..4).map(|_| pick(LETTERS)).collect(); + let part2: String = (0..4).map(|_| pick(DIGITS)).collect(); format!("{part1}-{part2}") } @@ -209,6 +236,7 @@ pub async fn initiate( user_agent: user_agent.map(str::to_owned), created_at: state.clock.now().unix_timestamp(), scopes, + auth_time: None, }; let entry_json = serde_json::to_string(&entry).map_err(|e| AppError::Internal(e.into()))?; @@ -300,12 +328,15 @@ pub async fn poll( .await .map_err(|e| AppError::Internal(e.into()))? .ok_or(AppError::DeviceAccessDenied)?; - auth_svc::ensure_account_usable(&user, state.clock.now())?; + auth_svc::ensure_account_usable(&user)?; // Held until the session exists: concurrent approvals for this user // and client count their sessions one at a time. - let lock = authorize_svc::lock_client_sessions(state, user_id, client_id).await?; + let mut lock = authorize_svc::lock_client_sessions(state, user_id, client_id).await?; let (used, allowed) = authorize_svc::session_allowance(state, user_id, &client).await?; + // Devices of the instance's own application are counted too: its + // default quota applies unless the user has one of their own. + let allowed = allowed.or(Some(i64::from(client.default_max_sessions))); if allowed.is_some_and(|allowed| used >= allowed) { return Err(AppError::DeviceSessionLimitReached); } @@ -333,9 +364,28 @@ pub async fn poll( SessionType::Device, Some(client_id), scopes, - None, + // Recorded and announced like a sign-in from a new device. + Some(auth_svc::SignIn { + identifier: None, + request_id: None, + audit_metadata: serde_json::json!({ + "method": "device_authorization", + "client_id": client_id, + }), + second_factor: false, + }), ) .await?; + let mut tokens = tokens; + if let Some(proven) = entry + .auth_time + .and_then(|t| time::OffsetDateTime::from_unix_timestamp(t).ok()) + { + crate::repositories::session::set_auth_time(&mut *lock, tokens.session.id, proven) + .await + .map_err(|e| AppError::Internal(e.into()))?; + tokens.session.auth_time = Some(proven); + } lock.commit() .await .map_err(|e| AppError::Internal(e.into()))?; @@ -346,28 +396,49 @@ pub async fn poll( } /// Refuse an address that has been asking after codes that do not exist. -async fn guard_code_scan(state: &AppState, ip: Option) -> Result<(), AppError> { - let Some(ip) = ip else { return Ok(()) }; - let key = format!("{DEVICE_SCAN_PREFIX}{}", ip_bucket(ip.ip())); - if redis_counter::peek(&state.redis, &key).await? >= MAX_UNKNOWN_CODES_BY_IP { - return Err(AppError::RateLimitExceeded); +async fn guard_code_scan( + state: &AppState, + ip: Option, + user_id: Uuid, +) -> Result<(), AppError> { + for key in scan_keys(ip, user_id) { + if redis_counter::peek(&state.redis, &key).await? >= MAX_UNKNOWN_CODES_BY_IP { + return Err(AppError::RateLimitExceeded); + } } Ok(()) } +/// Misses are counted per client address and per signed-in user: an address +/// alone would not bound a user behind a proxy that forwards none. +fn scan_keys(ip: Option, user_id: Uuid) -> Vec { + let mut keys = vec![format!("{DEVICE_SCAN_PREFIX}user:{user_id}")]; + if let Some(ip) = ip { + keys.push(format!("{DEVICE_SCAN_PREFIX}{}", ip_bucket(ip.ip()))); + } + keys +} + /// Count a lookup of a code that is not live. Only misses are counted: a /// legitimate approval resolves on the first try. -async fn note_unknown_code(state: &AppState, ip: Option) { - let Some(ip) = ip else { return }; - let key = format!("{DEVICE_SCAN_PREFIX}{}", ip_bucket(ip.ip())); - let budget = Budget { - key: &key, - limit: MAX_UNKNOWN_CODES_BY_IP, - window_secs: SCAN_WINDOW_SECS, - }; - if let Err(e) = redis_counter::consume(&state.redis, &[budget]).await { - tracing::warn!(error = %e, "could not record an unknown device code lookup"); - } +async fn note_unknown_code( + state: &AppState, + ip: Option, + user_id: Uuid, +) -> Result<(), AppError> { + let keys = scan_keys(ip, user_id); + let budgets: Vec = keys + .iter() + .map(|key| Budget { + key, + limit: MAX_UNKNOWN_CODES_BY_IP, + window_secs: SCAN_WINDOW_SECS, + }) + .collect(); + // A miss that cannot be counted is refused: the budget is what keeps the + // code space from being searched. + redis_counter::consume(&state.redis, &budgets).await?; + Ok(()) } /// Load the live entry a user code points at: `(device key, raw json, entry)`. @@ -396,55 +467,130 @@ async fn load_entry( Ok(Some((dk, raw, entry))) } -/// Describe a pending device authorization to the user about to decide on it. +/// Describe a pending device authorization to the user about to decide on it: +/// who asks, from where, and how much of the account the device would get. pub async fn describe( state: &AppState, + user_id: Uuid, + session_id: Uuid, user_code: &str, ip: Option, ) -> Result { - guard_code_scan(state, ip).await?; + guard_code_scan(state, ip, user_id).await?; let Some((_, _, entry)) = load_entry(state, user_code).await? else { - note_unknown_code(state, ip).await; + note_unknown_code(state, ip, user_id).await?; return Err(AppError::NotFound); }; if !entry.status.is_undecided() { return Err(AppError::Conflict("device_request_already_decided")); } - let client_name = match entry.client_id.as_deref() { + let client = match entry.client_id.as_deref() { Some(cid) => client_repo::find_by_id(&state.db, cid) .await - .map_err(|e| AppError::Internal(e.into()))? - .map(|client| client.display_name), + .map_err(|e| AppError::Internal(e.into()))?, + None => None, + }; + let reauthentication_required = + !crate::services::reauth::has_recent_reauth(state, session_id).await; + let consent = match &client { + Some(client) => { + Some(authorize_svc::describe(state, user_id, client, entry.scopes.as_deref()).await?) + } None => None, }; Ok(DevicePreview { user_code: entry.user_code, client_id: entry.client_id, - client_name, + client_name: client.map(|client| client.display_name), requested_from_ip: entry.client_ip, user_agent: entry.user_agent, created_at: entry.created_at, + reauthentication_required, + scopes: consent + .as_ref() + .map(|c| c.scopes.clone()) + .unwrap_or_default(), + unrestricted: consent.as_ref().is_some_and(|c| c.unrestricted), + unavailable_scopes: consent + .as_ref() + .map(|c| c.unavailable_scopes.clone()) + .unwrap_or_default(), + sessions_used: consent.as_ref().map_or(0, |c| c.sessions_used), + sessions_allowed: consent.and_then(|c| c.sessions_allowed), }) } -/// Approve a device authorization request. Called by an authenticated user. -pub async fn verify( - state: &AppState, - user_id: Uuid, - user_code: &str, - ip: Option, -) -> Result<(), AppError> { - update_status( +/// Approve a device authorization request. Called by a signed-in user. +/// +/// The approval mints a long-lived session for the client, possibly acting as +/// the account: it always needs a fresh proof of the password, the instance's +/// own application included. A code handed over by someone else is how a +/// device flow is phished, and the password is where the user notices. +pub async fn verify(state: &AppState, approval: &Approval<'_>) -> Result<(), AppError> { + guard_code_scan(state, approval.ip, approval.user_id).await?; + let Some((_, _, pending)) = load_entry(state, approval.user_code).await? else { + note_unknown_code(state, approval.ip, approval.user_id).await?; + return Err(AppError::NotFound); + }; + // The instance's own application receives a first-party session: the most + // rewarding code to phish. An account with a second factor approves it + // only from a session that proved one. + if let Some(client_id) = pending.client_id.as_deref() + && let Some(client) = client_repo::find_by_id(&state.db, client_id) + .await + .map_err(|e| AppError::Internal(e.into()))? + { + authorize_svc::ensure_second_factor_for_primary( + state, + approval.user_id, + approval.session_id, + &client, + ) + .await?; + } + crate::services::reauth::require_recent_reauth_or_password( state, - user_code, + approval.user_id, + approval.session_id, + approval.current_password, + approval.ip, + approval.request_id, + "approve_device", + ) + .await?; + let auth_time = crate::services::reauth::reauth_proven_at(state, approval.session_id).await; + let entry = update_status( + state, + approval.user_code, DeviceAuthStatus::Authorized, - Some(user_id), - ip, + approval.user_id, + approval.ip, + auth_time, + ) + .await?; + // The owner's history names the client and the device it was approved + // for: a phished code shows there, with the address that asked for it. + audit::append( + &state.db, + &NewAuditEntry { + user_id: Some(approval.user_id), + request_id: approval.request_id, + action: AuditAction::DeviceApproved, + ip_address: approval.ip, + metadata: serde_json::json!({ + "client_id": entry.client_id, + "scopes": entry.scopes, + "device_address": entry.client_ip, + "device_user_agent": entry.user_agent, + }), + }, ) .await + .map_err(|e| AppError::Internal(e.into()))?; + Ok(()) } /// Deny a device authorization request. Called by an authenticated user. @@ -453,31 +599,67 @@ pub async fn verify( /// other people's flows is that they cannot find the codes (`guard_code_scan`). pub async fn deny( state: &AppState, + user_id: Uuid, user_code: &str, ip: Option, ) -> Result<(), AppError> { - update_status(state, user_code, DeviceAuthStatus::Denied, None, ip).await + let entry = update_status( + state, + user_code, + DeviceAuthStatus::Denied, + user_id, + ip, + None, + ) + .await?; + // Anyone who finds a code can refuse it: the refusal is traced in the + // refuser's history, with the client and the device that asked. + audit::append( + &state.db, + &NewAuditEntry { + user_id: Some(user_id), + request_id: None, + action: AuditAction::DeviceDenied, + ip_address: ip, + metadata: serde_json::json!({ + "client_id": entry.client_id, + "device_address": entry.client_ip, + }), + }, + ) + .await + .map_err(|e| AppError::Internal(e.into()))?; + Ok(()) } +/// Decide the request as `user_id`; an approval records who approved it. async fn update_status( state: &AppState, user_code: &str, new_status: DeviceAuthStatus, - user_id: Option, + user_id: Uuid, ip: Option, -) -> Result<(), AppError> { - guard_code_scan(state, ip).await?; + auth_time: Option, +) -> Result { + guard_code_scan(state, ip, user_id).await?; let Some((dk, raw, mut entry)) = load_entry(state, user_code).await? else { - note_unknown_code(state, ip).await; + note_unknown_code(state, ip, user_id).await?; return Err(AppError::NotFound); }; if !entry.status.is_undecided() { return Err(AppError::Conflict("device_request_already_decided")); } + entry.user_id = (new_status == DeviceAuthStatus::Authorized).then_some(user_id); + // The consent is what the user held when approving, as in the code flow: + // a permission obtained later does not reach the device without a new one. + if new_status == DeviceAuthStatus::Authorized && entry.scopes.is_some() { + let held = authorize_svc::permission_names(state, user_id).await?; + entry.scopes = authorize_svc::consent(entry.scopes.as_deref(), &held); + } entry.status = new_status; - entry.user_id = user_id; + entry.auth_time = auth_time; let updated = serde_json::to_string(&entry).map_err(|e| AppError::Internal(e.into()))?; let mut conn = state @@ -496,7 +678,7 @@ async fn update_status( if swapped != 1 { return Err(AppError::Conflict("device_request_already_decided")); } - Ok(()) + Ok(entry) } #[cfg(test)] @@ -541,6 +723,7 @@ mod tests { user_agent: Some("MyApp/1.0".into()), created_at: 1700000000, scopes: None, + auth_time: None, }; let json = serde_json::to_string(&state).unwrap(); diff --git a/src/services/email.rs b/src/services/email.rs index 76ff82e..afbb633 100644 --- a/src/services/email.rs +++ b/src/services/email.rs @@ -28,12 +28,26 @@ const TNAME_PASSWORD_RESET: &str = "password_reset"; const TNAME_EMAIL_OTP: &str = "email_otp"; const TNAME_PASSWORD_CHANGED: &str = "password_changed"; const TNAME_TWO_FACTOR_DISABLED: &str = "two_factor_disabled"; +const TNAME_ACCOUNT_LOCKED: &str = "account_locked"; +const TNAME_CHANGED_BY_ADMINISTRATOR: &str = "changed_by_administrator"; +const TNAME_SECOND_FACTOR_ATTEMPTS: &str = "second_factor_attempts"; const TNAME_TWO_FACTOR_ENABLED: &str = "two_factor_enabled"; const TNAME_ACCOUNT_EXISTS: &str = "account_exists"; const TNAME_EMAIL_CHANGED: &str = "email_changed"; const TNAME_RECOVERY_CODE_USED: &str = "recovery_code_used"; const TNAME_NEW_DEVICE_LOGIN: &str = "new_device_login"; const TNAME_MAGIC_LINK: &str = "magic_link"; +const TNAME_ACCESS_ADDED: &str = "access_added"; +const TNAME_EMAIL_CHANGE_NEW_OTP: &str = "email_change_new_otp"; + +/// A way into an account other than its password, as the notifications list +/// it: `kind` is `passkey`, `totp`, `email`, `personal_access_token` or +/// `external_identity`; `name` is its label (passkey or token name, provider). +#[derive(Debug, Clone, serde::Serialize)] +pub struct AccessItem { + pub kind: &'static str, + pub name: String, +} /// Notifications waiting or being sent, past which new ones are dropped: a slow /// or unreachable relay must not pile up tasks in step with traffic. @@ -156,6 +170,38 @@ pub async fn send_password_reset_email( send(mailer, &mail_cfg.smtp, to_email, username, &subject, body).await } +/// The code confirming a new address, sent to that address: its holder may +/// have no account and did not ask, so the message says so and names no one. +pub async fn send_email_change_new_otp( + mailer: &Mailer, + templates: &Tera, + mail_cfg: &MailConfig, + to_email: &str, + locale: &str, + code: &str, +) -> Result<(), AppError> { + let mut ctx = Context::new(); + ctx.insert("code", code); + ctx.insert("expires_in_minutes", &15i32); + ctx.insert("app_name", &mail_cfg.smtp.from_name); + + let body = render_with_fallback( + templates, + TNAME_EMAIL_CHANGE_NEW_OTP, + locale, + &mail_cfg.default_locale, + &ctx, + )?; + let subject = render_subject( + templates, + TNAME_EMAIL_CHANGE_NEW_OTP, + locale, + &mail_cfg.default_locale, + &ctx, + )?; + send(mailer, &mail_cfg.smtp, to_email, "", &subject, body).await +} + pub async fn send_email_change_otp( mailer: &Mailer, templates: &Tera, @@ -220,6 +266,9 @@ pub async fn send_email_otp( send(mailer, &mail_cfg.smtp, to_email, username, &subject, body).await } +/// The password changed or was reset. `access` lists what still opens the +/// account without it, so an owner taking the account back sees what someone +/// else may have added while holding the password. pub async fn send_password_changed( mailer: &Mailer, templates: &Tera, @@ -227,10 +276,14 @@ pub async fn send_password_changed( to_email: &str, username: &str, locale: &str, + access: &[AccessItem], + removed: &[AccessItem], ) -> Result<(), AppError> { let mut ctx = Context::new(); ctx.insert("username", username); ctx.insert("app_name", &mail_cfg.smtp.from_name); + ctx.insert("access", access); + ctx.insert("removed", removed); let body = render_with_fallback( templates, @@ -249,6 +302,41 @@ pub async fn send_password_changed( send(mailer, &mail_cfg.smtp, to_email, username, &subject, body).await } +/// A way into the account was added: a passkey, a personal access token or an +/// external identity. Whoever holds the password can add one, and it outlives a +/// password reset: the owner must hear of it. +pub async fn send_access_added( + mailer: &Mailer, + templates: &Tera, + mail_cfg: &MailConfig, + to_email: &str, + username: &str, + locale: &str, + access: &AccessItem, +) -> Result<(), AppError> { + let mut ctx = Context::new(); + ctx.insert("username", username); + ctx.insert("app_name", &mail_cfg.smtp.from_name); + ctx.insert("kind", access.kind); + ctx.insert("name", &access.name); + + let body = render_with_fallback( + templates, + TNAME_ACCESS_ADDED, + locale, + &mail_cfg.default_locale, + &ctx, + )?; + let subject = render_subject( + templates, + TNAME_ACCESS_ADDED, + locale, + &mail_cfg.default_locale, + &ctx, + )?; + send(mailer, &mail_cfg.smtp, to_email, username, &subject, body).await +} + /// Mask an address for display in a notification: `j***@example.com`. pub fn mask_email(email: &str) -> String { match email.split_once('@') { @@ -487,6 +575,103 @@ pub async fn send_recovery_code_used( send(mailer, &mail_cfg.smtp, to_email, username, &subject, body).await } +pub async fn send_account_locked( + mailer: &Mailer, + templates: &Tera, + mail_cfg: &MailConfig, + to_email: &str, + username: &str, + locale: &str, + minutes: i64, +) -> Result<(), AppError> { + let mut ctx = Context::new(); + ctx.insert("username", username); + ctx.insert("minutes", &minutes); + ctx.insert("app_name", &mail_cfg.smtp.from_name); + + let body = render_with_fallback( + templates, + TNAME_ACCOUNT_LOCKED, + locale, + &mail_cfg.default_locale, + &ctx, + )?; + let subject = render_subject( + templates, + TNAME_ACCOUNT_LOCKED, + locale, + &mail_cfg.default_locale, + &ctx, + )?; + send(mailer, &mail_cfg.smtp, to_email, username, &subject, body).await +} + +/// `change` is one of `suspended`, `reactivated`, `role_granted`, +/// `role_revoked`, `role_extended`, `unlocked`, `access_removed` or +/// `sessions_revoked`; `role` names the role for the three role changes. +#[allow(clippy::too_many_arguments)] +pub async fn send_changed_by_administrator( + mailer: &Mailer, + templates: &Tera, + mail_cfg: &MailConfig, + to_email: &str, + username: &str, + locale: &str, + change: &str, + role: Option<&str>, +) -> Result<(), AppError> { + let mut ctx = Context::new(); + ctx.insert("username", username); + ctx.insert("change", change); + ctx.insert("role", &role.unwrap_or_default()); + ctx.insert("app_name", &mail_cfg.smtp.from_name); + + let body = render_with_fallback( + templates, + TNAME_CHANGED_BY_ADMINISTRATOR, + locale, + &mail_cfg.default_locale, + &ctx, + )?; + let subject = render_subject( + templates, + TNAME_CHANGED_BY_ADMINISTRATOR, + locale, + &mail_cfg.default_locale, + &ctx, + )?; + send(mailer, &mail_cfg.smtp, to_email, username, &subject, body).await +} + +pub async fn send_second_factor_attempts( + mailer: &Mailer, + templates: &Tera, + mail_cfg: &MailConfig, + to_email: &str, + username: &str, + locale: &str, +) -> Result<(), AppError> { + let mut ctx = Context::new(); + ctx.insert("username", username); + ctx.insert("app_name", &mail_cfg.smtp.from_name); + + let body = render_with_fallback( + templates, + TNAME_SECOND_FACTOR_ATTEMPTS, + locale, + &mail_cfg.default_locale, + &ctx, + )?; + let subject = render_subject( + templates, + TNAME_SECOND_FACTOR_ATTEMPTS, + locale, + &mail_cfg.default_locale, + &ctx, + )?; + send(mailer, &mail_cfg.smtp, to_email, username, &subject, body).await +} + // Tries locale template first, falls back to default_locale. fn render_with_fallback( templates: &Tera, @@ -559,7 +744,7 @@ async fn send( mod tests { use super::*; - const ALL_TEMPLATES: [&str; 10] = [ + const ALL_TEMPLATES: [&str; 14] = [ TNAME_VERIFICATION, TNAME_EMAIL_CHANGE_OTP, TNAME_PASSWORD_RESET, @@ -570,6 +755,10 @@ mod tests { TNAME_ACCOUNT_EXISTS, TNAME_EMAIL_CHANGED, TNAME_RECOVERY_CODE_USED, + TNAME_NEW_DEVICE_LOGIN, + TNAME_MAGIC_LINK, + TNAME_ACCESS_ADDED, + TNAME_EMAIL_CHANGE_NEW_OTP, ]; #[test] diff --git a/src/services/email_2fa.rs b/src/services/email_2fa.rs index 6adee4f..0e7a772 100644 --- a/src/services/email_2fa.rs +++ b/src/services/email_2fa.rs @@ -9,7 +9,6 @@ //! 2. send_code -- sends the first code so the user can confirm their email //! 3. verify_setup -- validates the code, marks the method verified, primary if first -use deadpool_redis::redis::AsyncCommands; use ipnetwork::IpNetwork; use serde_json::json; use uuid::Uuid; @@ -101,8 +100,10 @@ pub async fn verify_setup( verify_otp( state, user_id, + None, submitted_code, &format!("email2fa_setup_fail:{}", method_id), + None, ) .await?; @@ -169,14 +170,38 @@ pub async fn disable( // Code dispatch (used both during setup and during login challenge) -/// Generates and sends a 6-digit OTP to the user's email. -/// Enforces a 60-second cooldown between sends. -pub async fn send_code(state: &AppState, user_id: Uuid) -> Result<(), AppError> { - // Anti-spam cooldown - let cooldown_key = format!("email2fa_cd:{}", user_id); - if let Ok(mut conn) = state.redis.get().await { - let active: bool = conn.exists(&cooldown_key).await.unwrap_or(false); - if active { +/// Generates and sends a 6-digit OTP to the user's email, for a sign-in +/// `challenge` (its pre-auth token) or, without one, for confirming the +/// method. A challenge's code completes that challenge only: another one, +/// opened by whoever else holds the password, cannot use it. +/// Enforces a 60-second cooldown between sends (per challenge at sign-in, where +/// the account also has an hourly budget of codes). +pub async fn send_code( + state: &AppState, + user_id: Uuid, + challenge: Option<&str>, +) -> Result<(), AppError> { + // Anti-spam cooldown, claimed before the send: concurrent requests cannot + // all find it free and all send. + let cooldown_key = match challenge { + Some(token) => format!("email2fa_cd:{user_id}:{}", super::auth::challenge_id(token)), + None => format!("email2fa_cd:{user_id}"), + }; + if !redis_counter::claim_cooldown(&state.redis, &cooldown_key, SEND_COOLDOWN_SECS).await { + return Err(AppError::RateLimitExceeded); + } + if challenge.is_some() { + let account_key = format!("email2fa_send_user:{user_id}"); + let attempt = redis_counter::consume( + &state.redis, + &[Budget { + key: &account_key, + limit: MAX_SIGN_IN_CODES_PER_HOUR, + window_secs: USER_FAILURE_WINDOW_SECS, + }], + ) + .await?; + if attempt.exceeded { return Err(AppError::RateLimitExceeded); } } @@ -187,7 +212,10 @@ pub async fn send_code(state: &AppState, user_id: Uuid) -> Result<(), AppError> .ok_or(AppError::Unauthorized)?; let code = crypto::generate_otp(); - let hash = crypto::sha256(code.as_bytes()); + // A keyed digest: a copy of the table does not give live codes away. + let hash = state + .keyring + .otp_digest(OTP_PURPOSE, &code_subject(user_id, challenge), &code); email_2fa::create( &state.db, @@ -200,11 +228,6 @@ pub async fn send_code(state: &AppState, user_id: Uuid) -> Result<(), AppError> .await .map_err(|e| AppError::Internal(e.into()))?; - // Set cooldown after successful DB write - if let Ok(mut conn) = state.redis.get().await { - let _: Result<(), _> = conn.set_ex(&cooldown_key, 1u8, SEND_COOLDOWN_SECS).await; - } - let mailer = state.mailer.clone(); let templates = state.templates.clone(); let mail_cfg = state.config.mail.clone(); @@ -236,9 +259,38 @@ pub async fn verify_login_code( user_id: Uuid, pre_auth_token: &str, submitted_code: &str, + ip: Option, ) -> Result<(), AppError> { - let fail_key = format!("{}{pre_auth_token}", super::auth::EMAIL_2FA_FAIL_PREFIX); - verify_otp(state, user_id, submitted_code, &fail_key).await + let fail_key = format!( + "{}{}", + super::auth::EMAIL_2FA_FAIL_PREFIX, + super::auth::challenge_id(pre_auth_token) + ); + verify_otp( + state, + user_id, + Some(pre_auth_token), + submitted_code, + &fail_key, + ip, + ) + .await +} + +/// Separates the digests of these codes from any other flow's. +const OTP_PURPOSE: &str = "email_2fa"; + +/// Codes a sign-in may have mailed per account and hour, across challenges. +const MAX_SIGN_IN_CODES_PER_HOUR: i64 = 10; + +/// What a code's digest is bound to: the account, and the sign-in challenge +/// that asked for it (by its id, never the token). +pub fn code_subject(user_id: Uuid, challenge: Option<&str>) -> Vec { + let mut subject = user_id.as_bytes().to_vec(); + if let Some(token) = challenge { + subject.extend_from_slice(super::auth::challenge_id(token).as_bytes()); + } + subject } // Shared OTP verification logic @@ -246,34 +298,48 @@ pub async fn verify_login_code( async fn verify_otp( state: &AppState, user_id: Uuid, + challenge: Option<&str>, submitted_code: &str, fail_key: &str, + ip: Option, ) -> Result<(), AppError> { - let user_fail_key = format!("email2fa_user_fail:{user_id}"); + let account_keys = crate::services::auth::second_factor_budget_keys( + crate::services::auth::EMAIL_2FA_USER_FAIL_PREFIX, + user_id, + ip, + MAX_FAILURES_BY_USER, + ); // Reserve the attempt atomically before looking the code up. - let attempt = redis_counter::consume( - &state.redis, - &[ - Budget { - key: fail_key, - limit: MAX_FAILURES, - window_secs: OTP_EXPIRY_SECS, - }, - Budget { - key: &user_fail_key, - limit: MAX_FAILURES_BY_USER, - window_secs: USER_FAILURE_WINDOW_SECS, - }, - ], - ) - .await?; + let mut budgets = vec![Budget { + key: fail_key, + limit: MAX_FAILURES, + window_secs: OTP_EXPIRY_SECS, + }]; + budgets.extend(account_keys.iter().map(|(key, limit)| Budget { + key, + limit: *limit, + window_secs: USER_FAILURE_WINDOW_SECS, + })); + let attempt = redis_counter::consume(&state.redis, &budgets).await?; if attempt.exceeded { + if crate::services::auth::account_budget_exceeded(&attempt.counts, account_keys[0].1) { + crate::services::auth::notify_second_factor_pressure(state, user_id).await; + } return Err(AppError::RateLimitExceeded); } - let hash = crypto::sha256(submitted_code.as_bytes()); - let record = email_2fa::find_active_by_user_and_hash(&state.db, user_id, &hash) + let digests: Vec> = state + .keyring + .otp_digests( + OTP_PURPOSE, + &code_subject(user_id, challenge), + submitted_code, + ) + .iter() + .map(|digest| digest.to_vec()) + .collect(); + let record = email_2fa::find_active_by_user_and_hash(&state.db, user_id, &digests) .await .map_err(|e| AppError::Internal(e.into()))?; @@ -289,7 +355,9 @@ async fn verify_otp( return Err(AppError::TwoFactorFailed); } - redis_counter::reset(&state.redis, &[fail_key, &user_fail_key]).await; + let mut reset: Vec<&str> = account_keys.iter().map(|(key, _)| key.as_str()).collect(); + reset.push(fail_key); + redis_counter::reset(&state.redis, &reset).await; Ok(()) } diff --git a/src/services/email_change.rs b/src/services/email_change.rs index 8a50cf5..093abc1 100644 --- a/src/services/email_change.rs +++ b/src/services/email_change.rs @@ -40,6 +40,12 @@ use super::{auth as auth_svc, email as email_svc, events}; const FLOW_TTL_SECS: u64 = 60 * 15; // 15-minute window for the entire flow const MAX_OTP_FAILURES: i64 = 5; +/// Wrong codes one account may submit per hour across its flows: starting a +/// new flow opens a fresh budget of `MAX_OTP_FAILURES`, not a fresh search. +const MAX_OTP_FAILURES_PER_ACCOUNT: i64 = MAX_OTP_FAILURES * 3; +const OTP_ACCOUNT_WINDOW_SECS: u64 = 3600; +/// A flow starts at most once a minute: each start mails the current address. +const START_COOLDOWN_SECS: u64 = 60; /// Per-user cooldown between two completed email changes (prevents mailbox spam). const CHANGE_COOLDOWN_SECS: u64 = 300; @@ -80,6 +86,18 @@ pub async fn start( ) .await?; + // Each start mails a code: at most one a minute, refused when Redis + // cannot tell. + if !redis_counter::claim_cooldown_strict( + &state.redis, + &format!("email_change_start_cd:{user_id}"), + START_COOLDOWN_SECS, + ) + .await? + { + return Err(AppError::RateLimitExceeded); + } + // Block if a change was completed recently. let cooldown_key = format!("email_change_cd:{}", user_id); { @@ -98,9 +116,9 @@ pub async fn start( let active_key = format!("email_change_active:{}", user_id); if let Ok(mut conn) = state.redis.get().await { let old: Option = conn.get(&active_key).await.unwrap_or(None); - if let Some(old_token) = old { - let _: Result<(), _> = conn.del(format!("email_change_flow:{}", old_token)).await; - let _: Result<(), _> = conn.del(format!("email_change_fail:{}", old_token)).await; + if let Some(old_id) = old { + let _: Result<(), _> = conn.del(format!("email_change_flow:{old_id}")).await; + let _: Result<(), _> = conn.del(format!("email_change_fail:{old_id}")).await; } } @@ -115,7 +133,7 @@ pub async fn start( let flow_token = crypto::generate_token(); let otp = crypto::generate_otp(); - let otp_hash = hash_otp(&otp); + let otp_hash = hash_otp(state, user_id, &otp); let flow = FlowState { user_id, @@ -127,7 +145,9 @@ pub async fn start( // Record the active flow token so a second call can cancel the first. if let Ok(mut conn) = state.redis.get().await { - let _: Result<(), _> = conn.set_ex(&active_key, &flow_token, FLOW_TTL_SECS).await; + let _: Result<(), _> = conn + .set_ex(&active_key, crypto::token_id(&flow_token), FLOW_TTL_SECS) + .await; } audit::append( @@ -179,8 +199,15 @@ pub async fn verify_current( return Err(AppError::Unauthorized); }; - let fail_key = format!("email_change_fail:{}", flow_token); - verify_otp(state, submitted_code, flow.otp_hash.as_deref(), &fail_key).await?; + let fail_key = format!("email_change_fail:{}", crypto::token_id(flow_token)); + verify_otp( + state, + user_id, + submitted_code, + flow.otp_hash.as_deref(), + &fail_key, + ) + .await?; flow.step = next; flow.otp_hash = None; @@ -190,7 +217,12 @@ pub async fn verify_current( } /// Records the new email address and sends an OTP to it. -/// Validates uniqueness before sending to give a clear error without wasting an OTP. +/// +/// A taken address answers exactly like a free one, and the flow moves on the +/// same way, but no code is sent: the caller cannot read that mailbox, so the +/// answer reveals nothing, and its owner is not bothered. Submissions are +/// budgeted per account and per target address, so the route sends codes to +/// nobody in bulk. pub async fn submit_new( state: &AppState, user_id: Uuid, @@ -205,16 +237,44 @@ pub async fn submit_new( return Err(AppError::Unauthorized); }; + let account_key = format!("ec_submit_account:{user_id}"); + let target_key = format!( + "ec_submit_target:{}", + crypto::sha256(new_email.to_ascii_lowercase().as_bytes()) + .iter() + .map(|b| format!("{b:02x}")) + .collect::() + ); + match redis_counter::consume( + &state.redis, + &[ + Budget { + key: &account_key, + limit: MAX_SUBMISSIONS_PER_WINDOW, + window_secs: SUBMISSION_WINDOW_SECS, + }, + Budget { + key: &target_key, + limit: MAX_SUBMISSIONS_PER_WINDOW, + window_secs: SUBMISSION_WINDOW_SECS, + }, + ], + ) + .await + { + Ok(attempt) if attempt.exceeded => return Err(AppError::RateLimitExceeded), + Ok(_) => {} + // The budget keeps codes from being mailed to anyone in bulk: without + // it, nothing is sent. + Err(error) => return Err(error), + } + let taken = user_repo::email_taken(&state.db, new_email, user_id) .await .map_err(|e| AppError::Internal(e.into()))?; - if taken { - return Err(AppError::Conflict("email_taken")); - } - let otp = crypto::generate_otp(); - let otp_hash = hash_otp(&otp); + let otp_hash = hash_otp(state, user_id, &otp); flow.step = next; flow.otp_hash = Some(otp_hash); @@ -239,19 +299,23 @@ pub async fn submit_new( .await .map_err(|e| AppError::Internal(e.into()))?; + // Written whether a code leaves or not: the requester reads their own + // history, and a missing entry would tell them the address has an account. + if taken { + return Ok(()); + } + let mailer = state.mailer.clone(); let templates = state.templates.clone(); let mail_cfg = state.config.mail.clone(); let email_to = new_email.to_string(); - let username = user.username.clone(); let locale = user.preferred_locale.clone(); email_svc::dispatch_best_effort("email_change_otp_new", async move { - email_svc::send_email_change_otp( + email_svc::send_email_change_new_otp( &mailer, templates.as_ref(), &mail_cfg, &email_to, - &username, &locale, &otp, ) @@ -285,8 +349,15 @@ pub async fn confirm_new( let new_email = flow.new_email.as_deref().ok_or(AppError::Unauthorized)?; - let fail_key = format!("email_change_fail:{}", flow_token); - verify_otp(state, submitted_code, flow.otp_hash.as_deref(), &fail_key).await?; + let fail_key = format!("email_change_fail:{}", crypto::token_id(flow_token)); + verify_otp( + state, + user_id, + submitted_code, + flow.otp_hash.as_deref(), + &fail_key, + ) + .await?; let previous = user_repo::find_by_id(&state.db, user_id) .await @@ -313,9 +384,18 @@ pub async fn confirm_new( } token_repo::revoke_active_verification_by_user(&mut *tx, user_id).await?; + // Links already mailed to the previous address, possibly compromised, + // stop opening the account. + token_repo::revoke_mailbox_links(&mut tx, user_id).await?; // Ownership of the new address is proven via OTP, so it is verified at once. - user_repo::change_email(&mut *tx, user_id, new_email).await?; + // Two accounts confirming the same address at once: the constraint + // decides, and the loser hears the address is taken. + user_repo::change_email(&mut *tx, user_id, new_email) + .await + .map_err(|e| { + AppError::from_unique_violation(e, &[("users_email_key", "email_taken")]) + })?; session_repo::revoke_others(&mut *tx, user_id, current_session_id).await?; @@ -359,8 +439,8 @@ pub async fn confirm_new( if let Ok(mut conn) = state.redis.get().await { let _: Result<(), _> = conn .del(vec![ - format!("email_change_flow:{flow_token}"), - format!("email_change_fail:{flow_token}"), + format!("email_change_flow:{}", crypto::token_id(flow_token)), + format!("email_change_fail:{}", crypto::token_id(flow_token)), format!("email_change_active:{user_id}"), ]) .await; @@ -407,7 +487,8 @@ fn notify_previous_address( } async fn save_flow(state: &AppState, flow_token: &str, flow: &FlowState) -> Result<(), AppError> { - let key = format!("email_change_flow:{}", flow_token); + // Kept under the token's digest: a read of Redis yields no usable flow. + let key = format!("email_change_flow:{}", crypto::token_id(flow_token)); let val = serde_json::to_string(flow).map_err(|e| AppError::Internal(e.into()))?; let mut conn = state @@ -428,7 +509,8 @@ async fn load_flow( flow_token: &str, user_id: Uuid, ) -> Result { - let key = format!("email_change_flow:{}", flow_token); + // Kept under the token's digest: a read of Redis yields no usable flow. + let key = format!("email_change_flow:{}", crypto::token_id(flow_token)); let mut conn = state .redis @@ -451,36 +533,70 @@ async fn load_flow( async fn verify_otp( state: &AppState, + user_id: Uuid, submitted_code: &str, expected_hash: Option<&str>, fail_key: &str, ) -> Result<(), AppError> { let expected = expected_hash.ok_or(AppError::Unauthorized)?; + let account_key = format!("email_change_fail_user:{user_id}"); let attempt = redis_counter::consume( &state.redis, - &[Budget { - key: fail_key, - limit: MAX_OTP_FAILURES, - window_secs: FLOW_TTL_SECS, - }], + &[ + Budget { + key: fail_key, + limit: MAX_OTP_FAILURES, + window_secs: FLOW_TTL_SECS, + }, + Budget { + key: &account_key, + limit: MAX_OTP_FAILURES_PER_ACCOUNT, + window_secs: OTP_ACCOUNT_WINDOW_SECS, + }, + ], ) .await?; if attempt.exceeded { return Err(AppError::RateLimitExceeded); } - if hash_otp(submitted_code) != expected { + // Compared in constant time, under every key the keyring holds: a code + // issued just before a key rotation still verifies. + let matches = state + .keyring + .otp_digests(OTP_PURPOSE, user_id.as_bytes(), submitted_code) + .iter() + .any(|digest| { + crypto::constant_time_eq(encode_digest(digest).as_bytes(), expected.as_bytes()) + }); + if !matches { backoff::apply(attempt.counts[0]).await; return Err(AppError::TwoFactorFailed); } - redis_counter::reset(&state.redis, &[fail_key]).await; + redis_counter::reset(&state.redis, &[fail_key, &account_key]).await; Ok(()) } -/// Returns the base64url-encoded SHA-256 of an OTP plaintext. -fn hash_otp(code: &str) -> String { - let hash = crypto::sha256(code.as_bytes()); - base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(hash) +/// New addresses one account may submit per window, and submissions one address +/// may receive: codes are not sent to anyone in bulk. +const MAX_SUBMISSIONS_PER_WINDOW: i64 = 3; +const SUBMISSION_WINDOW_SECS: u64 = 3600; + +/// Separates the digests of this flow's codes from any other flow's. +const OTP_PURPOSE: &str = "email_change"; + +/// The keyed digest of a code of `user_id`'s flow, base64url-encoded as the +/// flow stores it: the Redis entry alone does not give the code away. +fn hash_otp(state: &AppState, user_id: Uuid, code: &str) -> String { + encode_digest( + &state + .keyring + .otp_digest(OTP_PURPOSE, user_id.as_bytes(), code), + ) +} + +fn encode_digest(digest: &[u8; 32]) -> String { + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest) } diff --git a/src/services/external_identity.rs b/src/services/external_identity.rs index 1e364c0..ee0f7e7 100644 --- a/src/services/external_identity.rs +++ b/src/services/external_identity.rs @@ -66,9 +66,15 @@ struct Outcome { provider: String, intent: Intent, binding_hash: String, - /// The account signing in, or the account the identity was linked to. + /// The account signing in, or the account starting the link. user_id: Option, + /// The identity signing in, or already linked to the account starting the + /// link. identity_id: Option, + /// The provider's subject of a link still to make: the link is made only + /// by `complete_link`, once the binding proves the browser that started it. + #[serde(default)] + subject: Option, /// Stable error code when the flow failed. error: Option, } @@ -209,13 +215,17 @@ pub async fn callback( binding_hash: pending.binding_hash.clone(), user_id: None, identity_id: None, + subject: None, error: None, }; match result { Ok(subject) => match settle(state, provider, &pending, &subject).await? { Ok((user_id, identity_id)) => { outcome.user_id = Some(user_id); - outcome.identity_id = Some(identity_id); + outcome.identity_id = identity_id; + if identity_id.is_none() { + outcome.subject = Some(subject); + } } Err(code) => outcome.error = Some(code.into()), }, @@ -335,16 +345,24 @@ async fn verify_id_token( return Err("unsupported id token algorithm"); } let jwks_uri = metadata["jwks_uri"].as_str().ok_or("no jwks uri")?; - let jwks = fetch_json(state, &format!("jwks:{}", provider.name), jwks_uri, false).await?; - let set: jsonwebtoken::jwk::JwkSet = - serde_json::from_value(jwks).map_err(|_| "malformed jwks")?; - let jwk = match header.kid.as_deref() { - Some(kid) => set.find(kid), - None if set.keys.len() == 1 => set.keys.first(), - None => None, + let cache_key = format!("jwks:{}", provider.name); + let find = |jwks: Value| -> Result, &'static str> { + let set: jsonwebtoken::jwk::JwkSet = + serde_json::from_value(jwks).map_err(|_| "malformed jwks")?; + Ok(match header.kid.as_deref() { + Some(kid) => set.find(kid).cloned(), + None if set.keys.len() == 1 => set.keys.first().cloned(), + None => None, + }) + }; + let mut jwk = find(fetch_json(state, &cache_key, jwks_uri, None).await?)?; + // A key the cached set does not hold may be a rotation at the provider: + // fetch the set again, at most once per JWKS_REFRESH_MIN_AGE. + if jwk.is_none() && forget_if_older(&cache_key, JWKS_REFRESH_MIN_AGE).await { + jwk = find(fetch_json(state, &cache_key, jwks_uri, None).await?)?; } - .ok_or("unknown signing key")?; - let key = jsonwebtoken::DecodingKey::from_jwk(jwk).map_err(|_| "unusable signing key")?; + let jwk = jwk.ok_or("unknown signing key")?; + let key = jsonwebtoken::DecodingKey::from_jwk(&jwk).map_err(|_| "unusable signing key")?; let mut validation = jsonwebtoken::Validation::new(header.alg); // Claims are checked against the application clock in the domain. validation.validate_exp = false; @@ -358,12 +376,29 @@ async fn verify_id_token( static DISCOVERED: LazyLock>> = LazyLock::new(|| RwLock::new(HashMap::new())); +/// How old a cached key set must be before an unknown key triggers a refetch: +/// a stream of tokens with made-up key ids cannot hammer the provider. +const JWKS_REFRESH_MIN_AGE: std::time::Duration = std::time::Duration::from_secs(30); + +/// Drop the cached value at `cache_key` if it was fetched at least `min_age` +/// ago. Returns whether it was dropped. +async fn forget_if_older(cache_key: &str, min_age: std::time::Duration) -> bool { + let mut cache = DISCOVERED.write().await; + match cache.get(cache_key) { + Some((fetched, _)) if fetched.elapsed() >= min_age => { + cache.remove(cache_key); + true + } + _ => false, + } +} + async fn discovery(state: &AppState, provider: &IdentityProviderConfig) -> Result { fetch_json( state, &format!("discovery:{}", provider.issuer), &format!("{}/.well-known/openid-configuration", provider.issuer), - true, + Some(&provider.issuer), ) .await .map_err(|reason| { @@ -380,7 +415,7 @@ async fn fetch_json( state: &AppState, cache_key: &str, url: &str, - check_issuer: bool, + expected_issuer: Option<&str>, ) -> Result { if let Some((fetched, value)) = DISCOVERED.read().await.get(cache_key) && fetched.elapsed() < DISCOVERY_TTL @@ -398,8 +433,30 @@ async fn fetch_json( .json() .await .map_err(|_| "malformed metadata")?; - if check_issuer && value["issuer"].as_str().is_none() { - return Err("metadata without issuer"); + // OpenID Connect Discovery section 4.3: the document is the issuer's only + // when it names that issuer exactly. Its endpoints receive the client + // secret and the codes: a document for another issuer is not followed. + if let Some(expected) = expected_issuer + && value["issuer"].as_str() != Some(expected) + { + return Err("metadata of another issuer"); + } + // The endpoints receive the client secret, the codes and verifiers: never + // over a weaker transport than the issuer's own (https in production, + // where the issuer must be https). + if let Some(expected) = expected_issuer { + let scheme = reqwest::Url::parse(expected) + .map(|url| url.scheme().to_owned()) + .map_err(|_| "unreadable issuer")?; + for field in ["authorization_endpoint", "token_endpoint", "jwks_uri"] { + if let Some(endpoint) = value[field].as_str() + && reqwest::Url::parse(endpoint) + .map(|url| url.scheme() != scheme) + .unwrap_or(true) + { + return Err("metadata endpoint over another transport"); + } + } } DISCOVERED.write().await.insert( cache_key.to_owned(), @@ -408,46 +465,29 @@ async fn fetch_json( Ok(value) } -/// Resolve an identified person: the account signing in, or the link made. +/// Resolve an identified person: the account signing in and its identity, or +/// the account starting a link and the identity it already holds (`None`: the +/// link is still to make). Nothing is written for a link here: the callback is +/// a URL anyone can be tricked into opening, and only `complete_link`, holding +/// the binding of the browser that started the flow, may link. async fn settle( state: &AppState, provider: &IdentityProviderConfig, pending: &Pending, subject: &str, -) -> Result, AppError> { +) -> Result), &'static str>, AppError> { let existing = identity_repo::find_by_subject(&state.db, &provider.name, subject).await?; match (pending.intent, existing, pending.user_id) { (Intent::SignIn, Some(identity), _) => { identity_repo::touch(&state.db, identity.id).await?; - Ok(Ok((identity.user_id, identity.id))) + Ok(Ok((identity.user_id, Some(identity.id)))) } (Intent::SignIn, None, _) => Ok(Err("not_linked")), (Intent::Link, Some(identity), Some(user_id)) if identity.user_id == user_id => { - Ok(Ok((user_id, identity.id))) + Ok(Ok((user_id, Some(identity.id)))) } (Intent::Link, Some(_), _) => Ok(Err("already_linked")), - (Intent::Link, None, Some(user_id)) => { - match identity_repo::link(&state.db, user_id, &provider.name, subject).await { - Ok(identity) => { - audit::append( - &state.db, - &NewAuditEntry { - user_id: Some(user_id), - request_id: None, - action: AuditAction::ExternalIdentityLinked, - ip_address: None, - metadata: json!({ "provider": provider.name }), - }, - ) - .await?; - Ok(Ok((user_id, identity.id))) - } - Err(sqlx::Error::Database(e)) if e.code().as_deref() == Some("23505") => { - Ok(Err("already_linked")) - } - Err(e) => Err(e.into()), - } - } + (Intent::Link, None, Some(user_id)) => Ok(Ok((user_id, None))), (Intent::Link, None, None) => Ok(Err("invalid_request")), } } @@ -468,7 +508,11 @@ async fn take_outcome( let outcome: Outcome = stored .and_then(|s| serde_json::from_str(&s).ok()) .ok_or(AppError::TokenInvalid)?; - if outcome.binding_hash != hex_digest(binding) || outcome.intent != intent { + if !crypto::constant_time_eq( + outcome.binding_hash.as_bytes(), + hex_digest(binding).as_bytes(), + ) || outcome.intent != intent + { return Err(AppError::TokenInvalid); } match outcome.error.as_deref() { @@ -496,7 +540,7 @@ pub async fn complete_sign_in( let user = user_repo::find_by_id(&state.db, outcome.user_id.ok_or(AppError::TokenInvalid)?) .await? .ok_or(AppError::TokenInvalid)?; - auth_svc::ensure_account_usable(&user, state.clock.now())?; + auth_svc::ensure_account_usable(&user)?; auth_svc::first_factor_proven( state, &user, @@ -511,7 +555,8 @@ pub async fn complete_sign_in( .await } -/// Finish a link started by `user_id`. +/// Finish a link started by `user_id`: the binding proves this is the browser +/// that started it, and only now is the identity linked. pub async fn complete_link( state: &AppState, user_id: Uuid, @@ -522,11 +567,47 @@ pub async fn complete_link( if outcome.user_id != Some(user_id) { return Err(AppError::TokenInvalid); } - identity_repo::find_by_user(&state.db, user_id) - .await? - .into_iter() - .find(|identity| Some(identity.id) == outcome.identity_id) - .ok_or(AppError::NotFound) + if let Some(identity_id) = outcome.identity_id { + return identity_repo::find_by_user(&state.db, user_id) + .await? + .into_iter() + .find(|identity| identity.id == identity_id) + .ok_or(AppError::NotFound); + } + let subject = outcome.subject.ok_or(AppError::TokenInvalid)?; + let provider = provider(state, &outcome.provider)?; + + let mut tx = state.db.begin().await?; + let identity = match identity_repo::link(&mut *tx, user_id, &provider.name, &subject).await { + Ok(identity) => identity, + Err(sqlx::Error::Database(e)) if e.code().as_deref() == Some("23505") => { + return Err(AppError::Conflict("external_identity_already_linked")); + } + Err(e) => return Err(e.into()), + }; + audit::append( + &mut *tx, + &NewAuditEntry { + user_id: Some(user_id), + request_id: None, + action: AuditAction::ExternalIdentityLinked, + ip_address: None, + metadata: json!({ "provider": provider.name }), + }, + ) + .await?; + tx.commit().await?; + + crate::services::user::notify_access_added( + state, + user_id, + crate::services::email::AccessItem { + kind: "external_identity", + name: provider.display_name.clone(), + }, + ) + .await; + Ok(identity) } /// Start linking a provider to a signed-in, re-authenticated account. diff --git a/src/services/key_rotation.rs b/src/services/key_rotation.rs index ca83154..d0d04b7 100644 --- a/src/services/key_rotation.rs +++ b/src/services/key_rotation.rs @@ -32,28 +32,22 @@ pub struct RotationResult { pub failed: usize, } -/// Rewrite every TOTP secret under the current key. Fails fast when no -/// previous key is configured or when it equals the current one. +/// Rewrite every TOTP and webhook secret that is not yet a `v2` ciphertext +/// under the current key. Without `PREVIOUS_ENCRYPTION_KEY` it upgrades the +/// older formats in place (binding each secret to its row); with it, it also +/// moves secrets off the previous key. Fails fast when the previous key equals +/// the current one. pub async fn rotate_totp_encryption_key(state: &AppState) -> Result { - let previous = state - .config - .crypto - .previous_encryption_key - .as_deref() - .ok_or_else(|| { - AppError::Internal(anyhow::anyhow!( - "PREVIOUS_ENCRYPTION_KEY must be set to run key rotation" - )) - })?; - - let old_key = - crypto::decode_encryption_key(previous).map_err(|e| AppError::Internal(e.into()))?; - let new_key = crypto::decode_encryption_key(&state.config.crypto.encryption_key) - .map_err(|e| AppError::Internal(e.into()))?; - if old_key == new_key { - return Err(AppError::Internal(anyhow::anyhow!( - "PREVIOUS_ENCRYPTION_KEY and ENCRYPTION_KEY are identical - nothing to rotate" - ))); + if let Some(previous) = state.config.crypto.previous_encryption_key.as_deref() { + let old_key = + crypto::decode_encryption_key(previous).map_err(|e| AppError::Internal(e.into()))?; + let new_key = crypto::decode_encryption_key(&state.config.crypto.encryption_key) + .map_err(|e| AppError::Internal(e.into()))?; + if old_key == new_key { + return Err(AppError::Internal(anyhow::anyhow!( + "PREVIOUS_ENCRYPTION_KEY and ENCRYPTION_KEY are identical - nothing to rotate" + ))); + } } let keyring = &state.keyring; @@ -73,9 +67,10 @@ pub async fn rotate_totp_encryption_key(state: &AppState) -> Result value, Err(e) => { @@ -96,15 +91,15 @@ pub async fn rotate_totp_encryption_key(state: &AppState) -> Result value, Err(e) => { @@ -134,6 +129,7 @@ pub async fn rotate_totp_encryption_key(state: &AppState) -> Result Result Result { + let kids: Vec = state + .keyring + .kids() + .into_iter() + .map(str::to_owned) + .collect(); + let count: i64 = sqlx::query_scalar( + "SELECT + (SELECT count(*) FROM two_factor_methods + WHERE totp_secret IS NOT NULL + AND (totp_secret !~ '^v2:' OR split_part(totp_secret, ':', 2) <> ALL($1))) + + (SELECT count(*) FROM webhook_endpoints + WHERE secret !~ '^v2:' OR split_part(secret, ':', 2) <> ALL($1))", + ) + .bind(&kids) + .fetch_one(&state.db) + .await?; + Ok(usize::try_from(count).unwrap_or(0)) +} diff --git a/src/services/oauth.rs b/src/services/oauth.rs index 1a7b239..74542a7 100644 --- a/src/services/oauth.rs +++ b/src/services/oauth.rs @@ -97,7 +97,16 @@ impl From for EndpointError { | E::Forbidden => ErrorCode::InvalidGrant, _ => return Self::App(error), }; - Self::OAuth(OAuthError::new(code, error.to_string())) + // Whoever holds a refresh token or a device code learns nothing of the + // account's state: suspended, locked, inactive or unverified all read + // the same. + let description = match &error { + E::AccountSuspended | E::AccountInactive | E::AccountLocked | E::EmailNotVerified => { + "the grant is not valid".to_owned() + } + _ => error.to_string(), + }; + Self::OAuth(OAuthError::new(code, description)) } } @@ -109,13 +118,124 @@ impl From for EndpointError { // Client authentication +/// Wrong client secrets one address may present per window. Secrets carry 256 +/// bits: this bounds volume, it is not what keeps them secret. +const MAX_CLIENT_AUTH_FAILURES_BY_IP: i64 = 20; +const CLIENT_AUTH_FAILURE_WINDOW_SECS: u64 = 900; +/// Requests one client may make per minute to the token, introspection and +/// revocation endpoints. These run under the general per-address limit: a +/// resource server introspecting from one address, or clients behind one NAT, +/// are bounded per client instead of by the strict per-address limit. +const CLIENT_REQUESTS_PER_MINUTE: i64 = 1_200; + +/// The budget of wrong secrets presented from `ip` for the client id the +/// request claims: one address guessing one client's secret is throttled, +/// without shutting the endpoints for the other clients and users behind the +/// same address. +fn client_failure_key(ip: IpNetwork, claimed_client_id: &str) -> String { + let claimed: String = crypto::sha256(claimed_client_id.as_bytes())[..16] + .iter() + .map(|b| format!("{b:02x}")) + .collect(); + format!( + "oauth_client_fail:{}:{claimed}", + crate::middleware::rate_limit::ip_bucket(ip.ip()) + ) +} + +/// The client id a request claims, and whether it presents a secret: only +/// requests presenting one can guess one. +fn claimed_secret_holder( + authorization: Option<&str>, + parameters: &[(String, String)], +) -> Option { + match authorization.filter(|h| h.to_ascii_lowercase().starts_with("basic ")) { + Some(header) => Some( + oauth::basic_credentials(header) + .map(|(id, _)| id) + .unwrap_or_default(), + ), + None => oauth::parameter(parameters, "client_secret").map(|_| { + oauth::parameter(parameters, "client_id") + .unwrap_or_default() + .to_owned() + }), + } +} + /// The client making a token or device authorization request: authenticated /// with its secret when it has one (`client_secret_basic` or /// `client_secret_post`), identified by `client_id` when it is public. +/// +/// Wrong secrets count against the address, and every authenticated request +/// against the client: both budgets bound volume and fail open. pub async fn authenticate_client( state: &AppState, authorization: Option<&str>, parameters: &[(String, String)], + ip: Option, +) -> Result { + let failure_key = ip + .zip(claimed_secret_holder(authorization, parameters)) + .map(|(ip, claimed)| client_failure_key(ip, &claimed)); + if let Some(key) = &failure_key + && crate::utils::redis_counter::peek(&state.redis, key) + .await + .unwrap_or(0) + >= MAX_CLIENT_AUTH_FAILURES_BY_IP + { + return Err(AppError::RateLimitExceeded.into()); + } + let client = identify_client(state, authorization, parameters).await; + match &client { + Err(EndpointError::OAuth(error)) if error.code == ErrorCode::InvalidClient => { + if let Some(key) = &failure_key { + let _ = crate::utils::redis_counter::consume( + &state.redis, + &[crate::utils::redis_counter::Budget { + key, + limit: MAX_CLIENT_AUTH_FAILURES_BY_IP, + window_secs: CLIENT_AUTH_FAILURE_WINDOW_SECS, + }], + ) + .await; + } + } + Ok(client) => { + // A confidential client proved its secret: its budget is its own. + // A public client is only named, by anyone: its budget is split + // by address, so a flood from a few addresses cannot spend the + // share of every one of its users. + let key = match ip { + Some(ip) if !client.is_confidential() => format!( + "oauth_client_rpm:{}:{}", + client.client_id, + crate::middleware::rate_limit::ip_bucket(ip.ip()) + ), + _ => format!("oauth_client_rpm:{}", client.client_id), + }; + let consumed = crate::utils::redis_counter::consume( + &state.redis, + &[crate::utils::redis_counter::Budget { + key: &key, + limit: CLIENT_REQUESTS_PER_MINUTE, + window_secs: 60, + }], + ) + .await; + if consumed.is_ok_and(|attempt| attempt.exceeded) { + return Err(AppError::RateLimitExceeded.into()); + } + } + Err(_) => {} + } + client +} + +async fn identify_client( + state: &AppState, + authorization: Option<&str>, + parameters: &[(String, String)], ) -> Result { let basic = authorization.filter(|h| h.to_ascii_lowercase().starts_with("basic ")); let invalid = |description: &str, basic_challenge: bool| { @@ -157,10 +277,12 @@ pub async fn authenticate_client( } }; + // One description for every failure: which client ids exist, and which + // hold a secret, is not the caller's to learn. let Some(client) = crate::repositories::registered_client::find_by_id(&state.db, &client_id).await? else { - return Err(invalid("unknown client", basic.is_some())); + return Err(invalid("client authentication failed", basic.is_some())); }; match (&client.client_secret_hash, secret) { (Some(expected), Some(secret)) => { @@ -170,10 +292,10 @@ pub async fn authenticate_client( } } (Some(_), None) => { - return Err(invalid("this client must authenticate", basic.is_some())); + return Err(invalid("client authentication failed", basic.is_some())); } (None, Some(_)) => { - return Err(invalid("this client has no secret", basic.is_some())); + return Err(invalid("client authentication failed", basic.is_some())); } (None, None) => {} } @@ -181,7 +303,7 @@ pub async fn authenticate_client( } fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { - a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 + crypto::constant_time_eq(a, b) } // Authorization requests @@ -195,6 +317,13 @@ struct StoredRequest { state: Option, #[serde(default)] nonce: Option, + /// OpenID Connect `max_age` (`prompt=login` is 0): the password must have + /// been proved this many seconds ago at most when the request is approved. + #[serde(default)] + max_age: Option, + /// The first signed-in user who looked at the request: only they decide it. + #[serde(default)] + viewer: Option, } /// Where `GET /oauth/authorize` sends the browser. @@ -229,11 +358,14 @@ pub async fn start_authorization( authorize_svc::validate_redirect(&client, &redirect_uri)?; let client_state = param("state").filter(|s| s.len() <= oauth::MAX_STATE_LEN); + let issuer = state.config.server.public_url.as_str(); let refuse = |code: ErrorCode, description: &str| { let mut response = vec![("error", code.as_str()), ("error_description", description)]; if let Some(client_state) = client_state { response.push(("state", client_state)); } + // RFC 9207: the client learns which server answered (mix-up defence). + response.push(("iss", issuer)); oauth::redirect_with(&redirect_uri, &response) .map(AuthorizeOutcome::Refused) .ok_or_else(|| { @@ -256,6 +388,48 @@ pub async fn start_authorization( "only the code response type is supported", ); } + // OpenID Connect parameters this server does not honour are refused rather + // than ignored: a client relying on them must know. + if param("request").is_some() { + return refuse( + ErrorCode::RequestNotSupported, + "request objects are not supported", + ); + } + if param("request_uri").is_some() { + return refuse( + ErrorCode::RequestUriNotSupported, + "request_uri is not supported", + ); + } + if param("response_mode").is_some_and(|mode| mode != "query") { + return refuse( + ErrorCode::InvalidRequest, + "only the query response mode is supported", + ); + } + let prompt: Vec<&str> = param("prompt").unwrap_or_default().split(' ').collect(); + if prompt.contains(&"none") { + return refuse( + ErrorCode::InteractionRequired, + "the consent page always asks the user", + ); + } + let max_age = match param("max_age").map(str::parse::) { + None => None, + Some(Ok(age)) if age >= 0 => Some(age), + Some(_) => { + return refuse( + ErrorCode::InvalidRequest, + "max_age must be a number of seconds", + ); + } + }; + let max_age = if prompt.contains(&"login") { + Some(0) + } else { + max_age + }; let Some(code_challenge) = param("code_challenge") else { return refuse(ErrorCode::InvalidRequest, "code_challenge is required"); }; @@ -278,6 +452,8 @@ pub async fn start_authorization( scopes, state: client_state.map(str::to_owned), nonce: param("nonce").map(str::to_owned), + max_age, + viewer: None, }) .map_err(|e| AppError::Internal(e.into()))?; let mut conn = state @@ -332,10 +508,7 @@ fn hex(bytes: &[u8]) -> String { bytes.iter().map(|b| format!("{b:02x}")).collect() } -async fn load_request( - state: &AppState, - id: &str, -) -> Result<(StoredRequest, RegisteredClient), AppError> { +async fn load_request(state: &AppState, id: &str) -> Result<(Loaded, RegisteredClient), AppError> { let mut conn = state .redis .get() @@ -345,14 +518,36 @@ async fn load_request( .get(request_key(id)) .await .map_err(|e| AppError::Internal(e.into()))?; - let request: StoredRequest = serde_json::from_str(&raw.ok_or(AppError::NotFound)?) - .map_err(|e| AppError::Internal(e.into()))?; + let raw = raw.ok_or(AppError::NotFound)?; + let request: StoredRequest = + serde_json::from_str(&raw).map_err(|e| AppError::Internal(e.into()))?; let client = authorize_svc::load_client(state, &request.client_id) .await .map_err(|_| AppError::NotFound)?; - Ok((request, client)) + Ok((Loaded { request, raw }, client)) +} + +/// A stored request as read, with the exact value read: claiming it swaps +/// that value only if nobody changed it meanwhile. +struct Loaded { + request: StoredRequest, + raw: String, } +/// Replace a value only if it is still the one read, keeping its expiry. +static CLAIM_REQUEST: std::sync::LazyLock = + std::sync::LazyLock::new(|| { + deadpool_redis::redis::Script::new( + r#" +if redis.call('GET', KEYS[1]) == ARGV[1] then + redis.call('SET', KEYS[1], ARGV[2], 'KEEPTTL') + return 1 +end +return 0 +"#, + ) + }); + /// Remove the request: of concurrent decisions, only the one that removed it /// goes on. async fn take_request(state: &AppState, id: &str) -> Result<(), AppError> { @@ -386,18 +581,68 @@ pub async fn describe_request( id: &str, ) -> Result { let (request, client) = load_request(state, id).await?; + let request = claim_request(state, id, request, user_id).await?; let description = authorize_svc::describe(state, user_id, &client, request.scopes.as_deref()).await?; + let reauthentication_required = + !password_fresh_enough(state, session_id, request.max_age).await; Ok(RequestDescription { request: description, redirect_uri: request.redirect_uri, - reauthentication_required: authorize_svc::requires_reauthentication( - state, session_id, &client, - ) - .await, + reauthentication_required, }) } +/// Tie the request to the first signed-in user who looks at it: someone else +/// who learnt its id cannot decide it. A request already tied to another user +/// is not found for them. +async fn claim_request( + state: &AppState, + id: &str, + loaded: Loaded, + user_id: Uuid, +) -> Result { + let Loaded { mut request, raw } = loaded; + match request.viewer { + Some(viewer) if viewer == user_id => Ok(request), + Some(_) => Err(AppError::NotFound), + None => { + request.viewer = Some(user_id); + let stored = + serde_json::to_string(&request).map_err(|e| AppError::Internal(e.into()))?; + let mut conn = state + .redis + .get() + .await + .map_err(|e| AppError::Internal(e.into()))?; + // Only if nobody claimed it since it was read, keeping its expiry: + // of two users reading it unclaimed, one gets it. + let claimed: i64 = CLAIM_REQUEST + .key(request_key(id)) + .arg(&raw) + .arg(stored) + .invoke_async(&mut *conn) + .await + .map_err(|e| AppError::Internal(e.into()))?; + if claimed != 1 { + return Err(AppError::NotFound); + } + Ok(request) + } + } +} + +/// Whether the session's proof of the password is recent enough for a +/// request's `max_age` (any standing proof when there is none). +async fn password_fresh_enough(state: &AppState, session_id: Uuid, max_age: Option) -> bool { + let Some(proven_at) = crate::services::reauth::reauth_proven_at(state, session_id).await else { + return false; + }; + let age = state.clock.now().unix_timestamp() - proven_at; + // `max_age=0` (and `prompt=login`) asks for the password again, always. + max_age.is_none_or(|max_age| max_age > 0 && age <= max_age) +} + /// Approve a request: the redirect carrying the code and state. pub async fn approve_request( state: &AppState, @@ -409,21 +654,37 @@ pub async fn approve_request( request_id: Option, ) -> Result { let (request, client) = load_request(state, id).await?; - // The password is checked before the request is taken: a missing or wrong - // one leaves the request to approve once the user has confirmed it. - if !client.is_primary { - crate::services::reauth::require_recent_reauth_or_password( - state, - user_id, - session_id, - current_password, - ip, - request_id, - "authorize_client", - ) - .await?; + let request = claim_request(state, id, request, user_id).await?; + // The client as registered now: a redirect URI removed since the request + // was made no longer receives a code. + authorize_svc::validate_redirect(&client, &request.redirect_uri)?; + // A request with `max_age` needs a proof of the password that recent: an + // older one asks for the password again. + if current_password.is_none() + && !password_fresh_enough(state, session_id, request.max_age).await + { + return Err(AppError::ReauthenticationRequired); } + // The password is checked before the request is taken: a missing or wrong + // one leaves the request to approve once the user has confirmed it. Every + // client needs it, the instance's own application included: an approval + // mints a new, long-lived session, and an access token alone must not be + // enough to obtain one. + crate::services::reauth::require_recent_reauth_or_password( + state, + user_id, + session_id, + current_password, + ip, + request_id, + "authorize_client", + ) + .await?; take_request(state, id).await?; + let auth_time = crate::services::reauth::reauth_proven_at(state, session_id) + .await + .and_then(|at| ::time::OffsetDateTime::from_unix_timestamp(at).ok()) + .unwrap_or_else(|| state.clock.now()); let approval = authorize_svc::Approval { user_id, session_id, @@ -432,6 +693,7 @@ pub async fn approve_request( code_challenge: &request.code_challenge, requested: request.scopes.as_deref(), nonce: request.nonce.as_deref(), + auth_time, // Proven above: the recent re-authentication marker now stands for it. current_password: None, ip, @@ -443,13 +705,15 @@ pub async fn approve_request( if let Some(client_state) = request.state.as_deref() { response.push(("state", client_state)); } + response.push(("iss", state.config.server.public_url.as_str())); oauth::redirect_with(&request.redirect_uri, &response) .ok_or_else(|| AppError::Internal(anyhow::anyhow!("stored redirect_uri does not parse"))) } /// Deny a request: the redirect carrying `access_denied`. -pub async fn deny_request(state: &AppState, id: &str) -> Result { +pub async fn deny_request(state: &AppState, user_id: Uuid, id: &str) -> Result { let (request, _) = load_request(state, id).await?; + let request = claim_request(state, id, request, user_id).await?; take_request(state, id).await?; let mut response = vec![ ("error", ErrorCode::AccessDenied.as_str()), @@ -458,6 +722,7 @@ pub async fn deny_request(state: &AppState, id: &str) -> Result ( - auth_svc::refresh_token( + oauth::GRANT_REFRESH_TOKEN => { + let refresh = required("refresh_token")?; + // RFC 6749 section 6: the client may ask for less than it was + // granted, for this access token; never for more. Checked before + // the rotation, so a refused request keeps its refresh token. + let narrowed = match param("scope") { + None => None, + Some(scope) => { + let requested: Vec = + scope.split_ascii_whitespace().map(str::to_owned).collect(); + let session = crate::repositories::session::find_by_token_hash( + &state.db, + &crypto::sha256(refresh.as_bytes()), + ) + .await?; + let within = session + .as_ref() + .and_then(|s| s.scopes.as_ref()) + .is_none_or(|granted| requested.iter().all(|r| granted.contains(r))); + if !within { + return Err(OAuthError::new( + ErrorCode::InvalidScope, + "a refresh asks for at most what was granted", + ) + .into()); + } + Some(requested) + } + }; + let mut tokens = auth_svc::refresh_token( state, - required("refresh_token")?, + refresh, Some(&client.client_id), ip, user_agent, None, ) - .await?, - None, - ), + .await?; + if let Some(narrowed) = narrowed { + tokens.access_token = auth_svc::build_access_token( + tokens.session.user_id, + tokens.session.id, + Some(&narrowed), + tokens.session.client_id.as_deref(), + &tokens.session.session_type, + state, + ) + .await?; + tokens.session.scopes = Some(narrowed); + } + // No nonce in a refreshed ID token: OpenID Connect ties the nonce + // to the authentication request, which a refresh is not. + (tokens, None) + } _ => ( device_svc::poll( state, @@ -596,7 +903,13 @@ async fn id_token( exp: now .saturating_add(i64::try_from(state.config.jwt.access_expiry_secs).unwrap_or(i64::MAX)), iat: now, - auth_time: tokens.session.family_created_at.unix_timestamp(), + // When the user last proved their password for the consent, not when + // the session was opened. + auth_time: tokens + .session + .auth_time + .unwrap_or(tokens.session.family_created_at) + .unix_timestamp(), nonce, at_hash: crate::domain::oidc::at_hash(&tokens.access_token), profile: crate::domain::oidc::user_claims(&user, &scopes), @@ -644,10 +957,24 @@ async fn client_credentials( ) .into()); } - let scopes = check_scopes(state, client, scope) + // No user takes part: the OpenID Connect scopes, which describe one, have + // no meaning here and are refused rather than issued as permissions. + if scope.is_some_and(|scope| { + scope + .split_ascii_whitespace() + .any(crate::domain::oidc::is_oidc_scope) + }) { + return Err(OAuthError::new( + ErrorCode::InvalidScope, + "OpenID Connect scopes need a user: not with the client credentials grant", + ) + .into()); + } + let mut scopes = check_scopes(state, client, scope) .await? .map_err(|message| OAuthError::new(ErrorCode::InvalidScope, message))? .unwrap_or_default(); + scopes.retain(|scope| !crate::domain::oidc::is_oidc_scope(scope)); let issuer = state.config.server.public_url.clone(); let now = state.clock.now().unix_timestamp(); @@ -660,6 +987,7 @@ async fn client_credentials( ) .with_rbac(Vec::new(), scopes.clone()); claims.client_id = Some(client.client_id.clone()); + claims.sub_type = Some("client".to_owned()); claims.iss = Some(issuer); claims.aud = state.config.jwt.audience.clone(); let access_token = @@ -685,7 +1013,7 @@ pub async fn device_authorization( ip: Option, user_agent: Option<&str>, ) -> Result { - let client = authenticate_client(state, authorization, parameters).await?; + let client = authenticate_client(state, authorization, parameters, ip).await?; let scopes = check_scopes(state, &client, oauth::parameter(parameters, "scope")) .await? .map_err(|message| OAuthError::new(ErrorCode::InvalidScope, message))?; @@ -698,9 +1026,13 @@ pub async fn device_authorization( #[derive(Debug, Default, Serialize, utoipa::ToSchema)] pub struct Introspection { pub active: bool, - /// `access_token`, `refresh_token` or `personal_access_token`. + /// `access_token` or `refresh_token`. #[serde(skip_serializing_if = "Option::is_none")] pub token_type: Option<&'static str>, + /// For a user's token, the session it comes from: `web`, `device`, or + /// `personal_access_token` for a script. + #[serde(skip_serializing_if = "Option::is_none")] + pub session_type: Option<&'static str>, #[serde(skip_serializing_if = "Option::is_none")] pub scope: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -722,13 +1054,13 @@ pub struct Introspection { /// The kind of a presented token, from its shape. enum Presented<'a> { Access(&'a str), - Personal(&'a str), + Personal, Refresh(&'a str), } fn classify(token: &str) -> Presented<'_> { if crate::domain::personal_access_token::random_part(token).is_some() { - Presented::Personal(token) + Presented::Personal } else if token.split('.').count() == 3 { Presented::Access(token) } else { @@ -742,8 +1074,9 @@ pub async fn introspect( state: &AppState, authorization: Option<&str>, parameters: &[(String, String)], + ip: Option, ) -> Result { - let client = authenticate_client(state, authorization, parameters).await?; + let client = authenticate_client(state, authorization, parameters, ip).await?; if !client.is_confidential() { return Err(OAuthError::new( ErrorCode::UnauthorizedClient, @@ -760,14 +1093,28 @@ pub async fn introspect( let Some(claims) = verified_claims(state, jwt) else { return Ok(Introspection::default()); }; + // A resource server introspects anyone's access tokens; another + // confidential client only its own: it has no business learning + // what other tokens are worth. + if !client.allows_introspection + && claims.client_id.as_deref() != Some(client.client_id.as_str()) + { + return Ok(Introspection::default()); + } + // A client credentials token carries no session to narrow it: its + // permissions are read against the client's scopes of today, so a + // scope taken back from the client is gone from its tokens at once. + let mut current_scopes: Option> = None; let active = match claims.client_id.as_deref() { // A client credentials token: active while not revoked and the // client may still use the grant. Some(client_id) if claims.sid.is_nil() => { - !auth_svc::is_jti_blocked(state, claims.jti).await? - && crate::repositories::registered_client::find_by_id(&state.db, client_id) + let issuer = + crate::repositories::registered_client::find_by_id(&state.db, client_id) .await? - .is_some_and(|c| c.allows_client_credentials) + .filter(|c| c.allows_client_credentials); + current_scopes = issuer.as_ref().map(|c| c.scopes.clone()); + !auth_svc::is_jti_blocked(state, claims.jti).await? && issuer.is_some() } _ => auth_svc::verify_token_state(state, claims.jti, claims.sid) .await @@ -776,16 +1123,32 @@ pub async fn introspect( if !active { return Ok(Introspection::default()); } - let session_client = match claims.client_id.clone() { - Some(client_id) => Some(client_id), - None => crate::repositories::session::find_by_id(&state.db, claims.sid) - .await? - .and_then(|s| s.client_id), + let session = if claims.sid.is_nil() { + None + } else { + crate::repositories::session::find_by_id(&state.db, claims.sid).await? }; + let session_client = claims + .client_id + .clone() + .or_else(|| session.as_ref().and_then(|s| s.client_id.clone())); + // A resource server registered with scopes learns only those. + let scope: Vec<&str> = claims + .permissions + .iter() + .filter(|p| client.scopes.is_empty() || client.scopes.contains(p)) + .filter(|p| { + current_scopes + .as_ref() + .is_none_or(|scopes| scopes.contains(p)) + }) + .map(String::as_str) + .collect(); Introspection { active: true, token_type: Some("access_token"), - scope: Some(claims.permissions.join(" ")).filter(|s| !s.is_empty()), + session_type: session.map(|s| s.session_type.as_str()), + scope: Some(scope.join(" ")).filter(|s| !s.is_empty()), client_id: session_client, sub: Some(claims.sub), exp: Some(claims.exp), @@ -801,7 +1164,10 @@ pub async fn introspect( &crypto::sha256(raw.as_bytes()), ) .await? - .filter(|s| s.is_active(now) && s.rotated_at.is_none()) else { + .filter(|s| s.is_active(now) && s.rotated_at.is_none()) + // A refresh token is its client's secret: only that client learns + // anything of it. + .filter(|s| s.client_id.as_deref() == Some(client.client_id.as_str())) else { return Ok(Introspection::default()); }; Introspection { @@ -815,27 +1181,8 @@ pub async fn introspect( ..Introspection::default() } } - Presented::Personal(secret) => { - let random = - crate::domain::personal_access_token::random_part(secret).unwrap_or_default(); - let Some(found) = crate::repositories::personal_access_token::find_by_hash( - &state.db, - &crypto::sha256(random.as_bytes()), - ) - .await? - .filter(|f| f.session_revoked_at.is_none() && f.token.expires_at > now) else { - return Ok(Introspection::default()); - }; - Introspection { - active: true, - token_type: Some("personal_access_token"), - scope: Some(found.token.scopes.join(" ")), - sub: Some(found.token.user_id), - exp: Some(found.token.expires_at.unix_timestamp()), - iat: Some(found.token.created_at.unix_timestamp()), - ..Introspection::default() - } - } + // Personal access tokens belong to accounts, not to clients. + Presented::Personal => Introspection::default(), }; Ok(introspection) } @@ -862,7 +1209,7 @@ pub async fn revoke( parameters: &[(String, String)], ip: Option, ) -> Result<(), EndpointError> { - let client = authenticate_client(state, authorization, parameters).await?; + let client = authenticate_client(state, authorization, parameters, ip).await?; let token = oauth::parameter(parameters, "token") .ok_or_else(|| OAuthError::new(ErrorCode::InvalidRequest, "token is required"))?; let owned = |session: &crate::domain::session::Session| { @@ -919,7 +1266,32 @@ pub async fn revoke( } } // Personal access tokens belong to accounts, not clients. - Presented::Personal(_) => {} + Presented::Personal => {} } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn account_states_read_the_same_to_a_client() { + let descriptions: Vec> = [ + AppError::AccountSuspended, + AppError::AccountInactive, + AppError::AccountLocked, + AppError::EmailNotVerified, + ] + .into_iter() + .map(|error| match EndpointError::from(error) { + EndpointError::OAuth(error) => { + assert_eq!(error.code, ErrorCode::InvalidGrant); + error.description + } + EndpointError::App(_) => panic!("not an OAuth error"), + }) + .collect(); + assert!(descriptions.windows(2).all(|pair| pair[0] == pair[1])); + } +} diff --git a/src/services/passkey.rs b/src/services/passkey.rs index 41ba326..879cb8f 100644 --- a/src/services/passkey.rs +++ b/src/services/passkey.rs @@ -249,6 +249,16 @@ pub async fn register( ) .await?; + crate::services::user::notify_access_added( + state, + user_id, + crate::services::email::AccessItem { + kind: "passkey", + name: passkey.name.clone(), + }, + ) + .await; + let recovery_codes = if recovery_code::count_usable_by_user(&state.db, user_id).await? == 0 { Some(two_factor_svc::create_recovery_codes_internal(state, user_id).await?) } else { @@ -283,11 +293,14 @@ pub async fn remove( "remove_passkey", ) .await?; - if !passkey_repo::delete_owned(&state.db, id, user_id).await? { + let mut tx = state.db.begin().await?; + user_repo::lock_row(&mut *tx, user_id).await?; + if !passkey_repo::delete_owned(&mut *tx, id, user_id).await? { return Err(AppError::NotFound); } + crate::services::user::keep_a_second_factor_for_administrators(&mut tx, user_id).await?; audit::append( - &state.db, + &mut *tx, &NewAuditEntry { user_id: Some(user_id), request_id, @@ -297,6 +310,7 @@ pub async fn remove( }, ) .await?; + tx.commit().await?; Ok(()) } @@ -350,7 +364,7 @@ pub async fn sign_in( match verify_assertion(state, credential).await { Ok((passkey, user)) => { - auth_svc::ensure_account_usable(&user, state.clock.now())?; + auth_svc::ensure_account_usable(&user)?; auth_svc::issue_tokens( state, user.id, @@ -365,6 +379,8 @@ pub async fn sign_in( identifier: Some(&user.username), request_id, audit_metadata: json!({ "method": "passkey", "passkey_id": passkey.id }), + // User verification on the authenticator: two factors. + second_factor: true, }), ) .await @@ -418,9 +434,14 @@ async fn verify_assertion( let passkey = passkey_repo::find_by_credential_id(&state.db, &decode(&credential.raw_id)?) .await? .ok_or_else(|| refused("unknown credential"))?; - if let Some(handle) = credential.response.user_handle.as_deref() - && decode(handle)? != passkey.user_id.as_bytes() - { + // Sign-in offers no allowed credentials, so the authenticator must say + // whose credential it used. + let handle = credential + .response + .user_handle + .as_deref() + .ok_or_else(|| refused("missing user handle"))?; + if decode(handle)? != passkey.user_id.as_bytes() { return Err(refused("user handle mismatch")); } let auth_data = decode(&credential.response.authenticator_data)?; diff --git a/src/services/personal_access_token.rs b/src/services/personal_access_token.rs index cb1d0c6..c1eddc5 100644 --- a/src/services/personal_access_token.rs +++ b/src/services/personal_access_token.rs @@ -80,6 +80,12 @@ pub async fn create( let expires_at = state.clock.now() + Duration::days(lifetime_days); let mut tx = state.db.begin().await?; + // Serialized per account: concurrent creations cannot all count the same + // tokens and pass the limit together. + sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))") + .bind(format!("personal_access_tokens:{user_id}")) + .execute(&mut *tx) + .await?; if pat_repo::count_active_by_user(&mut *tx, user_id).await? >= pat::MAX_ACTIVE_PER_ACCOUNT { return Err(AppError::Conflict("too_many_tokens")); } @@ -100,6 +106,7 @@ pub async fn create( client_id: None, family_created_at: None, scopes: Some(&scopes), + mfa: false, }, ) .await?; @@ -128,6 +135,16 @@ pub async fn create( .await?; tx.commit().await?; + crate::services::user::notify_access_added( + state, + user_id, + crate::services::email::AccessItem { + kind: "personal_access_token", + name: token.name.clone(), + }, + ) + .await; + Ok(CreatedToken { token, secret }) } @@ -179,13 +196,15 @@ pub async fn exchange(state: &AppState, presented: &str) -> Result Option { return None; } Err(error) => { + // Without the URL: it carries the first characters of the + // password's SHA-1. + let error = error.without_url(); tracing::warn!(%error, "pwned passwords API unreachable"); return None; } @@ -73,6 +76,7 @@ async fn fetch_range(state: &AppState, prefix: &str) -> Option { match response.text().await { Ok(body) => Some(body), Err(error) => { + let error = error.without_url(); tracing::warn!(%error, "pwned passwords answer could not be read"); None } diff --git a/src/services/reauth.rs b/src/services/reauth.rs index dfa16f2..39c3ddb 100644 --- a/src/services/reauth.rs +++ b/src/services/reauth.rs @@ -33,7 +33,10 @@ pub async fn mark_recent_reauth(state: &AppState, session_id: Uuid) { if let Ok(mut conn) = state.redis.get().await { let key = reauth_key(session_id); - let _: Result<(), _> = conn.set_ex(&key, 1u8, ttl).await; + // The value is when the password was proved: OpenID Connect `max_age` + // and `auth_time` need it. + let proven_at = state.clock.now().unix_timestamp(); + let _: Result<(), _> = conn.set_ex(&key, proven_at, ttl).await; } } @@ -44,6 +47,13 @@ pub async fn clear_recent_reauth(state: &AppState, session_id: Uuid) { } } +/// When the session last proved the password, while that proof still stands. +pub async fn reauth_proven_at(state: &AppState, session_id: Uuid) -> Option { + let mut conn = state.redis.get().await.ok()?; + let value: Option = conn.get(reauth_key(session_id)).await.ok()?; + value?.parse().ok() +} + pub async fn has_recent_reauth(state: &AppState, session_id: Uuid) -> bool { match state.redis.get().await { Ok(mut conn) => { @@ -63,9 +73,12 @@ pub async fn reauthenticate( request_id: Option, reason: &'static str, ) -> Result<(), AppError> { - user_svc::verify_password(state, user_id, current_password).await?; + user_svc::verify_password(state, user_id, session_id, current_password).await?; + // Audited first: a proof of the password that left no trace grants + // nothing. + record_reauth_event(state, user_id, ip, request_id, reason).await?; mark_recent_reauth(state, session_id).await; - record_reauth_event(state, user_id, ip, request_id, reason).await + Ok(()) } pub async fn require_recent_reauth_or_password( diff --git a/src/services/session.rs b/src/services/session.rs index 5ba68c3..c479463 100644 --- a/src/services/session.rs +++ b/src/services/session.rs @@ -56,17 +56,13 @@ pub async fn revoke( return Err(AppError::Forbidden); } - session_repo::revoke(&state.db, session_id) + // The revocation and its audit entry commit together. + let mut tx = state.db.begin().await?; + session_repo::revoke(&mut *tx, session_id) .await .map_err(|e| AppError::Internal(e.into()))?; - - // Blacklist the refresh token so it cannot be used even before DB TTL expires. - auth_svc::blocklist_refresh_token(state, &session.token_hash, session.expires_at).await; - auth_svc::invalidate_session_cache(state, session.id); - reauth_svc::clear_recent_reauth(state, session.id).await; - audit::append( - &state.db, + &mut *tx, &NewAuditEntry { user_id: Some(user_id), request_id, @@ -77,6 +73,12 @@ pub async fn revoke( ) .await .map_err(|e| AppError::Internal(e.into()))?; + tx.commit().await?; + + // Blacklist the refresh token so it cannot be used even before DB TTL expires. + auth_svc::blocklist_refresh_token(state, &session.token_hash, session.expires_at).await; + auth_svc::invalidate_session_cache(state, session.id).await; + reauth_svc::clear_recent_reauth(state, session.id).await; Ok(()) } diff --git a/src/services/two_factor.rs b/src/services/two_factor.rs index dadb848..7924ceb 100644 --- a/src/services/two_factor.rs +++ b/src/services/two_factor.rs @@ -11,8 +11,6 @@ use ipnetwork::IpNetwork; use serde_json::json; use uuid::Uuid; -use deadpool_redis::redis::AsyncCommands; - use crate::{ domain::{ audit::AuditAction, @@ -155,7 +153,7 @@ pub async fn setup_totp( ); let encrypted = state .keyring - .encrypt(&base32_secret) + .encrypt(&base32_secret, user_id.as_bytes()) .map_err(|e| AppError::Internal(e.into()))?; let method = create_or_restart_method( @@ -213,6 +211,7 @@ pub async fn verify_setup( let valid = totp::verify_code( encrypted_secret, + user_id, code, &state.keyring, state.config.crypto.totp_skew, @@ -223,9 +222,14 @@ pub async fn verify_setup( // Consumed in the durable replay table shared with sign-in: a code seen // while confirming the method cannot complete a sign-in as well. let consumed = valid - && tf_repo::try_consume_totp_code(&state.db, user_id, &crypto::sha256(code.as_bytes())) - .await - .map_err(|e| AppError::Internal(e.into()))?; + && tf_repo::try_consume_totp_code( + &state.db, + user_id, + &crypto::sha256(code.as_bytes()), + state.clock.now(), + ) + .await + .map_err(|e| AppError::Internal(e.into()))?; if !consumed { return Err(AppError::TwoFactorFailed); } @@ -292,24 +296,23 @@ pub async fn generate_recovery_codes( ) .await?; + // Claimed before regenerating: concurrent requests cannot each replace + // the codes the previous one just showed. let cooldown_key = format!("rc_regen:{}", user_id); - if let Ok(mut conn) = state.redis.get().await { - let locked: bool = conn.exists(&cooldown_key).await.unwrap_or(false); - if locked { - return Err(AppError::RateLimitExceeded); - } + if !redis_counter::claim_cooldown_strict(&state.redis, &cooldown_key, RC_REGEN_COOLDOWN_SECS) + .await? + { + return Err(AppError::RateLimitExceeded); } - let codes = create_recovery_codes(state, user_id).await?; - - // Set cooldown after successful regeneration. - if let Ok(mut conn) = state.redis.get().await { - let _: Result<(), _> = conn - .set_ex(&cooldown_key, 1u8, RC_REGEN_COOLDOWN_SECS) - .await; + match create_recovery_codes(state, user_id).await { + Ok(codes) => Ok(codes), + Err(error) => { + // Nothing was replaced: the owner may try again. + redis_counter::reset(&state.redis, &[&cooldown_key]).await; + Err(error) + } } - - Ok(codes) } // Internal version used by verify_setup; no password check needed at that point. @@ -348,67 +351,6 @@ async fn create_recovery_codes(state: &AppState, user_id: Uuid) -> Result, -) -> Result<(), AppError> { - // The same per-account budget as the sign-in challenge: a second route - // must not double the guesses against the same codes. - let fail_key = format!("{}{user_id}", super::auth::RC_USER_FAIL_PREFIX); - - let attempt = redis_counter::consume( - &state.redis, - &[Budget { - key: &fail_key, - limit: super::auth::MAX_RECOVERY_FAILURES_BY_USER, - window_secs: super::auth::RECOVERY_FAILURE_USER_WINDOW_SECS, - }], - ) - .await?; - if attempt.exceeded { - return Err(AppError::RateLimitExceeded); - } - - // `find_by_hash` refuses used and expired codes; `consume` re-checks both. - let hash = crypto::sha256(code.as_bytes()); - let record = recovery_code::find_by_hash(&state.db, &hash) - .await - .map_err(|e| AppError::Internal(e.into()))? - .filter(|r| r.user_id == user_id); - - let consumed = match record { - Some(record) => recovery_code::consume(&state.db, record.id) - .await - .map_err(|e| AppError::Internal(e.into()))?, - None => false, - }; - if !consumed { - return Err(AppError::TwoFactorFailed); - } - - redis_counter::reset(&state.redis, &[&fail_key]).await; - - audit::append( - &state.db, - &NewAuditEntry { - user_id: Some(user_id), - request_id, - action: AuditAction::RecoveryCodeUsed, - ip_address: None, - metadata: json!({}), - }, - ) - .await - .map_err(|e| AppError::Internal(e.into()))?; - - Ok(()) -} - /// Disables the TOTP method. Requires a recent re-authentication or the current /// password. The remaining verified method, if any, becomes primary; recovery /// codes are removed only with the last method. @@ -471,13 +413,16 @@ pub(crate) async fn disable_method( .map_err(|e| AppError::Internal(e.into()))? .ok_or(AppError::NotFound)?; - let removed = tf_repo::remove_method(&state.db, method_id, user_id, method_type) + let mut tx = state.db.begin().await?; + user_repo::lock_row(&mut *tx, user_id).await?; + let removed = tf_repo::remove_method(&mut tx, method_id, user_id, method_type) .await .map_err(|e| AppError::Internal(e.into()))? .ok_or(AppError::NotFound)?; + crate::services::user::keep_a_second_factor_for_administrators(&mut tx, user_id).await?; audit::append( - &state.db, + &mut *tx, &NewAuditEntry { user_id: Some(user_id), request_id, @@ -492,6 +437,7 @@ pub(crate) async fn disable_method( ) .await .map_err(|e| AppError::Internal(e.into()))?; + tx.commit().await?; notify_two_factor_change(state, &user, label, false); Ok(()) diff --git a/src/services/user.rs b/src/services/user.rs index 03cbedd..dabaabb 100644 --- a/src/services/user.rs +++ b/src/services/user.rs @@ -18,33 +18,48 @@ use crate::{ utils::password, }; -use super::{auth as auth_svc, events, reauth as reauth_svc}; +use super::{auth as auth_svc, email::AccessItem, events, reauth as reauth_svc}; use crate::utils::redis_counter::{self, Budget}; -/// Redis key prefix for the per-user reauth-failure counter. +/// Redis key prefix of the re-authentication failure budgets. /// Protects re-authentication / sensitive-action endpoints (`change_password`, /// `change_username`, sessions::revoke, email-change flow, ...) from /// brute-force when an access token has been stolen: even with a valid /// access token, an attacker cannot try unlimited passwords. const REAUTH_FAIL_PREFIX: &str = "reauth_failures:"; -/// TTL of the counter (1 hour). Resets after a period of inactivity so a +/// TTL of the counters (1 hour). Resets after a period of inactivity so a /// legitimate user mistyping yesterday is not blocked today. const REAUTH_FAIL_TTL_SECS: u64 = 3600; - +/// The account-wide ceiling, in multiples of `LOCKOUT_THRESHOLD`: high enough +/// that one stolen session exhausting its own budget leaves the owner's +/// sessions able to re-authenticate, low enough to bound the guesses made +/// through several sessions. +const REAUTH_ACCOUNT_CEILING_FACTOR: i64 = 3; + +/// The account-wide re-authentication budget (reset by an administrator's +/// unlock). pub(crate) fn reauth_fail_key(user_id: Uuid) -> String { format!("{}{}", REAUTH_FAIL_PREFIX, user_id) } -/// Verifies a user's current password. Returns Err(ReauthenticationFailed) on mismatch: -/// the caller is already signed in, so "invalid email or password" would mislead. +/// The budget of one session. +fn session_reauth_fail_key(user_id: Uuid, session_id: Uuid) -> String { + format!("{REAUTH_FAIL_PREFIX}{user_id}:{session_id}") +} + +/// Verifies a user's current password from `session_id`. Returns +/// Err(ReauthenticationFailed) on mismatch: the caller is already signed in, +/// so "invalid email or password" would mislead. /// -/// Wraps a per-user Redis counter to prevent brute-force across re-auth -/// endpoints. Once the configured `LOCKOUT_THRESHOLD` is reached, the call -/// returns `AppError::AccountLocked` until the TTL elapses, regardless of -/// whether the next submitted password is correct. +/// Guesses are counted per session and per account. Once a session reaches +/// `LOCKOUT_THRESHOLD` failures, that session gets `AccountLocked` until the +/// window ends, whatever it submits; the account-wide ceiling is three times +/// higher. A stolen session thus locks itself out, not the owner's own +/// sessions, which keep revoking it and changing the password. pub async fn verify_password( state: &AppState, user_id: Uuid, + session_id: Uuid, password: &str, ) -> Result<(), AppError> { let user = user_repo::find_by_id(&state.db, user_id) @@ -53,18 +68,29 @@ pub async fn verify_password( .ok_or(AppError::NotFound)?; let threshold = i64::from(state.config.security.lockout_threshold); - let fail_key = reauth_fail_key(user_id); + let session_key = session_reauth_fail_key(user_id, session_id); + let account_key = reauth_fail_key(user_id); // Reserve the attempt before Argon2 runs, in one atomic step: parallel // guesses cannot all read a count below the threshold. Fails closed when - // Redis is unavailable, like every budget guarding a secret. - let attempt = redis_counter::consume( + // Redis is unavailable, like every budget guarding a secret. The session's + // budget comes first: once it is exhausted, its attempts stop counting + // against the account, so one stolen session adds at most + // `LOCKOUT_THRESHOLD` to the account's budget and never locks the owner. + let attempt = redis_counter::consume_in_order( &state.redis, - &[Budget { - key: &fail_key, - limit: threshold, - window_secs: REAUTH_FAIL_TTL_SECS, - }], + &[ + Budget { + key: &session_key, + limit: threshold, + window_secs: REAUTH_FAIL_TTL_SECS, + }, + Budget { + key: &account_key, + limit: threshold.saturating_mul(REAUTH_ACCOUNT_CEILING_FACTOR), + window_secs: REAUTH_FAIL_TTL_SECS, + }, + ], ) .await?; if attempt.exceeded { @@ -76,21 +102,23 @@ pub async fn verify_password( .map_err(|e| AppError::Internal(e.into()))?; if !valid { - if attempt.max_count() >= threshold { + if attempt.counts[0] >= threshold { // Threshold just reached: a distinct error, so the caller (and the // logs) can tell the lockout from a mistyped password. tracing::warn!( user_id = %user_id, - failures = attempt.max_count(), - "reauth lockout triggered for user" + failures = attempt.counts[0], + "reauth lockout triggered for session" ); return Err(AppError::AccountLocked); } return Err(AppError::ReauthenticationFailed); } - // A success clears the budget, so earlier typos do not count later. - redis_counter::reset(&state.redis, &[&fail_key]).await; + // A success proves the password: earlier typos stop counting, for this + // session and for the account. A session that exhausted its own budget + // stays locked until its window ends. + redis_counter::reset(&state.redis, &[&session_key, &account_key]).await; Ok(()) } @@ -109,32 +137,36 @@ pub async fn change_username( ip: Option, request_id: Option, ) -> Result<(), AppError> { - if user_repo::find_by_username(&state.db, new_username) - .await? - .is_some() - { + // Reserved names count as taken, as at registration: a rename accepted + // where a registration was refused would tell which address it named. + if user_repo::username_unavailable(&state.db, new_username).await? { return Err(AppError::Conflict("username_taken")); } - user_repo::update_username(&state.db, user_id, new_username) + // The rename and its audit entry commit together. + let mut tx = state.db.begin().await?; + user_repo::update_username(&mut *tx, user_id, new_username) .await // The pre-check can race with another rename; the constraint decides. .map_err(|e| { - AppError::from_unique_violation(e, &[("users_username_key", "username_taken")]) + AppError::from_unique_violation(e, &[("users_username_lower_key", "username_taken")]) })?; audit::append( - &state.db, + &mut *tx, &NewAuditEntry { user_id: Some(user_id), request_id, action: AuditAction::UsernameChanged, ip_address: ip, + // Neither name: audit metadata cannot be rewritten, and the entry + // outlives an erased account without identifying it. metadata: json!({}), }, ) .await .map_err(|e| AppError::Internal(e.into()))?; + tx.commit().await?; Ok(()) } @@ -191,6 +223,11 @@ pub async fn change_password( let mut tx = state.db.begin().await?; user_repo::update_password_hash(&mut *tx, user_id, &new_hash).await?; + // Like a reset: the new password is not locked by the guesses that locked + // the old one, or the owner could not sign in again after changing it. + user_repo::clear_lockout(&mut *tx, user_id).await?; + // A reset or sign-in link requested before the change would bypass it. + crate::repositories::token::revoke_mailbox_links(&mut tx, user_id).await?; // Other devices must sign in with the new password; the current session // too unless the caller keeps it. @@ -230,7 +267,79 @@ pub async fn change_password( events::wake(); auth_svc::invalidate_session_caches(state, &revoked_session_ids).await; + redis_counter::reset(&state.redis, &[&format!("login_try:{user_id}")]).await; + // A second-factor challenge opened with the old password dies with it, as + // after a reset: it would otherwise still open a session. + auth_svc::purge_user_pre_auth_and_email_change(state, user.id).await; + notify_password_changed(state, &user, Vec::new()).await; + + Ok(()) +} + +/// What opens the account besides its password: passkeys, verified second +/// factors, personal access tokens and external identities. Best effort: an +/// error leaves the list shorter, never the notification unsent. +pub(crate) async fn access_summary(state: &AppState, user_id: Uuid) -> Vec { + use crate::domain::two_factor::TwoFactorType; + use crate::repositories::{ + external_identity as identity_repo, passkey as passkey_repo, + personal_access_token as pat_repo, two_factor as tf_repo, + }; + + let (passkeys, methods, tokens, identities) = tokio::join!( + passkey_repo::find_by_user(&state.db, user_id), + tf_repo::find_by_user(&state.db, user_id), + pat_repo::find_active_by_user(&state.db, user_id), + identity_repo::find_by_user(&state.db, user_id), + ); + let mut access = Vec::new(); + access.extend( + passkeys + .unwrap_or_default() + .into_iter() + .map(|p| AccessItem { + kind: "passkey", + name: p.name, + }), + ); + access.extend( + methods + .unwrap_or_default() + .into_iter() + .filter(|m| m.is_verified) + .map(|m| AccessItem { + kind: match m.method_type { + TwoFactorType::Totp => "totp", + TwoFactorType::Email => "email", + }, + name: String::new(), + }), + ); + access.extend(tokens.unwrap_or_default().into_iter().map(|t| AccessItem { + kind: "personal_access_token", + name: t.name, + })); + access.extend( + identities + .unwrap_or_default() + .into_iter() + .map(|i| AccessItem { + kind: "external_identity", + name: i.provider, + }), + ); + access +} + +/// Tell the owner their password changed, listing what still opens the account +/// and what the change removed. +pub(crate) async fn notify_password_changed( + state: &AppState, + user: &User, + removed: Vec, +) { + let access = access_summary(state, user.id).await; let mailer = state.mailer.clone(); let templates = state.templates.clone(); let mail_cfg = state.config.mail.clone(); @@ -245,11 +354,34 @@ pub async fn change_password( &email_to, &username, &locale, + &access, + &removed, ) .await }); +} - Ok(()) +/// Tell the owner a way into the account was added (passkey, personal access +/// token, external identity). +pub(crate) async fn notify_access_added(state: &AppState, user_id: Uuid, access: AccessItem) { + let Ok(Some(user)) = user_repo::find_by_id(&state.db, user_id).await else { + return; + }; + let mailer = state.mailer.clone(); + let templates = state.templates.clone(); + let mail_cfg = state.config.mail.clone(); + super::email::dispatch_best_effort("access_added_email", async move { + super::email::send_access_added( + &mailer, + templates.as_ref(), + &mail_cfg, + &user.email, + &user.username, + &user.preferred_locale, + &access, + ) + .await + }); } /// Permanently deletes the user account and all associated data. @@ -292,22 +424,30 @@ pub(crate) async fn erase_account( ip: Option, request_id: Option, ) -> Result<(), AppError> { - // Collect active session IDs before deletion so we can invalidate their - // Redis cache entries - otherwise the session validity cache would stay - // warm for up to SESSION_CACHE_TTL_SECS after the account is gone. - let session_ids: Vec<_> = session_repo::find_active_by_user(&state.db, user_id) - .await - .unwrap_or_default() - .into_iter() - .map(|s| s.id) - .collect(); - // Downstream services erase their data on `user.deleted`, and the user id is // the only key to it. The audit entry, the event and the deletion commit // together: the account is never gone without its event, and the event // never announces a deletion that failed. The event waits in the outbox // while NATS is down. + // Deleting the last active account able to manage roles would leave the + // deployment without one: refused, whoever asks (the owner or an admin). let mut tx = state.db.begin().await?; + // Read under the account's lock: a role granted meanwhile is seen. + user_repo::lock_row(&mut *tx, user_id).await?; + // The sessions whose caches go with the account, read under its lock: one + // opened meanwhile is forgotten like the others, instead of staying warm + // for SESSION_CACHE_TTL_SECS after the account is gone. + let session_ids: Vec<_> = session_repo::find_active_by_user(&mut *tx, user_id) + .await? + .into_iter() + .map(|s| s.id) + .collect(); + let manages_roles = crate::repositories::role::user_has_permission( + &mut *tx, + user_id, + crate::domain::role::ROLES_MANAGE, + ) + .await?; // Appended before the deletion: the foreign key then sets its user_id to NULL. audit::append( @@ -327,8 +467,10 @@ pub(crate) async fn erase_account( events::enqueue(&mut *tx, "user.deleted", &events::UserDeleted { user_id }).await?; - user_repo::forget_traces(&mut *tx, user_id).await?; - user_repo::delete(&mut *tx, user_id).await?; + user_repo::erase(&mut *tx, user_id).await?; + if manages_roles { + super::admin::roles::keep_an_administrator(&mut tx).await?; + } tx.commit().await?; events::wake(); @@ -401,3 +543,19 @@ pub async fn export_data( .await? .ok_or(AppError::NotFound) } + +/// Refuse, before the transaction commits, a change that leaves an account +/// holding administrative permissions without any second factor: the +/// administration would refuse it, but the permissions would stay in its +/// tokens behind its password alone. Run after locking the account's row. +pub(crate) async fn keep_a_second_factor_for_administrators( + tx: &mut sqlx::PgConnection, + user_id: Uuid, +) -> Result<(), AppError> { + if crate::repositories::role::holds_administration(&mut *tx, user_id).await? + && !user_repo::has_second_factor(&mut *tx, user_id).await? + { + return Err(AppError::Conflict("administrator_needs_second_factor")); + } + Ok(()) +} diff --git a/src/services/webhooks.rs b/src/services/webhooks.rs index a0a147e..c8d7b62 100644 --- a/src/services/webhooks.rs +++ b/src/services/webhooks.rs @@ -100,7 +100,7 @@ async fn attempt(state: &AppState, delivery: &ClaimedDelivery) -> Outcome { let failed = |status, error: String| Outcome::Failed { status, error }; let key = match state .keyring - .decrypt(&delivery.secret) + .decrypt(&delivery.secret, delivery.endpoint_id.as_bytes()) .ok() .and_then(|secret| webhook::secret_bytes(&secret)) { @@ -165,8 +165,12 @@ async fn attempt(state: &AppState, delivery: &ClaimedDelivery) -> Outcome { Some(response.status().as_u16()), format!("endpoint answered {}", response.status()), ), + // Fixed messages: the error of the HTTP client carries the full URL, + // whose path or query may hold a token of the endpoint's own. Err(e) if e.is_timeout() => failed(None, "timed out".into()), - Err(e) => failed(None, format!("request failed: {e}")), + Err(e) if e.is_connect() => failed(None, "connection failed".into()), + Err(e) if e.is_redirect() => failed(None, "redirect refused".into()), + Err(_) => failed(None, "request failed".into()), } } @@ -258,24 +262,57 @@ fn checked<'a>( Ok((events, description)) } -fn new_secret(state: &AppState) -> Result<(String, String), AppError> { +/// A new signing secret for endpoint `id`, and its ciphertext bound to it. +fn new_secret(state: &AppState, id: Uuid) -> Result<(String, String), AppError> { let secret = webhook::format_secret(&crypto::random_bytes::<32>()); let encrypted = state .keyring - .encrypt(&secret) + .encrypt(&secret, id.as_bytes()) .map_err(|e| AppError::Internal(anyhow::anyhow!("cannot encrypt webhook secret: {e:?}")))?; Ok((secret, encrypted)) } +/// Pointing a webhook somewhere sends it every account event of its +/// subscription: creating one, changing where it points and rotating its +/// secret need a recent re-authentication, like granting a role. +async fn require_reauth( + state: &AppState, + actor: &Actor, + reason: &'static str, +) -> Result<(), AppError> { + crate::services::reauth::require_recent_reauth_or_password( + state, + actor.user_id, + actor.session_id, + None, + actor.ip, + actor.request_id, + reason, + ) + .await +} + +/// The host of a webhook URL, for the audit log: enough to trace where events +/// went, without the path or query, which may carry a token of their own. +fn url_host(url: &str) -> Option { + reqwest::Url::parse(url) + .ok() + .and_then(|url| url.host_str().map(str::to_owned)) +} + pub async fn create( state: &AppState, actor: &Actor, input: &EndpointInput<'_>, ) -> Result { let (events, description) = checked(state, input)?; - let (secret, encrypted) = new_secret(state)?; + require_reauth(state, actor, "admin_create_webhook").await?; + let id = Uuid::new_v4(); + let (secret, encrypted) = new_secret(state, id)?; + let mut tx = state.db.begin().await?; let endpoint = webhook_repo::create_endpoint( - &state.db, + &mut *tx, + id, &EndpointSettings { url: input.url, description, @@ -285,22 +322,38 @@ pub async fn create( &encrypted, ) .await?; - audit_change(state, actor, AuditAction::WebhookCreated, endpoint.id).await?; + audit_change( + &mut tx, + actor, + AuditAction::WebhookCreated, + endpoint.id, + json!({ "host": url_host(input.url) }), + ) + .await?; + tx.commit().await?; Ok(SavedEndpoint { endpoint, secret: Some(secret), }) } +/// Change an endpoint. Pointing it at another host gives it a new signing +/// secret, returned once: the former host would otherwise keep a secret still +/// valid for the endpoint, and could sign deliveries to the new one. pub async fn update( state: &AppState, actor: &Actor, id: Uuid, input: &EndpointInput<'_>, -) -> Result { +) -> Result { let (events, description) = checked(state, input)?; + require_reauth(state, actor, "admin_update_webhook").await?; + let previous = webhook_repo::find_endpoint(&state.db, id) + .await? + .ok_or(AppError::NotFound)?; + let mut tx = state.db.begin().await?; let endpoint = webhook_repo::update_endpoint( - &state.db, + &mut *tx, id, &EndpointSettings { url: input.url, @@ -311,53 +364,119 @@ pub async fn update( ) .await? .ok_or(AppError::NotFound)?; - audit_change(state, actor, AuditAction::WebhookUpdated, id).await?; - Ok(endpoint) + let moved = url_host(&previous.url) != url_host(input.url); + let secret = if moved { + let (secret, encrypted) = new_secret(state, id)?; + webhook_repo::replace_secret(&mut *tx, id, &encrypted).await?; + Some(secret) + } else { + None + }; + audit_change( + &mut tx, + actor, + AuditAction::WebhookUpdated, + id, + json!({ + "previous_host": url_host(&previous.url), + "host": url_host(input.url), + "secret_rotated": moved, + }), + ) + .await?; + tx.commit().await?; + Ok(SavedEndpoint { endpoint, secret }) } pub async fn rotate_secret(state: &AppState, actor: &Actor, id: Uuid) -> Result { - let (secret, encrypted) = new_secret(state)?; - if !webhook_repo::replace_secret(&state.db, id, &encrypted).await? { + require_reauth(state, actor, "admin_webhook_secret").await?; + let (secret, encrypted) = new_secret(state, id)?; + let mut tx = state.db.begin().await?; + if !webhook_repo::replace_secret(&mut *tx, id, &encrypted).await? { return Err(AppError::NotFound); } - audit_change(state, actor, AuditAction::WebhookSecretRotated, id).await?; + audit_change( + &mut tx, + actor, + AuditAction::WebhookSecretRotated, + id, + json!({}), + ) + .await?; + tx.commit().await?; Ok(secret) } +/// Deleting an endpoint drops its pending deliveries: a recent +/// re-authentication, like every other change to where events go. pub async fn delete(state: &AppState, actor: &Actor, id: Uuid) -> Result<(), AppError> { - if !webhook_repo::delete_endpoint(&state.db, id).await? { + require_reauth(state, actor, "admin_delete_webhook").await?; + let previous = webhook_repo::find_endpoint(&state.db, id) + .await? + .ok_or(AppError::NotFound)?; + let mut tx = state.db.begin().await?; + if !webhook_repo::delete_endpoint(&mut *tx, id).await? { return Err(AppError::NotFound); } - audit_change(state, actor, AuditAction::WebhookDeleted, id).await + audit_change( + &mut tx, + actor, + AuditAction::WebhookDeleted, + id, + json!({ "host": url_host(&previous.url) }), + ) + .await?; + tx.commit().await?; + Ok(()) } +/// Send a delivery again: audited like every change an administrator makes, +/// since it replays account events to the endpoint. pub async fn redeliver( state: &AppState, + actor: &Actor, endpoint_id: Uuid, delivery_id: Uuid, ) -> Result<(), AppError> { - if !webhook_repo::redeliver(&state.db, endpoint_id, delivery_id).await? { + require_reauth(state, actor, "admin_redeliver_webhook").await?; + let mut tx = state.db.begin().await?; + if !webhook_repo::redeliver(&mut *tx, endpoint_id, delivery_id).await? { return Err(AppError::NotFound); } + audit_change( + &mut tx, + actor, + AuditAction::WebhookUpdated, + endpoint_id, + json!({ "redelivered": delivery_id }), + ) + .await?; + tx.commit().await?; wake(); Ok(()) } -/// The URL is left out of the audit log: it may carry a token of its own. +/// Audited in the change's transaction. The URL itself is left out, only its +/// host is kept: the path or query may carry a token of its own. async fn audit_change( - state: &AppState, + tx: &mut sqlx::PgConnection, actor: &Actor, action: AuditAction, id: Uuid, + extra: serde_json::Value, ) -> Result<(), AppError> { + let mut metadata = json!({ "webhook_id": id }); + if let (Some(metadata), Some(extra)) = (metadata.as_object_mut(), extra.as_object()) { + metadata.extend(extra.clone()); + } audit::append( - &state.db, + &mut *tx, &NewAuditEntry { user_id: Some(actor.user_id), request_id: actor.request_id, action, ip_address: actor.ip, - metadata: actor.metadata(json!({ "webhook_id": id })), + metadata: actor.metadata(metadata), }, ) .await?; diff --git a/src/state.rs b/src/state.rs index b61a7d7..86887a4 100644 --- a/src/state.rs +++ b/src/state.rs @@ -77,6 +77,9 @@ pub struct AppState { /// JWKS document served at /.well-known/jwks.json, precomputed at startup /// (current key first, previous key appended during rotation windows). pub jwt_jwks: Arc, + /// The last public readiness answer and when it was taken: a flood of + /// `GET /ready` costs one database query and one Redis ping per second. + pub readiness_cache: Arc>>, } /// JWT key material parsed once at startup. @@ -166,6 +169,7 @@ impl AppState { jwt_verifying_keys: Arc::new(jwt_keys.verifying_keys), jwt_kid: jwt_keys.kid, jwt_jwks: jwt_keys.jwks, + readiness_cache: Arc::default(), config: Arc::new(config), }) } @@ -247,6 +251,7 @@ fn parse_jwt_keys(config: &Config) -> Result { // (until the tokens it signed have expired). let mut jwks_keys = vec![jwt::public_key_to_jwk(&p256_key, &kid)]; let mut verifying = vec![(kid.clone(), verifying_key.clone())]; + let mut points = vec![(kid.clone(), p256_key.to_encoded_point(false))]; for (variable, pem) in [ ("JWT_NEXT_PUBLIC_KEY", config.jwt.next_public_key.as_deref()), ( @@ -258,9 +263,20 @@ fn parse_jwt_keys(config: &Config) -> Result { let key = jwt::parse_verifying_key(pem).map_err(|e| invalid(variable, e))?; let p256 = jwt::parse_p256_verifying_key(pem).map_err(|e| invalid(variable, e))?; let extra_kid = jwt::compute_kid(&p256); - if verifying.iter().any(|(held, _)| *held == extra_kid) { - continue; + let point = p256.to_encoded_point(false); + match points.iter().find(|(held, _)| *held == extra_kid) { + // The same key named twice (a rotation not started yet). + Some((_, held)) if *held == point => continue, + // Two keys under one kid: one of them would be silently ignored. + Some(_) => { + return Err(AppStateError::Config(ConfigError::Invalid { + key: variable.into(), + reason: "this key shares its key id with another configured key".into(), + })); + } + None => {} } + points.push((extra_kid.clone(), point)); jwks_keys.push(jwt::public_key_to_jwk(&p256, &extra_kid)); verifying.push((extra_kid, key)); } @@ -285,8 +301,13 @@ async fn build_pg_pool(cfg: &DatabaseConfig) -> Result { .await } +/// The client for outgoing calls: CAPTCHA verification, the breached-password +/// range API and external identity providers. Redirects are not followed: a +/// token endpoint answering with one would otherwise receive the client +/// secret, code and verifier again at whatever host it names. fn build_http_client(cfg: &CaptchaConfig) -> Result { Client::builder() + .redirect(reqwest::redirect::Policy::none()) .connect_timeout(Duration::from_secs(cfg.request_timeout_secs)) .timeout(Duration::from_secs(cfg.request_timeout_secs)) .build() diff --git a/src/utils/crypto.rs b/src/utils/crypto.rs index 5a1c3bc..e8f34ca 100644 --- a/src/utils/crypto.rs +++ b/src/utils/crypto.rs @@ -1,16 +1,19 @@ -//! Cryptographic primitives: hashing, random token generation, AES-256-GCM encryption. +//! Cryptographic primitives: hashing, constant-time comparison, random +//! generation, keyed digests of one-time codes and AES-256-GCM encryption. //! -//! AES-256-GCM is used exclusively to encrypt TOTP secrets at rest before storing -//! them in the database. The nonce (12 bytes) is prepended to the ciphertext -//! and the whole thing is base64-encoded for storage. +//! AES-256-GCM encrypts secrets at rest (TOTP secrets, webhook signing +//! secrets). The nonce (12 bytes) is prepended to the ciphertext and the whole +//! thing is base64-encoded for storage; since `v2`, the ciphertext is bound to +//! the row it belongs to through associated data. use std::fmt::Write; use aes_gcm::{ Aes256Gcm, Key, Nonce, - aead::{Aead, KeyInit}, + aead::{Aead, KeyInit, Payload}, }; use base64::{Engine, engine::general_purpose::STANDARD as B64}; +use hmac::{Hmac, Mac}; use rand_core::{OsRng, RngCore}; use sha2::{Digest, Sha256}; @@ -31,25 +34,52 @@ pub enum CryptoError { // Hashing /// Returns the SHA-256 digest of the input. Used to hash tokens before DB storage. +/// The id under which a bearer token's state is kept in Redis: the hex SHA-256 +/// of the token. A read of Redis (a dump, a replica) yields no usable token. +pub fn token_id(token: &str) -> String { + sha256(token.as_bytes()) + .iter() + .map(|b| format!("{b:02x}")) + .collect() +} + pub fn sha256(data: &[u8]) -> [u8; 32] { let mut hasher = Sha256::new(); hasher.update(data); hasher.finalize().into() } +/// Whether two byte strings are equal, in time independent of their contents: +/// comparing a secret or its digest byte by byte and stopping at the first +/// difference tells an attacker how much of a guess was right. +pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + use subtle::ConstantTimeEq; + a.len() == b.len() && bool::from(a.ct_eq(b)) +} + // Random generation -/// Generates a 32-byte cryptographically secure random token, base64url-encoded. -/// Used for email verification and password reset tokens. -/// A 6-digit numeric one-time code (000000..999999, ~20 bits). +/// A uniformly drawn index below `bound` (1..=u32::MAX), from the OS CSPRNG, +/// by rejection sampling: no value is favoured by a modulo. +pub fn random_below(bound: u32) -> u32 { + assert!(bound > 0, "an empty range has no index"); + let zone = u32::MAX - (u32::MAX % bound); + loop { + let draw = OsRng.next_u32(); + if draw < zone { + return draw % bound; + } + } +} + +/// A 6-digit numeric one-time code (000000..999999, ~20 bits), from the OS +/// CSPRNG. /// /// Its strength is not the entropy alone: every flow using it pairs the code -/// with an attempt budget, backoff and a short TTL. +/// with an attempt budget, backoff and a short TTL, and stores it as a keyed +/// digest ([`Keyring::otp_digest`]). pub fn generate_otp() -> String { - use rand::RngExt; - - let code: u32 = rand::rng().random_range(0..1_000_000); - format!("{code:06}") + format!("{:06}", random_below(1_000_000)) } /// `N` bytes from the OS CSPRNG. @@ -59,6 +89,7 @@ pub fn random_bytes() -> [u8; N] { bytes } +/// Generates a 32-byte cryptographically secure random token, base64url-encoded. pub fn generate_token() -> String { let mut bytes = [0u8; 32]; OsRng.fill_bytes(&mut bytes); @@ -97,16 +128,27 @@ pub fn decode_encryption_key(b64: &str) -> Result<[u8; 32], CryptoError> { // Keyring -/// Prefix of versioned ciphertexts: `v1:{kid}:{base64(nonce || ciphertext)}`. -const V1_PREFIX: &str = "v1:"; +/// Prefix of ciphertexts bound to their row: `v2:{kid}:{base64(...)}`, sealed +/// with `V2_AAD_LABEL || context` as associated data. +const V2_PREFIX: &str = "v2:"; +const V2_AAD_LABEL: &[u8] = b"auth-api v2:"; +/// HKDF label of the key identifier written in ciphertexts. +const KID_INFO: &[u8] = b"key id"; +/// HKDF parameters of the key that digests one-time codes. +const OTP_KEY_SALT: &[u8] = b"auth-api keyring"; +const OTP_KEY_INFO: &[u8] = b"auth-api otp v1"; /// Keys for data encrypted at rest: the current key, which encrypts, and the /// previous one, still accepted for reading while a rotation runs. /// -/// Ciphertexts name their key (`v1:{kid}:...`), so a read goes straight to the +/// Ciphertexts name their key (`v2:{kid}:...`), so a read goes straight to the /// right key and a rotation can tell which rows are done: it can stop and pick -/// up where it left off. Values written before versioning (bare base64) are -/// read with the current key, then the previous one. +/// up where it left off. Each `v2` ciphertext is bound to the row it belongs to +/// (a context such as the account id): swapping two rows' ciphertexts makes +/// both unreadable instead of trading secrets. `v1` values (no context) and +/// values written before versioning (bare base64) are still read. +/// +/// Each key also derives, by HKDF, the key of the one-time code digests. #[derive(Clone)] pub struct Keyring { current: KeyEntry, @@ -117,19 +159,50 @@ pub struct Keyring { struct KeyEntry { kid: String, key: [u8; 32], + otp_key: [u8; 32], } impl KeyEntry { fn new(key: [u8; 32]) -> Self { - // First 8 bytes of the key's SHA-256: identifies it without revealing it. - let kid = sha256(&key)[..8] - .iter() - .fold(String::with_capacity(16), |mut out, byte| { + // Derived with its own HKDF label: names the key in every ciphertext + // without handing out a hash that checks a guessed key. + let kid = hkdf_sha256(&key, OTP_KEY_SALT, KID_INFO)[..8].iter().fold( + String::with_capacity(16), + |mut out, byte| { let _ = write!(out, "{byte:02x}"); out - }); - Self { kid, key } + }, + ); + Self { + kid, + key, + otp_key: hkdf_sha256(&key, OTP_KEY_SALT, OTP_KEY_INFO), + } } + + fn otp_digest(&self, purpose: &str, subject: &[u8], code: &str) -> [u8; 32] { + let mut mac = + Hmac::::new_from_slice(&self.otp_key).expect("HMAC accepts keys of any length"); + // Length-prefixed fields: no two (purpose, subject, code) triples + // produce the same input. + for field in [purpose.as_bytes(), subject, code.as_bytes()] { + mac.update(&(field.len() as u64).to_be_bytes()); + mac.update(field); + } + mac.finalize().into_bytes().into() + } +} + +/// HKDF-SHA256 (RFC 5869) for one 32-byte output block. +fn hkdf_sha256(ikm: &[u8], salt: &[u8], info: &[u8]) -> [u8; 32] { + let mut extract = + Hmac::::new_from_slice(salt).expect("HMAC accepts keys of any length"); + extract.update(ikm); + let prk = extract.finalize().into_bytes(); + let mut expand = Hmac::::new_from_slice(&prk).expect("HMAC accepts keys of any length"); + expand.update(info); + expand.update(&[1]); + expand.finalize().into_bytes().into() } impl Keyring { @@ -153,36 +226,71 @@ impl Keyring { &self.current.kid } - pub fn encrypt(&self, plaintext: &str) -> Result { + /// Encrypt `plaintext` under the current key, bound to `context` (the id of + /// the row it belongs to): it decrypts only with the same context. + pub fn encrypt(&self, plaintext: &str, context: &[u8]) -> Result { Ok(format!( - "{V1_PREFIX}{}:{}", + "{V2_PREFIX}{}:{}", self.current.kid, - encrypt(plaintext, &self.current.key)? + encrypt_with_aad(plaintext, &self.current.key, &v2_aad(context))? )) } - pub fn decrypt(&self, stored: &str) -> Result { - match stored.strip_prefix(V1_PREFIX) { - Some(rest) => { - let (kid, body) = rest.split_once(':').ok_or(CryptoError::InvalidInput)?; - decrypt(body, self.key_for(kid).ok_or(CryptoError::UnknownKey)?) - } - // Legacy value: no key name, so try the keys in order. - None => decrypt(stored, &self.current.key).or_else(|err| match &self.previous { - Some(previous) => decrypt(stored, &previous.key), - None => Err(err), - }), - } + /// Decrypt a value written by [`Keyring::encrypt`] for `context`. Only the + /// `v2` format is read: a value without its row bound in could be moved to + /// another account's row, and no deployment holds one. + pub fn decrypt(&self, stored: &str, context: &[u8]) -> Result { + let rest = stored + .strip_prefix(V2_PREFIX) + .ok_or(CryptoError::InvalidInput)?; + let (kid, body) = rest.split_once(':').ok_or(CryptoError::InvalidInput)?; + let key = self.key_for(kid).ok_or(CryptoError::UnknownKey)?; + decrypt_with_aad(body, key, &v2_aad(context)) } - /// Whether `stored` still has to be rewritten under the current key. + /// Whether `stored` still has to be rewritten: under another key, or in a + /// format older than `v2`. pub fn needs_rotation(&self, stored: &str) -> bool { stored - .strip_prefix(V1_PREFIX) + .strip_prefix(V2_PREFIX) .and_then(|rest| rest.split_once(':')) .is_none_or(|(kid, _)| kid != self.current.kid) } + /// Whether `stored` is a `v2` value under a key this keyring holds: what + /// [`Keyring::decrypt`] can read. Anything else stops the start-up. + pub fn knows_key_of(&self, stored: &str) -> bool { + stored + .strip_prefix(V2_PREFIX) + .and_then(|rest| rest.split_once(':')) + .is_some_and(|(kid, _)| self.key_for(kid).is_some()) + } + + /// Identifiers of the keys this keyring holds, current first. + pub fn kids(&self) -> Vec<&str> { + std::iter::once(&self.current) + .chain(self.previous.as_ref()) + .map(|entry| entry.kid.as_str()) + .collect() + } + + /// Keyed digest of a one-time code, under the current key: what is stored. + /// `purpose` separates the flows, `subject` binds the code to its account: + /// a digest read from the database or Redis cannot be brute-forced offline + /// without the key, nor matched against another flow or account. + pub fn otp_digest(&self, purpose: &str, subject: &[u8], code: &str) -> [u8; 32] { + self.current.otp_digest(purpose, subject, code) + } + + /// Digests of a submitted code under every key held, current first: a code + /// issued just before a key rotation still verifies. + pub fn otp_digests(&self, purpose: &str, subject: &[u8], code: &str) -> Vec<[u8; 32]> { + std::iter::once(&self.current) + .chain(self.previous.as_ref()) + .map(|entry| entry.otp_digest(purpose, subject, code)) + .collect() + } + fn key_for(&self, kid: &str) -> Option<&[u8; 32]> { std::iter::once(&self.current) .chain(self.previous.as_ref()) @@ -193,8 +301,32 @@ impl Keyring { // AES-256-GCM -/// Encrypts plaintext using AES-256-GCM. Returns base64(nonce || ciphertext). -pub fn encrypt(plaintext: &str, key: &[u8; 32]) -> Result { +fn v2_aad(context: &[u8]) -> Vec { + let mut aad = Vec::with_capacity(V2_AAD_LABEL.len() + context.len()); + aad.extend_from_slice(V2_AAD_LABEL); + aad.extend_from_slice(context); + aad +} + +/// Encrypts plaintext using AES-256-GCM without associated data. Returns +/// base64(nonce || ciphertext). Only the tests use it: a ciphertext bound to +/// nothing could be moved from one row to another, which the `v2` format of +/// [`Keyring`] rules out. +#[cfg(test)] +fn encrypt(plaintext: &str, key: &[u8; 32]) -> Result { + encrypt_with_aad(plaintext, key, &[]) +} + +/// A ciphertext in the unversioned format written before `v2`, bound to no +/// row: for tests that check such values are refused. Never for storage. +#[doc(hidden)] +pub fn legacy_unbound_ciphertext(plaintext: &str, key: &[u8; 32]) -> Result { + encrypt_with_aad(plaintext, key, &[]) +} + +/// AES-256-GCM with associated data: authenticated, not encrypted, and +/// required again to decrypt. +fn encrypt_with_aad(plaintext: &str, key: &[u8; 32], aad: &[u8]) -> Result { let cipher = Aes256Gcm::new(&Key::::from(*key)); // aead 0.6 dropped `AeadCore::generate_nonce`; fill the 96-bit nonce // directly from the OS CSPRNG instead. @@ -203,7 +335,13 @@ pub fn encrypt(plaintext: &str, key: &[u8; 32]) -> Result { let nonce = Nonce::from(nonce_bytes); let ciphertext = cipher - .encrypt(&nonce, plaintext.as_bytes()) + .encrypt( + &nonce, + Payload { + msg: plaintext.as_bytes(), + aad, + }, + ) .map_err(|_| CryptoError::Encryption)?; // Prepend the 12-byte nonce so we can recover it during decryption @@ -214,19 +352,14 @@ pub fn encrypt(plaintext: &str, key: &[u8; 32]) -> Result { Ok(B64.encode(combined)) } -/// Re-encrypts a ciphertext from `old_key` to `new_key` in a single step. -/// Used during encryption key rotation to migrate all stored TOTP secrets. -pub fn re_encrypt( - encoded: &str, - old_key: &[u8; 32], - new_key: &[u8; 32], -) -> Result { - let plaintext = decrypt(encoded, old_key)?; - encrypt(&plaintext, new_key) +/// Decrypts a value produced by `encrypt`. +#[cfg(test)] +fn decrypt(encoded: &str, key: &[u8; 32]) -> Result { + decrypt_with_aad(encoded, key, &[]) } -/// Decrypts a value produced by `encrypt`. -pub fn decrypt(encoded: &str, key: &[u8; 32]) -> Result { +/// Decrypts a value produced by [`encrypt_with_aad`] with the same data. +fn decrypt_with_aad(encoded: &str, key: &[u8; 32], aad: &[u8]) -> Result { let combined = B64.decode(encoded).map_err(|_| CryptoError::InvalidInput)?; // 12-byte nonce + at least 16-byte GCM tag @@ -239,7 +372,13 @@ pub fn decrypt(encoded: &str, key: &[u8; 32]) -> Result { let nonce = Nonce::try_from(nonce_bytes).map_err(|_| CryptoError::InvalidInput)?; let plaintext = cipher - .decrypt(&nonce, ciphertext) + .decrypt( + &nonce, + Payload { + msg: ciphertext, + aad, + }, + ) .map_err(|_| CryptoError::Decryption)?; String::from_utf8(plaintext).map_err(|_| CryptoError::InvalidInput) @@ -247,6 +386,20 @@ pub fn decrypt(encoded: &str, key: &[u8; 32]) -> Result { #[cfg(test)] mod tests { + /// The key identifier is not a plain hash of the key: it cannot check a + /// guessed key offline (SEC-71). + #[test] + fn the_key_id_is_not_a_hash_of_the_key() { + let key = [9u8; 32]; + let keyring = super::Keyring::new(key, None); + let plain: String = super::sha256(&key)[..8] + .iter() + .map(|b| format!("{b:02x}")) + .collect(); + assert_ne!(keyring.current_kid(), plain); + assert_eq!(keyring.current_kid().len(), 16); + } + use super::*; const KEY: &[u8; 32] = &[42u8; 32]; @@ -349,33 +502,102 @@ mod tests { #[test] fn keyring_writes_versioned_ciphertexts_it_can_read() { let keyring = Keyring::new([1u8; 32], None); - let stored = keyring.encrypt("JBSWY3DPEHPK3PXP").unwrap(); - assert!(stored.starts_with(&format!("v1:{}:", keyring.current_kid()))); - assert_eq!(keyring.decrypt(&stored).unwrap(), "JBSWY3DPEHPK3PXP"); + let stored = keyring.encrypt("JBSWY3DPEHPK3PXP", b"row-1").unwrap(); + assert!(stored.starts_with(&format!("v2:{}:", keyring.current_kid()))); + assert_eq!( + keyring.decrypt(&stored, b"row-1").unwrap(), + "JBSWY3DPEHPK3PXP" + ); assert!(!keyring.needs_rotation(&stored)); + assert!(keyring.knows_key_of(&stored)); } #[test] - fn keyring_reads_the_previous_key_and_the_legacy_format() { - let old = Keyring::new([1u8; 32], None); - let versioned_old = old.encrypt("secret").unwrap(); - let legacy_old = encrypt("secret", &[1u8; 32]).unwrap(); + fn a_ciphertext_moved_to_another_row_no_longer_decrypts() { + let keyring = Keyring::new([1u8; 32], None); + let stored = keyring.encrypt("victim's secret", b"victim").unwrap(); + assert!(matches!( + keyring.decrypt(&stored, b"attacker"), + Err(CryptoError::Decryption) + )); + } + #[test] + fn keyring_reads_the_previous_key_and_refuses_unbound_formats() { + let old = Keyring::new([1u8; 32], None); + let v2_old = old.encrypt("secret", b"row").unwrap(); let rotating = Keyring::new([2u8; 32], Some([1u8; 32])); - assert_eq!(rotating.decrypt(&versioned_old).unwrap(), "secret"); - assert_eq!(rotating.decrypt(&legacy_old).unwrap(), "secret"); - assert!(rotating.needs_rotation(&versioned_old)); - assert!(rotating.needs_rotation(&legacy_old)); + assert_eq!(rotating.decrypt(&v2_old, b"row").unwrap(), "secret"); + assert!(rotating.needs_rotation(&v2_old)); + + // Values without their row bound in are not read, and stop the start. + let v1 = format!( + "v1:{}:{}", + old.current_kid(), + encrypt("secret", &[1u8; 32]).unwrap() + ); + let unversioned = encrypt("secret", &[1u8; 32]).unwrap(); + for stored in [&v1, &unversioned] { + assert!(matches!( + old.decrypt(stored, b"row"), + Err(CryptoError::InvalidInput) + )); + assert!(!old.knows_key_of(stored)); + } } #[test] fn keyring_refuses_a_key_it_does_not_hold() { - let stored = Keyring::new([1u8; 32], None).encrypt("secret").unwrap(); + let stored = Keyring::new([1u8; 32], None) + .encrypt("secret", b"row") + .unwrap(); let other = Keyring::new([2u8; 32], None); assert!(matches!( - other.decrypt(&stored), + other.decrypt(&stored, b"row"), Err(CryptoError::UnknownKey) )); + assert!(!other.knows_key_of(&stored)); + } + + #[test] + fn otp_digests_are_keyed_bound_and_survive_a_rotation() { + let keyring = Keyring::new([1u8; 32], None); + let digest = keyring.otp_digest("email_2fa", b"user-1", "123456"); + assert_ne!(digest, sha256(b"123456"), "not a bare hash"); + assert_ne!(digest, keyring.otp_digest("email_2fa", b"user-2", "123456")); + assert_ne!( + digest, + keyring.otp_digest("email_change", b"user-1", "123456") + ); + assert_ne!( + digest, + Keyring::new([2u8; 32], None).otp_digest("email_2fa", b"user-1", "123456") + ); + + let rotating = Keyring::new([2u8; 32], Some([1u8; 32])); + assert!( + rotating + .otp_digests("email_2fa", b"user-1", "123456") + .contains(&digest) + ); + } + + #[test] + fn constant_time_equality_compares_contents_and_lengths() { + assert!(constant_time_eq(b"digest", b"digest")); + assert!(!constant_time_eq(b"digest", b"digesT")); + assert!(!constant_time_eq(b"digest", b"digest+")); + } + + #[test] + fn random_indexes_stay_in_range_and_cover_it() { + let mut seen = [false; 8]; + for _ in 0..1_000 { + let index = random_below(8) as usize; + seen[index] = true; + } + assert!(seen.iter().all(|&s| s)); + assert_eq!(random_below(1), 0); } mod properties { @@ -394,20 +616,20 @@ mod tests { ) { prop_assume!(old != new); let before = Keyring::new(old, None); - let stored = before.encrypt(&secret).unwrap(); - prop_assert_eq!(before.decrypt(&stored).unwrap(), secret.clone()); + let stored = before.encrypt(&secret, b"row").unwrap(); + prop_assert_eq!(before.decrypt(&stored, b"row").unwrap(), secret.clone()); prop_assert!(!before.needs_rotation(&stored)); // During a rotation the old ciphertext stays readable... let during = Keyring::new(new, Some(old)); prop_assert!(during.needs_rotation(&stored)); - prop_assert_eq!(during.decrypt(&stored).unwrap(), secret.clone()); + prop_assert_eq!(during.decrypt(&stored, b"row").unwrap(), secret.clone()); // ...and once rewritten, no longer needs the old key. - let rewritten = during.encrypt(&secret).unwrap(); + let rewritten = during.encrypt(&secret, b"row").unwrap(); prop_assert!(!during.needs_rotation(&rewritten)); - prop_assert_eq!(Keyring::new(new, None).decrypt(&rewritten).unwrap(), secret); - prop_assert!(before.decrypt(&rewritten).is_err()); + prop_assert_eq!(Keyring::new(new, None).decrypt(&rewritten, b"row").unwrap(), secret); + prop_assert!(before.decrypt(&rewritten, b"row").is_err()); } } } @@ -429,14 +651,6 @@ mod tests { assert_ne!(a, b); } - #[test] - fn re_encryption_moves_a_secret_to_the_new_key() { - let stored = encrypt("JBSWY3DPEHPK3PXP", KEY).unwrap(); - let moved = re_encrypt(&stored, KEY, OTHER_KEY).unwrap(); - assert_eq!(decrypt(&moved, OTHER_KEY).unwrap(), "JBSWY3DPEHPK3PXP"); - assert!(decrypt(&moved, KEY).is_err()); - } - #[test] fn an_empty_secret_round_trips() { // Nonce and tag only: the shortest ciphertext there is. diff --git a/src/utils/jwt.rs b/src/utils/jwt.rs index 62ee29d..cf1e36d 100644 --- a/src/utils/jwt.rs +++ b/src/utils/jwt.rs @@ -65,6 +65,14 @@ pub struct Claims { /// token belongs to no user and no session: `sid` is nil. #[serde(default, skip_serializing_if = "Option::is_none")] pub client_id: Option, + /// What `sub` names: `user`, or `client` for a client credentials token. + /// A resource server authorizing by `sub` tells them apart with it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sub_type: Option, + /// The kind of session a user's token comes from: `web`, `device`, or + /// `personal_access_token` for a script. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_type: Option, } impl Claims { @@ -82,6 +90,8 @@ impl Claims { roles: Vec::new(), permissions: Vec::new(), client_id: None, + sub_type: Some("user".to_owned()), + session_type: None, } } @@ -112,6 +122,9 @@ pub fn encode_token( // Optional key identifier. When present, JWKS-based verifiers can pin // verification to a specific key, allowing safe key rotation. header.kid = kid.map(str::to_owned); + // An access token says so (RFC 9068): a resource server can tell it from + // an ID token, which keeps the plain `JWT` type. + header.typ = Some("at+jwt".to_owned()); jsonwebtoken::encode(&header, claims, key).map_err(|e| JwtError::Encode(e.to_string())) } @@ -284,11 +297,13 @@ pub fn parse_verifying_key(pem: &str) -> Result { .map_err(|e| JwtError::Decode(format!("invalid public key PEM: {e}"))) } -/// Compute a short key ID (first 8 hex chars of the SHA-256 of the uncompressed public point). +/// Compute a key ID: the first 16 hex digits (64 bits) of the SHA-256 of the +/// uncompressed public point. Wide enough that two keys held together never +/// share one by chance; `parse_jwt_keys` refuses to start if they do. pub fn compute_kid(key: &VerifyingKey) -> String { let point = key.to_encoded_point(false); let hash = Sha256::digest(point.as_bytes()); - hash[..4].iter().map(|b| format!("{b:02x}")).collect() + hash[..8].iter().map(|b| format!("{b:02x}")).collect() } /// Build a JWK representation of a P-256 public key for the JWKS endpoint. @@ -419,6 +434,8 @@ mod tests { roles: Vec::new(), permissions: Vec::new(), client_id: None, + sub_type: None, + session_type: None, }; let token = encode_token(&claims, &sk, None).unwrap(); assert!(matches!( @@ -443,6 +460,8 @@ mod tests { roles: Vec::new(), permissions: Vec::new(), client_id: None, + sub_type: None, + session_type: None, }; let token = encode_token(&claims, &sk, None).unwrap(); assert!(matches!( @@ -637,6 +656,8 @@ mod tests { roles: Vec::new(), permissions: Vec::new(), client_id: None, + sub_type: None, + session_type: None, }; let token = encode_token(&claims, &sk, None).unwrap(); @@ -670,6 +691,8 @@ mod tests { roles: Vec::new(), permissions: Vec::new(), client_id: None, + sub_type: None, + session_type: None, }; let token = encode_token(&claims, &sk, None).unwrap(); @@ -685,11 +708,11 @@ mod tests { } #[test] - fn a_kid_is_eight_lowercase_hex_digits_stable_per_key() { + fn a_kid_is_sixteen_lowercase_hex_digits_stable_per_key() { let (_, public) = test_key_pems(); let key = parse_p256_verifying_key(&public).unwrap(); let kid = compute_kid(&key); - assert_eq!(kid.len(), 8); + assert_eq!(kid.len(), 16); assert!( kid.bytes() .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)), diff --git a/src/utils/password.rs b/src/utils/password.rs index ea6ac12..b4b034b 100644 --- a/src/utils/password.rs +++ b/src/utils/password.rs @@ -75,6 +75,50 @@ pub fn verify(password: &str, hash: &str) -> Result { } } +/// Whether `hash` was computed with weaker parameters than `cfg` asks for, or +/// another algorithm: the password is then hashed again once proven, so raising +/// `ARGON2_*` protects existing accounts as they sign in, not only new ones. +/// A hash that does not parse is left alone (verification refuses it anyway). +pub fn needs_rehash(hash: &str, cfg: &CryptoConfig) -> bool { + let Ok(parsed) = PasswordHash::new(hash) else { + return false; + }; + if parsed.algorithm.as_str() != "argon2id" { + return true; + } + match Params::try_from(&parsed) { + Ok(params) => { + params.m_cost() < cfg.argon2_memory_kib + || params.t_cost() < cfg.argon2_iterations + || params.p_cost() < cfg.argon2_parallelism + } + Err(_) => false, + } +} + +/// How many times the configured cost a stored hash may take: room for a +/// configuration halved since the hash was written (it is rehashed at the +/// next sign-in), not for a hash planted to exhaust memory. +pub const STORED_COST_FACTOR: u32 = 2; + +/// Whether the parameters written in `hash` would cost far more than the +/// configured ones: a hash planted in the database with, say, 256 MiB of +/// memory would take an instance down at each sign-in attempt, since every +/// concurrent hash could cost that much. Bounded by `STORED_COST_FACTOR` times +/// the configuration (at least 4 iterations and 4 lanes), so the memory the +/// container needs is known: see `log_capacity`. +pub fn exceeds_configured_cost(hash: &str, cfg: &CryptoConfig) -> bool { + let Ok(parsed) = PasswordHash::new(hash) else { + return false; + }; + let Ok(params) = Params::try_from(&parsed) else { + return false; + }; + params.m_cost() > cfg.argon2_memory_kib.saturating_mul(STORED_COST_FACTOR) + || params.t_cost() > (cfg.argon2_iterations.saturating_mul(STORED_COST_FACTOR)).max(4) + || params.p_cost() > (cfg.argon2_parallelism.saturating_mul(STORED_COST_FACTOR)).max(4) +} + /// Runs Argon2id hashing on the blocking threadpool so authentication work /// does not stall the async runtime under load. Concurrency is bounded by /// the global Argon2 semaphore (see `argon2_semaphore`). @@ -104,11 +148,23 @@ pub async fn hash_async(password: &str, cfg: &CryptoConfig) -> Result Result { + if password.len() > MAX_VERIFIED_PASSWORD_BYTES { + return Ok(false); + } + if exceeds_configured_cost(hash_value, cfg) { + tracing::error!("stored password hash asks for far more than the configured cost: refused"); + return Ok(false); + } let semaphore = argon2_semaphore(cfg); let permit = semaphore .acquire() @@ -165,11 +221,17 @@ pub fn log_capacity(cfg: &CryptoConfig) { } } -/// Concurrent hashes, memory per hash and their total, in MiB. +/// Concurrent hashes, memory per hash and their total, in MiB. The total is +/// the worst case: every concurrent hash checking a stored hash at the highest +/// cost `exceeds_configured_cost` accepts. fn argon2_budget(cfg: &CryptoConfig) -> (u64, u64, u64) { let concurrency = u64::from(cfg.argon2_max_concurrency.max(1)); let per_hash_mib = u64::from(cfg.argon2_memory_kib) / 1024; - (concurrency, per_hash_mib, concurrency * per_hash_mib) + ( + concurrency, + per_hash_mib, + concurrency * per_hash_mib * u64::from(STORED_COST_FACTOR), + ) } /// Whether a memory limit leaves less than [`BASELINE_MIB`] beside the budget. @@ -194,6 +256,68 @@ fn parse_memory_max(contents: &str) -> Option { .map(|bytes| bytes / 1024 / 1024) } +#[cfg(test)] +mod rehash_tests { + use super::*; + + fn config(memory: u32, iterations: u32, parallelism: u32) -> CryptoConfig { + CryptoConfig { + argon2_memory_kib: memory, + argon2_iterations: iterations, + argon2_parallelism: parallelism, + argon2_max_concurrency: 1, + totp_issuer: String::new(), + encryption_key: String::new(), + previous_encryption_key: None, + totp_skew: 1, + recovery_code_expiry_days: 0, + } + } + + #[test] + fn a_hash_weaker_than_the_configuration_is_rehashed() { + let weak = hash("Password-1!", &config(8, 1, 1)).unwrap(); + assert!(needs_rehash(&weak, &config(16, 1, 1)), "memory"); + assert!(needs_rehash(&weak, &config(8, 2, 1)), "iterations"); + assert!(needs_rehash(&weak, &config(8, 1, 2)), "parallelism"); + assert!(!needs_rehash(&weak, &config(8, 1, 1)), "as configured"); + assert!( + !needs_rehash(&weak, &config(4, 1, 1)), + "stronger than asked" + ); + assert!(!needs_rehash("not a phc string", &config(8, 1, 1))); + } + + /// A password longer than any the policy accepts is wrong, without work. + #[tokio::test] + async fn a_password_longer_than_any_accepted_is_wrong() { + let cfg = config(1024, 1, 1); + let long = "x".repeat(MAX_VERIFIED_PASSWORD_BYTES + 1); + let stored = hash(&long, &cfg).unwrap(); + assert!(!verify_async(&long, &stored, &cfg).await.unwrap()); + } + + /// A hash asking for far more than the configured cost is refused before + /// any work (SEC-71). + #[tokio::test] + async fn a_hash_costing_far_more_than_configured_is_refused() { + let cfg = config(19_456, 2, 1); + let planted = "$argon2id$v=19$m=4194304,t=2,p=1$c2FsdHNhbHRzYWx0$aGFzaGhhc2hoYXNoaGFzaGhhc2hoYXNoaGFzaA"; + assert!(exceeds_configured_cost(planted, &cfg)); + assert!(!verify_async("Password-1!", planted, &cfg).await.unwrap()); + + let lowered = hash("Password-1!", &config(38_912, 3, 2)).unwrap(); + assert!( + !exceeds_configured_cost(&lowered, &cfg), + "a cost halved since stays readable" + ); + // 256 MiB per hash: three concurrent sign-ins would exhaust a 512 MiB + // container (C-04). + let heavy = "$argon2id$v=19$m=262144,t=2,p=1$c2FsdHNhbHRzYWx0$aGFzaGhhc2hoYXNoaGFzaGhhc2hoYXNoaGFzaA"; + assert!(exceeds_configured_cost(heavy, &cfg)); + } +} + #[cfg(test)] mod tests { use super::*; @@ -273,13 +397,13 @@ mod tests { } #[test] - fn the_argon2_budget_is_concurrency_times_memory() { + fn the_argon2_budget_is_concurrency_times_the_highest_accepted_cost() { let mut cfg = test_config(); cfg.argon2_memory_kib = 65_536; cfg.argon2_max_concurrency = 4; - assert_eq!(argon2_budget(&cfg), (4, 64, 256)); + assert_eq!(argon2_budget(&cfg), (4, 64, 512)); cfg.argon2_max_concurrency = 0; - assert_eq!(argon2_budget(&cfg), (1, 64, 64)); + assert_eq!(argon2_budget(&cfg), (1, 64, 128)); } #[test] diff --git a/src/utils/redis_counter.rs b/src/utils/redis_counter.rs index fefb02d..cee74b3 100644 --- a/src/utils/redis_counter.rs +++ b/src/utils/redis_counter.rs @@ -44,6 +44,35 @@ return counts ) }); +/// Like [`CONSUME`], but in order and stopping at the first budget past its +/// limit: the budgets after it are left untouched (count 0). +static CONSUME_IN_ORDER: LazyLock