From ec93f90ffe38a35686ae5271dafdc55b5e73716b Mon Sep 17 00:00:00 2001 From: SIIR3X <125802911+SIIR3X@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:46:21 +0200 Subject: [PATCH 01/55] fix(auth): refuse delegated tokens on account, approval and admin routes --- CHANGELOG.md | 15 ++ docs/dev/api/openapi.yaml | 147 +++++++++++++- docs/dev/api/routes.md | 7 +- docs/dev/guides/integration.md | 6 + docs/dev/security-model.md | 13 +- src/domain/session.rs | 88 +++++++- src/error.rs | 10 + src/handlers/audit.rs | 4 +- src/handlers/external_identity.rs | 10 +- src/handlers/extractors.rs | 39 +++- src/handlers/oauth.rs | 35 +++- src/handlers/passkey.rs | 10 +- src/handlers/personal_access_token.rs | 8 +- src/handlers/session.rs | 8 +- src/handlers/two_factor.rs | 22 +- src/handlers/user.rs | 24 +-- src/openapi.rs | 18 +- src/repositories/session.rs | 28 ++- src/services/auth/tokens.rs | 22 +- src/services/device.rs | 69 +++++-- tests/integration/api/account/tokens.rs | 6 +- tests/security/delegation.rs | 258 ++++++++++++++++++++++++ tests/security/main.rs | 1 + 23 files changed, 757 insertions(+), 91 deletions(-) create mode 100644 tests/security/delegation.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 723d63f..30ebea4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ as described in the [versioning policy](docs/dev/guides/versioning.md). ## [Unreleased] +### Security + +- 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`. + ## [2.0.1] - 2026-09-18 Dependency and image updates; no change to the API, the events or the diff --git a/docs/dev/api/openapi.yaml b/docs/dev/api/openapi.yaml index a460165..c363938 100644 --- a/docs/dev/api/openapi.yaml +++ b/docs/dev/api/openapi.yaml @@ -3124,6 +3124,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 +3269,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 +3409,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 +3495,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: @@ -3828,6 +3852,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: @@ -3945,6 +3975,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 +4027,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: @@ -4124,6 +4166,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: @@ -4188,6 +4236,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 +4332,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 +4382,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 +4594,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 +4652,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 +4701,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: @@ -4932,6 +5016,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 +5199,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 +5320,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 +5375,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 +5409,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: @@ -5507,6 +5621,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: @@ -5639,6 +5759,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: @@ -5863,6 +5989,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: @@ -6492,6 +6624,7 @@ components: required: - user_code - created_at + - reauthentication_required properties: client_id: type: @@ -6504,6 +6637,11 @@ components: created_at: type: integer format: int64 + reauthentication_required: + type: boolean + description: |- + Approving needs `current_password` (or a recent `POST /users/me/reauth`): + the client is not the instance's own application. requested_from_ip: type: - string @@ -6521,6 +6659,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: diff --git a/docs/dev/api/routes.md b/docs/dev/api/routes.md index 2d38b9f..8b47136 100644 --- a/docs/dev/api/routes.md +++ b/docs/dev/api/routes.md @@ -9,6 +9,7 @@ the overview. |------|---------| | - | No authentication | | JWT | Access token in `Authorization: Bearer` | +| 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 an account with a second factor | | 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 | @@ -92,7 +93,7 @@ and 8628 (device authorization). Token and device authorization requests are | POST | `/oauth/introspect` | confidential client | Strict | | POST | `/oauth/revoke` | client | Strict | | 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`) @@ -168,7 +169,9 @@ and are left alone. `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 +(`POST /oauth/device/verify`; approving a client other than the instance's own +application 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). diff --git a/docs/dev/guides/integration.md b/docs/dev/guides/integration.md index a39cbbf..8d3cb9d 100644 --- a/docs/dev/guides/integration.md +++ b/docs/dev/guides/integration.md @@ -110,6 +110,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 diff --git a/docs/dev/security-model.md b/docs/dev/security-model.md index eee73d7..3319c49 100644 --- a/docs/dev/security-model.md +++ b/docs/dev/security-model.md @@ -64,6 +64,15 @@ the database together, is out of scope. 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. - **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 @@ -126,7 +135,8 @@ the database together, is out of scope. - **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 a device of a client other than the instance's + own application requires a re-authentication, like consenting to it. - **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. @@ -276,3 +286,4 @@ when a cited test no longer exists. | 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-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` | diff --git a/src/domain/session.rs b/src/domain/session.rs index 5eb71a7..5ee7cf8 100644 --- a/src/domain/session.rs +++ b/src/domain/session.rs @@ -164,27 +164,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, } } @@ -334,12 +372,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/error.rs b/src/error.rs index 7418445..9946c52 100644 --- a/src/error.rs +++ b/src/error.rs @@ -69,6 +69,8 @@ pub enum AppError { TwoFactorRequired, #[error("recent re-authentication required")] ReauthenticationRequired, + #[error("a session of the account itself is required")] + FirstPartySessionRequired, // 404 #[error("resource not found")] @@ -221,6 +223,13 @@ impl IntoResponse for AppError { "Recent re-authentication is required for this action.", ), ), + 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 => ( @@ -541,6 +550,7 @@ mod tests { #[test] fn reauthentication_required_is_403() { assert_eq!(status(AppError::ReauthenticationRequired), 403); + assert_eq!(status(AppError::FirstPartySessionRequired), 403); } // 404 diff --git a/src/handlers/audit.rs b/src/handlers/audit.rs index 9c94be0..0a94df1 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); 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..d15669c 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,10 +89,42 @@ impl FromRequestParts for AuthUser { roles: claims.roles, permissions: claims.permissions, request_id, + first_party, }) } } +/// 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 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`]. @@ -104,7 +139,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() diff --git a/src/handlers/oauth.rs b/src/handlers/oauth.rs index 7a71b9e..82be957 100644 --- a/src/handlers/oauth.rs +++ b/src/handlers/oauth.rs @@ -22,7 +22,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 +147,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 { @@ -357,7 +360,7 @@ pub async fn authorize( )] pub async fn describe_request( State(state): State, - 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 +395,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,7 +427,7 @@ 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?; @@ -575,11 +578,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.session_id, &user_code, ip).await?, + )) } #[utoipa::path( @@ -589,7 +594,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,12 +604,23 @@ 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?; } diff --git a/src/handlers/passkey.rs b/src/handlers/passkey.rs index f88e247..2e6edb9 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( @@ -181,7 +181,7 @@ pub async fn register( 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..05baf5d 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 @@ -95,7 +95,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 +132,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> { @@ -160,7 +160,7 @@ pub async fn verify_totp_setup( pub async fn disable_totp( State(state): State, ClientIp(ip): ClientIp, - auth: AuthUser, + auth: FirstPartyUser, Path(method_id): Path, body: Option>, ) -> Result { @@ -193,7 +193,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( @@ -223,7 +223,7 @@ pub async fn regenerate_recovery_codes( )] pub async fn use_recovery_code( State(state): State, - auth: AuthUser, + auth: FirstPartyUser, Json(body): Json, ) -> Result { tf_svc::use_recovery_code(&state, auth.user_id, &body.code, auth.request_id).await?; @@ -248,7 +248,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(); @@ -279,7 +279,7 @@ pub async fn setup_email_otp( )] 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?; Ok(StatusCode::NO_CONTENT) @@ -300,7 +300,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> { @@ -329,7 +329,7 @@ pub async fn verify_email_otp_setup( 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 +386,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..65e7fd6 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?; @@ -225,7 +225,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 +256,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 +288,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 +322,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)?; @@ -402,7 +402,7 @@ pub fn validate_password(password: &str) -> Result<(), AppError> { 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/openapi.rs b/src/openapi.rs index 34c0a89..5fc1758 100644 --- a/src/openapi.rs +++ b/src/openapi.rs @@ -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"))) diff --git a/src/repositories/session.rs b/src/repositories/session.rs index e37c5e2..9ed4392 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"; @@ -48,6 +54,13 @@ pub struct NewSession<'a> { 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 +80,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 diff --git a/src/services/auth/tokens.rs b/src/services/auth/tokens.rs index 35f9088..b717039 100644 --- a/src/services/auth/tokens.rs +++ b/src/services/auth/tokens.rs @@ -299,7 +299,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 +336,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,15 +349,20 @@ 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 first_party = session.first_party(); let _: Result<(), _> = conn - .set_ex(&cache_key, u8::from(active), SESSION_CACHE_TTL_SECS) + .set_ex( + &cache_key, + crate::domain::session::cached_value(active, first_party), + SESSION_CACHE_TTL_SECS, + ) .await; - active + (active, first_party) } }; if active { - Ok(()) + Ok(first_party) } else { Err(AppError::Unauthorized) } diff --git a/src/services/device.rs b/src/services/device.rs index b35dcf4..3e6cbc9 100644 --- a/src/services/device.rs +++ b/src/services/device.rs @@ -106,6 +106,19 @@ 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`): + /// the client is not the instance's own application. + pub reauthentication_required: bool, +} + +/// 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 { @@ -399,6 +412,7 @@ async fn load_entry( /// Describe a pending device authorization to the user about to decide on it. pub async fn describe( state: &AppState, + session_id: Uuid, user_code: &str, ip: Option, ) -> Result { @@ -412,37 +426,64 @@ pub async fn describe( 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 = match &client { + Some(client) => authorize_svc::requires_reauthentication(state, session_id, client).await, + None => true, + }; 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, }) } -/// 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> { +/// Approve a device authorization request. Called by a signed-in user. +/// +/// The approval mints a long-lived session for the client: for a client other +/// than the instance's own application it needs a fresh proof of the password, +/// like consenting to an authorization request. +pub async fn verify(state: &AppState, approval: &Approval<'_>) -> Result<(), AppError> { + guard_code_scan(state, approval.ip).await?; + let Some((_, _, entry)) = load_entry(state, approval.user_code).await? else { + note_unknown_code(state, approval.ip).await; + return Err(AppError::NotFound); + }; + let primary = match entry.client_id.as_deref() { + Some(cid) => client_repo::find_by_id(&state.db, cid) + .await + .map_err(|e| AppError::Internal(e.into()))? + .is_some_and(|client| client.is_primary), + None => false, + }; + if !primary { + crate::services::reauth::require_recent_reauth_or_password( + state, + approval.user_id, + approval.session_id, + approval.current_password, + approval.ip, + approval.request_id, + "approve_device", + ) + .await?; + } update_status( state, - user_code, + approval.user_code, DeviceAuthStatus::Authorized, - Some(user_id), - ip, + Some(approval.user_id), + approval.ip, ) .await } diff --git a/tests/integration/api/account/tokens.rs b/tests/integration/api/account/tokens.rs index 35d815e..ac6d6b2 100644 --- a/tests/integration/api/account/tokens.rs +++ b/tests/integration/api/account/tokens.rs @@ -72,7 +72,11 @@ async fn a_token_is_exchanged_for_access_tokens_carrying_its_scopes_only() { let claims = app.decode_access_token(access); assert_eq!(claims.permissions, ["audit:read"]); assert!(claims.roles.is_empty()); - assert_eq!(app.get_auth("/users/me", access).await.status(), 200); + // A live token, delegated: the account routes are not its to use. + let response = app.get_auth("/users/me", access).await; + assert_eq!(response.status(), 403); + let body: Value = response.json().await.unwrap(); + assert_eq!(body["code"], "first_party_session_required"); let listed: Value = app .get_auth("/users/me/tokens", &user.access_token) diff --git a/tests/security/delegation.rs b/tests/security/delegation.rs new file mode 100644 index 0000000..d401116 --- /dev/null +++ b/tests/security/delegation.rs @@ -0,0 +1,258 @@ +//! Delegated tokens (SEC-42): a token issued to a client application or +//! obtained from a personal access token acts within what was granted to it, +//! never as the account itself. +//! +//! Before the control, such a token reached every account route, and could +//! approve on its own a device flow of the instance's application: a new, +//! unrestricted session carrying every role of the account, administration +//! included. + +use reqwest::{Method, StatusCode}; +use serde_json::{Value, json}; +use utoipa::OpenApi; +use uuid::Uuid; + +use crate::common::{ + app::TestApp, + fixtures::{self, AuthenticatedUser}, +}; + +async fn register_client(app: &TestApp, client_id: &str, is_primary: bool) { + sqlx::query( + "INSERT INTO registered_clients (client_id, display_name, is_primary, default_max_sessions) + VALUES ($1, $1, $2, 5)", + ) + .bind(client_id) + .bind(is_primary) + .execute(&app.db) + .await + .unwrap(); +} + +async fn form(app: &TestApp, path: &str, parameters: &[(&str, &str)]) -> (StatusCode, Value) { + let response = app + .client + .post(app.url(path)) + .form(parameters) + .send() + .await + .unwrap(); + let status = response.status(); + (status, response.json().await.unwrap_or(Value::Null)) +} + +/// Start a device flow for `client_id`: `(device_code, user_code)`. +async fn start_device_flow(app: &TestApp, client_id: &str) -> (String, String) { + let (status, body) = form( + app, + "/oauth/device_authorization", + &[("client_id", client_id)], + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + ( + body["device_code"].as_str().unwrap().to_owned(), + body["user_code"].as_str().unwrap().to_owned(), + ) +} + +async fn poll(app: &TestApp, device_code: &str, client_id: &str) -> (StatusCode, Value) { + form( + app, + "/oauth/token", + &[ + ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), + ("device_code", device_code), + ("client_id", client_id), + ], + ) + .await +} + +async fn approve(app: &TestApp, access_token: &str, body: Value) -> (StatusCode, Value) { + let response = app + .post_auth("/oauth/device/verify", access_token, &body) + .await; + let status = response.status(); + (status, response.json().await.unwrap_or(Value::Null)) +} + +/// An access token of a device session of `client_id`, approved by `user`. +async fn client_session(app: &TestApp, user: &AuthenticatedUser, client_id: &str) -> String { + let (device_code, user_code) = start_device_flow(app, client_id).await; + let (status, body) = approve( + app, + &user.access_token, + json!({ "user_code": user_code, "current_password": user.password }), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let (status, body) = poll(app, &device_code, client_id).await; + assert_eq!(status, StatusCode::OK, "{body}"); + body["access_token"].as_str().unwrap().to_owned() +} + +/// An access token exchanged from a personal access token of `user`. +async fn personal_access_session(app: &TestApp, user: &AuthenticatedUser) -> String { + let created: Value = app + .post_auth( + "/users/me/tokens", + &user.access_token, + &json!({ "name": "script" }), + ) + .await + .json() + .await + .unwrap(); + let exchanged: Value = app + .post( + "/auth/personal-access-tokens/exchange", + &json!({ "token": created["secret"] }), + ) + .await + .json() + .await + .unwrap(); + exchanged["access_token"].as_str().unwrap().to_owned() +} + +/// Every documented operation reserved to first-party sessions. +fn first_party_operations() -> Vec<(Method, String)> { + let document = serde_json::to_value(auth_api::openapi::ApiDoc::openapi()).unwrap(); + let mut operations = Vec::new(); + for (template, item) in document["paths"].as_object().unwrap() { + if !auth_api::openapi::requires_first_party(template) { + continue; + } + for method in item.as_object().unwrap().keys() { + if let Ok(method) = method.to_ascii_uppercase().parse::() { + operations.push((method, template.clone())); + } + } + } + assert!(operations.len() >= 50, "found {}", operations.len()); + operations +} + +fn concrete(template: &str) -> String { + template + .split('/') + .map(|segment| match segment { + "{user_code}" => "ABCD-2345".to_owned(), + s if s.starts_with('{') => Uuid::new_v4().to_string(), + s => s.to_owned(), + }) + .collect::>() + .join("/") +} + +async fn assert_refused_as_delegated(app: &TestApp, label: &str, token: &str) { + for (method, template) in first_party_operations() { + let mut request = app + .client + .request(method.clone(), app.url(&concrete(&template))) + .bearer_auth(token); + if method != Method::GET { + request = request.json(&json!({})); + } + let response = request.send().await.unwrap(); + let status = response.status(); + let body: Value = response.json().await.unwrap_or(Value::Null); + assert_eq!( + (status, body["code"].as_str()), + (StatusCode::FORBIDDEN, Some("first_party_session_required")), + "{method} {template} answered {label} with {status} {body}" + ); + } +} + +#[tokio::test] +async fn delegated_tokens_are_refused_on_every_account_approval_and_admin_route() { + let app = TestApp::spawn().await; + register_client(&app, "third-party", false).await; + let user = fixtures::authenticated_user(&app, 0).await; + + let client_token = client_session(&app, &user, "third-party").await; + assert_refused_as_delegated(&app, "a third-party client token", &client_token).await; + + let pat_token = personal_access_session(&app, &user).await; + assert_refused_as_delegated(&app, "a personal access token", &pat_token).await; +} + +#[tokio::test] +async fn a_delegated_token_cannot_approve_itself_an_unrestricted_session() { + let app = TestApp::spawn().await; + register_client(&app, "primary-app", true).await; + register_client(&app, "third-party", false).await; + let user = fixtures::authenticated_user(&app, 1).await; + let delegated = client_session(&app, &user, "third-party").await; + + // The chain of the finding: a flow of the instance's own application, + // approved with the delegated token, polled for a full session. + let (device_code, user_code) = start_device_flow(&app, "primary-app").await; + let (status, body) = approve(&app, &delegated, json!({ "user_code": user_code })).await; + assert_eq!(status, StatusCode::FORBIDDEN, "{body}"); + assert_eq!(body["code"], "first_party_session_required"); + + let (status, body) = poll(&app, &device_code, "primary-app").await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["error"], "authorization_pending", "{body}"); +} + +#[tokio::test] +async fn delegated_tokens_keep_the_routes_meant_for_clients() { + let app = TestApp::spawn().await; + register_client(&app, "third-party", false).await; + let user = fixtures::authenticated_user(&app, 2).await; + let delegated = client_session(&app, &user, "third-party").await; + + let response = app.post_auth("/auth/logout", &delegated, &json!({})).await; + assert_eq!(response.status(), StatusCode::NO_CONTENT); +} + +#[tokio::test] +async fn the_instance_application_without_scopes_acts_as_the_account() { + let app = TestApp::spawn().await; + register_client(&app, "primary-app", true).await; + let user = fixtures::authenticated_user(&app, 3).await; + let token = client_session(&app, &user, "primary-app").await; + + let response = app.get_auth("/users/me", &token).await; + assert_eq!(response.status(), StatusCode::OK); +} + +#[tokio::test] +async fn approving_another_client_needs_a_recent_reauthentication() { + let app = TestApp::spawn().await; + register_client(&app, "primary-app", true).await; + register_client(&app, "third-party", false).await; + let user = fixtures::authenticated_user(&app, 4).await; + app.clear_recent_reauth(&user.access_token).await; + + let (_, user_code) = start_device_flow(&app, "third-party").await; + let preview: Value = app + .get_auth(&format!("/oauth/device/{user_code}"), &user.access_token) + .await + .json() + .await + .unwrap(); + assert_eq!(preview["reauthentication_required"], true); + + let (status, body) = approve(&app, &user.access_token, json!({ "user_code": user_code })).await; + assert_eq!(status, StatusCode::FORBIDDEN, "{body}"); + assert_eq!(body["code"], "reauthentication_required"); + + let (status, body) = approve( + &app, + &user.access_token, + json!({ "user_code": user_code, "current_password": user.password }), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + + // The instance's own application needs none. + app.clear_recent_reauth(&user.access_token).await; + let (_, user_code) = start_device_flow(&app, "primary-app").await; + let (status, body) = approve(&app, &user.access_token, json!({ "user_code": user_code })).await; + assert_eq!(status, StatusCode::OK, "{body}"); +} diff --git a/tests/security/main.rs b/tests/security/main.rs index 3ec72c0..0179d76 100644 --- a/tests/security/main.rs +++ b/tests/security/main.rs @@ -9,6 +9,7 @@ mod common { mod authentication; mod authorization; mod catalog; +mod delegation; mod edge; mod headers; mod jwt_rotation; From 4f06be39f1840ad1652a77960d00eba82db1eece Mon Sep 17 00:00:00 2001 From: SIIR3X <125802911+SIIR3X@users.noreply.github.com> Date: Fri, 18 Sep 2026 17:58:27 +0200 Subject: [PATCH 02/55] fix(auth): carry registration credentials in pending account verification links --- CHANGELOG.md | 5 + docs/dev/database/schema.md | 6 +- docs/dev/security-model.md | 14 ++- migrations/0025_pending_registrations.sql | 25 +++++ migrations/SHA256SUMS | 1 + src/domain/token.rs | 31 ++++++- src/repositories/token.rs | 44 ++++++++- src/repositories/user.rs | 28 ++++++ src/services/auth/register.rs | 70 ++++++++++++-- tests/integration/api/auth/password_reset.rs | 23 +++-- .../schema/tokens/email_verification.rs | 47 +++++++--- tests/security/regressions/mod.rs | 1 + tests/security/regressions/pre_hijacking.rs | 93 +++++++++++++++++++ 13 files changed, 344 insertions(+), 44 deletions(-) create mode 100644 migrations/0025_pending_registrations.sql create mode 100644 tests/security/regressions/pre_hijacking.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 30ebea4..f6ee338 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,11 @@ as described in the [versioning policy](docs/dev/guides/versioning.md). 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 (migration 0025). ## [2.0.1] - 2026-09-18 diff --git a/docs/dev/database/schema.md b/docs/dev/database/schema.md index 13bbaea..84b3337 100644 --- a/docs/dev/database/schema.md +++ b/docs/dev/database/schema.md @@ -67,7 +67,11 @@ 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). ### webhook_endpoints, webhook_deliveries diff --git a/docs/dev/security-model.md b/docs/dev/security-model.md index 3319c49..e40c0b6 100644 --- a/docs/dev/security-model.md +++ b/docs/dev/security-model.md @@ -30,10 +30,15 @@ the database together, is out of scope. 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`. +- **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, and the link applies + them when it verifies the account. Whoever registered the address first does + not decide the password its owner activates. Links of a pending account + coexist (a resend repeats the latest registration) 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 @@ -287,3 +292,4 @@ when a cited test no longer exists. | 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-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 registration on a pending address carries its own credentials in its link: registering someone's address first does not choose the password they activate | `the_owner_activates_the_account_with_the_password_they_chose`, `a_resend_repeats_the_latest_registration_not_the_first`, `resent_links_coexist_until_one_verifies_the_account`, `email_verification_tokens_carry_complete_credentials_or_none` | diff --git a/migrations/0025_pending_registrations.sql b/migrations/0025_pending_registrations.sql new file mode 100644 index 0000000..ed9a119 --- /dev/null +++ b/migrations/0025_pending_registrations.sql @@ -0,0 +1,25 @@ +-- 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 no longer lets an attacker +-- decide the password the owner will activate. +-- +-- The links of a pending account therefore coexist until one of them is used: +-- a later registration or resend must not revoke the owner's own link. The +-- verification revokes the others. +ALTER TABLE email_verification_tokens + ADD COLUMN password_hash TEXT, + ADD COLUMN username VARCHAR(50), + ADD COLUMN preferred_locale VARCHAR(10), + ADD CONSTRAINT email_verification_tokens_credentials_together CHECK ( + (password_hash IS NULL) = (username IS NULL) + AND (password_hash IS NULL) = (preferred_locale IS NULL) + ), + ADD CONSTRAINT email_verification_tokens_username_format CHECK ( + username IS NULL OR username ~ '^[a-zA-Z0-9_]{3,50}$' + ), + ADD CONSTRAINT email_verification_tokens_locale_format CHECK ( + preferred_locale IS NULL OR preferred_locale ~ '^[a-z]{2}(_[A-Z]{2})?$' + ); + +DROP INDEX idx_email_verification_tokens_user_active; diff --git a/migrations/SHA256SUMS b/migrations/SHA256SUMS index a276c93..5ef9b33 100644 --- a/migrations/SHA256SUMS +++ b/migrations/SHA256SUMS @@ -22,3 +22,4 @@ a1e01ce9c1cde16545191dc1f31ec725dcfc4d15393657077f057efea7f72a20 0020_oauth.sql 2515d6388f54364f07f460c8e3ac97b09a039e0ec2a6c4f458c3dcb103f062f5 0022_openid_connect.sql a3fdddb551010b532efa344548a0b467652649068b939f99de46d4a8fdaf1053 0023_passkeys.sql 075c2bb4a512689cb03c870b347cf1687944b0e9f0ccaeb8be1327b6af9279fb 0024_external_identities.sql +8c16772a3f7f23ebdb38fed5414e59799df768e930ec14972d37dfa69c98b11d 0025_pending_registrations.sql 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/repositories/token.rs b/src/repositories/token.rs index 8045fa6..ac45ced 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,10 +54,39 @@ 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 } +/// Credentials of the most recent live link of a pending account, so a +/// resend repeats what its latest registration chose rather than falling back +/// to the credentials of whoever registered the address first. +pub async fn latest_active_credentials<'e>( + executor: impl PgExecutor<'e>, + user_id: Uuid, +) -> Result, sqlx::Error> { + let row: Option<(String, String, String)> = sqlx::query_as( + "SELECT password_hash, username, preferred_locale FROM email_verification_tokens + WHERE user_id = $1 AND used_at IS NULL AND expires_at > NOW() + AND password_hash IS NOT NULL + ORDER BY created_at DESC + LIMIT 1", + ) + .bind(user_id) + .fetch_optional(executor) + .await?; + Ok(row.map( + |(password_hash, username, preferred_locale)| PendingCredentials { + password_hash, + username, + preferred_locale, + }, + )) +} + pub async fn find_verification_by_hash( pool: &PgPool, token_hash: &[u8], @@ -81,7 +115,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, @@ -146,7 +180,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, diff --git a/src/repositories/user.rs b/src/repositories/user.rs index e411761..8897445 100644 --- a/src/repositories/user.rs +++ b/src/repositories/user.rs @@ -145,6 +145,34 @@ 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 o.username = $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(()) +} + pub async fn verify_if_pending<'e>( executor: impl PgExecutor<'e>, id: Uuid, diff --git a/src/services/auth/register.rs b/src/services/auth/register.rs index 9baf2d9..66e4dfb 100644 --- a/src/services/auth/register.rs +++ b/src/services/auth/register.rs @@ -34,10 +34,26 @@ 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. + // A pending account belongs to nobody yet: this registration gets its + // own link, carrying the credentials it chose. Whoever clicks it + // activates the account with them, so an attacker who registered the + // address first does not pick the owner's password. An active account + // is told of the attempt. if existing.status == UserStatus::PendingVerification { - issue_verification(state, &existing, ip, user_agent, request_id).await?; + 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 { notify_existing_account(state, &existing); } @@ -95,6 +111,7 @@ pub async fn register( request_ip: ip, request_user_agent: user_agent, target_email: email, + credentials: None, }, ) .await @@ -174,7 +191,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,11 +202,15 @@ pub async fn resend_verification( result } -/// Replace the pending verification link of `user` and e-mail it, within the -/// per-account budget. +/// 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 repeats those of the latest live link. 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. async fn issue_verification( state: &AppState, user: &User, + credentials: Option, ip: Option, user_agent: Option<&str>, request_id: Option, @@ -209,9 +230,11 @@ 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?; + let credentials = match credentials { + Some(credentials) => Some(credentials), + None => token::latest_active_credentials(&mut *tx, user.id).await?, + }; token::create_verification( &mut *tx, &NewEmailVerificationToken { @@ -221,6 +244,7 @@ async fn issue_verification( request_ip: ip, request_user_agent: user_agent, target_email: &user.email, + credentials: credentials.as_ref(), }, ) .await @@ -239,12 +263,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( @@ -282,6 +312,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/tests/integration/api/auth/password_reset.rs b/tests/integration/api/auth/password_reset.rs index 784afd2..bfc5ccb 100644 --- a/tests/integration/api/auth/password_reset.rs +++ b/tests/integration/api/auth/password_reset.rs @@ -353,7 +353,7 @@ fn verification_mails(app: &TestApp, email: &str) -> Vec Value { + let response = app + .post( + "/auth/register", + &json!({ "username": username, "email": VICTIM, "password": password }), + ) + .await; + assert_eq!(response.status().as_u16(), 202); + response.json().await.unwrap() +} + +async fn login_status(app: &TestApp, password: &str) -> u16 { + app.post( + "/auth/login", + &json!({ "identifier": VICTIM, "password": password }), + ) + .await + .status() + .as_u16() +} + +#[tokio::test] +async fn the_owner_activates_the_account_with_the_password_they_chose() { + let app = TestApp::spawn().await; + let squatted = register(&app, "squatter", ATTACKER_PASSWORD).await; + app.mail.wait_for(VICTIM, SUBJECT).await; + + let owned = register(&app, "rightful_owner", OWNER_PASSWORD).await; + assert_eq!( + squatted, owned, + "the second registration looks like a first" + ); + + let mails = app.mail.wait_for_count(VICTIM, 2).await; + let owner_mail = mails.last().unwrap(); + assert!( + owner_mail.html.contains("rightful_owner"), + "the link names the account it activates" + ); + let token = owner_mail.value_after("token=").unwrap(); + let verified = app + .post("/auth/verify-email", &json!({ "token": token })) + .await; + assert_eq!(verified.status().as_u16(), 200); + + assert_eq!(login_status(&app, ATTACKER_PASSWORD).await, 401); + assert_eq!(login_status(&app, OWNER_PASSWORD).await, 200); + let username: String = sqlx::query_scalar("SELECT username FROM users WHERE email = $1") + .bind(VICTIM) + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(username, "rightful_owner"); +} + +#[tokio::test] +async fn a_resend_repeats_the_latest_registration_not_the_first() { + let app = TestApp::spawn().await; + register(&app, "squatter", ATTACKER_PASSWORD).await; + register(&app, "rightful_owner", OWNER_PASSWORD).await; + app.mail.wait_for_count(VICTIM, 2).await; + + // Anyone knowing the address can ask for a resend. + let resent = app + .post("/auth/verify-email/resend", &json!({ "email": VICTIM })) + .await; + assert_eq!(resent.status().as_u16(), 200); + let mails = app.mail.wait_for_count(VICTIM, 3).await; + let token = mails.last().unwrap().value_after("token=").unwrap(); + let verified = app + .post("/auth/verify-email", &json!({ "token": token })) + .await; + assert_eq!(verified.status().as_u16(), 200); + + assert_eq!(login_status(&app, ATTACKER_PASSWORD).await, 401); + assert_eq!(login_status(&app, OWNER_PASSWORD).await, 200); +} From 71b61821293c6e766f3c35e1937e071cd70707da Mon Sep 17 00:00:00 2001 From: SIIR3X <125802911+SIIR3X@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:10:33 +0200 Subject: [PATCH 03/55] fix(account): announce added access and list it after password changes --- CHANGELOG.md | 5 + docs/dev/security-model.md | 6 + src/repositories/user.rs | 20 +++ src/services/auth/password_reset.rs | 8 + src/services/email.rs | 55 ++++++- src/services/external_identity.rs | 9 ++ src/services/passkey.rs | 10 ++ src/services/personal_access_token.rs | 10 ++ src/services/user.rs | 89 ++++++++++- templates/emails/en/access_added.html | 9 ++ templates/emails/en/access_added.subject | 1 + templates/emails/en/password_changed.html | 6 + templates/emails/fr/access_added.html | 9 ++ templates/emails/fr/access_added.subject | 1 + templates/emails/fr/password_changed.html | 6 + .../regressions/access_persistence.rs | 140 ++++++++++++++++++ tests/security/regressions/mod.rs | 1 + 17 files changed, 382 insertions(+), 3 deletions(-) create mode 100644 templates/emails/en/access_added.html create mode 100644 templates/emails/en/access_added.subject create mode 100644 templates/emails/fr/access_added.html create mode 100644 templates/emails/fr/access_added.subject create mode 100644 tests/security/regressions/access_persistence.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index f6ee338..4fcd8d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,11 @@ as described in the [versioning policy](docs/dev/guides/versioning.md). 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 (migration 0025). +- 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. ## [2.0.1] - 2026-09-18 diff --git a/docs/dev/security-model.md b/docs/dev/security-model.md index e40c0b6..c3999d4 100644 --- a/docs/dev/security-model.md +++ b/docs/dev/security-model.md @@ -43,6 +43,11 @@ the database together, is out of scope. 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. +- **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 pending + account taken back by a reset loses all of them. - **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. @@ -293,3 +298,4 @@ when a cited test no longer exists. | 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-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 registration on a pending address carries its own credentials in its link: registering someone's address first does not choose the password they activate | `the_owner_activates_the_account_with_the_password_they_chose`, `a_resend_repeats_the_latest_registration_not_the_first`, `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` | diff --git a/src/repositories/user.rs b/src/repositories/user.rs index 8897445..19a73a7 100644 --- a/src/repositories/user.rs +++ b/src/repositories/user.rs @@ -173,6 +173,26 @@ pub async fn adopt_pending_credentials<'e>( Ok(()) } +/// 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(()) +} + pub async fn verify_if_pending<'e>( executor: impl PgExecutor<'e>, id: Uuid, diff --git a/src/services/auth/password_reset.rs b/src/services/auth/password_reset.rs index 4ceba69..463f6da 100644 --- a/src/services/auth/password_reset.rs +++ b/src/services/auth/password_reset.rs @@ -187,6 +187,8 @@ pub async fn reset_password( .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 +232,11 @@ 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 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 { + crate::services::user::notify_password_changed(state, &user).await; + } + Ok(()) } diff --git a/src/services/email.rs b/src/services/email.rs index 76ff82e..80fbabd 100644 --- a/src/services/email.rs +++ b/src/services/email.rs @@ -34,6 +34,16 @@ 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"; + +/// 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. @@ -220,6 +230,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 +240,12 @@ pub async fn send_password_changed( 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("access", access); let body = render_with_fallback( templates, @@ -249,6 +264,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('@') { @@ -559,7 +609,7 @@ async fn send( mod tests { use super::*; - const ALL_TEMPLATES: [&str; 10] = [ + const ALL_TEMPLATES: [&str; 13] = [ TNAME_VERIFICATION, TNAME_EMAIL_CHANGE_OTP, TNAME_PASSWORD_RESET, @@ -570,6 +620,9 @@ mod tests { TNAME_ACCOUNT_EXISTS, TNAME_EMAIL_CHANGED, TNAME_RECOVERY_CODE_USED, + TNAME_NEW_DEVICE_LOGIN, + TNAME_MAGIC_LINK, + TNAME_ACCESS_ADDED, ]; #[test] diff --git a/src/services/external_identity.rs b/src/services/external_identity.rs index 1e364c0..020fe4a 100644 --- a/src/services/external_identity.rs +++ b/src/services/external_identity.rs @@ -440,6 +440,15 @@ async fn settle( }, ) .await?; + crate::services::user::notify_access_added( + state, + user_id, + crate::services::email::AccessItem { + kind: "external_identity", + name: provider.display_name.clone(), + }, + ) + .await; Ok(Ok((user_id, identity.id))) } Err(sqlx::Error::Database(e)) if e.code().as_deref() == Some("23505") => { diff --git a/src/services/passkey.rs b/src/services/passkey.rs index 41ba326..b11a727 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 { diff --git a/src/services/personal_access_token.rs b/src/services/personal_access_token.rs index cb1d0c6..1f12397 100644 --- a/src/services/personal_access_token.rs +++ b/src/services/personal_access_token.rs @@ -128,6 +128,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 }) } diff --git a/src/services/user.rs b/src/services/user.rs index 03cbedd..3dadad2 100644 --- a/src/services/user.rs +++ b/src/services/user.rs @@ -18,7 +18,7 @@ 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. @@ -231,6 +231,69 @@ pub async fn change_password( auth_svc::invalidate_session_caches(state, &revoked_session_ids).await; + notify_password_changed(state, &user).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. +pub(crate) async fn notify_password_changed(state: &AppState, user: &User) { + 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 +308,33 @@ pub async fn change_password( &email_to, &username, &locale, + &access, ) .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. diff --git a/templates/emails/en/access_added.html b/templates/emails/en/access_added.html new file mode 100644 index 0000000..e0ca274 --- /dev/null +++ b/templates/emails/en/access_added.html @@ -0,0 +1,9 @@ + + + +

Hello {{ username }},

+

A new way to access your {{ app_name }} account was added: +{% if kind == "passkey" %}the passkey "{{ name }}"{% elif kind == "personal_access_token" %}the personal access token "{{ name }}"{% else %}sign-in with {{ name }}{% endif %}.

+

If you didn't do this, someone else knows your password: remove it from your security settings, reset your password and contact support.

+ + diff --git a/templates/emails/en/access_added.subject b/templates/emails/en/access_added.subject new file mode 100644 index 0000000..b61a1d7 --- /dev/null +++ b/templates/emails/en/access_added.subject @@ -0,0 +1 @@ +A new way to access your account was added diff --git a/templates/emails/en/password_changed.html b/templates/emails/en/password_changed.html index 7528bf2..15680da 100644 --- a/templates/emails/en/password_changed.html +++ b/templates/emails/en/password_changed.html @@ -4,5 +4,11 @@

Hello {{ username }},

Your password was changed on your {{ app_name }} account.

If you didn't do this, contact support immediately.

+{% if access %} +

These still open your account without the new password. Remove any you do not recognize from your security settings:

+
    +{% for item in access %}
  • {% if item.kind == "passkey" %}Passkey "{{ item.name }}"{% elif item.kind == "totp" %}Authenticator app{% elif item.kind == "email" %}Codes sent by email{% elif item.kind == "personal_access_token" %}Personal access token "{{ item.name }}"{% else %}Sign-in with {{ item.name }}{% endif %}
  • +{% endfor %}
+{% endif %} diff --git a/templates/emails/fr/access_added.html b/templates/emails/fr/access_added.html new file mode 100644 index 0000000..6bfe05b --- /dev/null +++ b/templates/emails/fr/access_added.html @@ -0,0 +1,9 @@ + + + +

Bonjour {{ username }},

+

Un nouveau moyen d'acceder a votre compte {{ app_name }} a ete ajoute : +{% if kind == "passkey" %}la cle d'acces "{{ name }}"{% elif kind == "personal_access_token" %}le jeton d'acces personnel "{{ name }}"{% else %}la connexion avec {{ name }}{% endif %}.

+

Si ce n'est pas vous, quelqu'un d'autre connait votre mot de passe : supprimez cet acces depuis vos parametres de securite, reinitialisez votre mot de passe et contactez le support.

+ + diff --git a/templates/emails/fr/access_added.subject b/templates/emails/fr/access_added.subject new file mode 100644 index 0000000..a1bd089 --- /dev/null +++ b/templates/emails/fr/access_added.subject @@ -0,0 +1 @@ +Un nouvel accès a été ajouté à votre compte diff --git a/templates/emails/fr/password_changed.html b/templates/emails/fr/password_changed.html index 01890ec..9cdefcc 100644 --- a/templates/emails/fr/password_changed.html +++ b/templates/emails/fr/password_changed.html @@ -4,5 +4,11 @@

Bonjour {{ username }},

Votre mot de passe a ete modifie sur votre compte {{ app_name }}.

Si vous n'etes pas a l'origine de cette action, contactez le support immediatement.

+{% if access %} +

Ces acces ouvrent toujours votre compte sans le nouveau mot de passe. Supprimez depuis vos parametres de securite ceux que vous ne reconnaissez pas :

+
    +{% for item in access %}
  • {% if item.kind == "passkey" %}Cle d'acces "{{ item.name }}"{% elif item.kind == "totp" %}Application d'authentification{% elif item.kind == "email" %}Codes envoyes par e-mail{% elif item.kind == "personal_access_token" %}Jeton d'acces personnel "{{ item.name }}"{% else %}Connexion avec {{ item.name }}{% endif %}
  • +{% endfor %}
+{% endif %} diff --git a/tests/security/regressions/access_persistence.rs b/tests/security/regressions/access_persistence.rs new file mode 100644 index 0000000..8a57935 --- /dev/null +++ b/tests/security/regressions/access_persistence.rs @@ -0,0 +1,140 @@ +//! Ways into an account that outlive its password (SEC-44). +//! +//! Whoever held the password for a while can add a passkey or a personal +//! access token, and neither ends with a password reset. The owner hears of +//! each addition, and every password change or reset lists what still opens +//! the account. A pending account taken back by a reset keeps nothing. + +use serde_json::{Value, json}; +use testkit::authenticator::SoftAuthenticator; + +use crate::common::{app::TestApp, fixtures}; + +const ACCESS_ADDED: &str = "A new way to access your account was added"; +const PASSWORD_CHANGED: &str = "Your password has been changed"; + +#[tokio::test] +async fn adding_a_passkey_or_a_token_is_announced_to_the_owner() { + let app = TestApp::spawn().await; + let user = fixtures::authenticated_user(&app, 0).await; + + let created = app + .post_auth( + "/users/me/tokens", + &user.access_token, + &json!({ "name": "nightly-backup" }), + ) + .await; + assert_eq!(created.status().as_u16(), 201); + let mail = app.mail.wait_for(&user.email, ACCESS_ADDED).await; + assert!(mail.html.contains("nightly-backup"), "{}", mail.html); + + let mut authenticator = SoftAuthenticator::for_app(&app); + let options: Value = app + .post_auth("/users/me/passkeys/options", &user.access_token, &json!({})) + .await + .json() + .await + .unwrap(); + let credential = authenticator.create(&options); + let registered = app + .post_auth( + "/users/me/passkeys", + &user.access_token, + &json!({ "name": "Planted key", "credential": credential }), + ) + .await; + assert_eq!(registered.status().as_u16(), 201); + let mails = app.mail.wait_for_count(&user.email, 3).await; + assert!( + mails + .iter() + .any(|m| m.subject == ACCESS_ADDED && m.html.contains("Planted key")) + ); +} + +#[tokio::test] +async fn a_reset_lists_what_still_opens_the_account() { + let app = TestApp::spawn().await; + let user = fixtures::authenticated_user(&app, 1).await; + // A passkey survives the reset; a personal access token does not (its + // session is revoked with the others), so only the passkey is listed. + let mut authenticator = SoftAuthenticator::for_app(&app); + let options: Value = app + .post_auth("/users/me/passkeys/options", &user.access_token, &json!({})) + .await + .json() + .await + .unwrap(); + let credential = authenticator.create(&options); + let registered = app + .post_auth( + "/users/me/passkeys", + &user.access_token, + &json!({ "name": "Planted key", "credential": credential }), + ) + .await; + assert_eq!(registered.status().as_u16(), 201); + let created = app + .post_auth( + "/users/me/tokens", + &user.access_token, + &json!({ "name": "planted-token" }), + ) + .await; + assert_eq!(created.status().as_u16(), 201); + + let token = fixtures::create_password_reset_token(&app.db, user.id).await; + let reset = app + .post( + "/auth/reset-password", + &json!({ "token": token.raw, "new_password": "Brand-New-Pass-9!" }), + ) + .await; + assert_eq!(reset.status().as_u16(), 200); + + let mail = app.mail.wait_for(&user.email, PASSWORD_CHANGED).await; + assert!(mail.html.contains("Planted key"), "{}", mail.html); + assert!(!mail.html.contains("planted-token"), "{}", mail.html); +} + +#[tokio::test] +async fn a_pending_account_taken_back_by_a_reset_keeps_no_other_access() { + let app = TestApp::spawn().await; + let user = fixtures::register_user(&app, 2).await; + // Rows only a signed-in account could have created: planted directly. + sqlx::query( + "INSERT INTO external_identities (user_id, provider, subject) VALUES ($1, 'github', '42')", + ) + .bind(user.id) + .execute(&app.db) + .await + .unwrap(); + sqlx::query( + "INSERT INTO recovery_codes (user_id, code_hash, code_position) VALUES ($1, $2, 1)", + ) + .bind(user.id) + .bind(auth_api::utils::crypto::sha256(b"planted-code").to_vec()) + .execute(&app.db) + .await + .unwrap(); + + let token = fixtures::create_password_reset_token(&app.db, user.id).await; + let reset = app + .post( + "/auth/reset-password", + &json!({ "token": token.raw, "new_password": "Owner-Takes-Back-7!" }), + ) + .await; + assert_eq!(reset.status().as_u16(), 200); + + for table in ["external_identities", "recovery_codes"] { + let left: i64 = + sqlx::query_scalar(&format!("SELECT count(*) FROM {table} WHERE user_id = $1")) + .bind(user.id) + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(left, 0, "{table} survived the owner's reset"); + } +} diff --git a/tests/security/regressions/mod.rs b/tests/security/regressions/mod.rs index f947623..b36f508 100644 --- a/tests/security/regressions/mod.rs +++ b/tests/security/regressions/mod.rs @@ -1,3 +1,4 @@ +mod access_persistence; mod account_hardening; mod pre_hijacking; mod second_factor; From d534caf8d5152f755e9c49bbbd0a1a0271be04ad Mon Sep 17 00:00:00 2001 From: SIIR3X <125802911+SIIR3X@users.noreply.github.com> Date: Sat, 19 Sep 2026 02:22:39 +0200 Subject: [PATCH 04/55] fix(identity): link external identities only when the binding completes the flow --- CHANGELOG.md | 5 ++ docs/dev/security-model.md | 7 +- src/repositories/external_identity.rs | 6 +- src/services/external_identity.rs | 107 +++++++++++++++---------- tests/integration/api/auth/external.rs | 44 ++++++++++ 5 files changed, 121 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fcd8d0..f47faa0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,11 @@ as described in the [versioning policy](docs/dev/guides/versioning.md). 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. ## [2.0.1] - 2026-09-18 diff --git a/docs/dev/security-model.md b/docs/dev/security-model.md index c3999d4..e633e15 100644 --- a/docs/dev/security-model.md +++ b/docs/dev/security-model.md @@ -115,7 +115,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. @@ -295,7 +298,7 @@ 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 registration on a pending address carries its own credentials in its link: registering someone's address first does not choose the password they activate | `the_owner_activates_the_account_with_the_password_they_chose`, `a_resend_repeats_the_latest_registration_not_the_first`, `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` | 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/services/external_identity.rs b/src/services/external_identity.rs index 020fe4a..85672ac 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()), }, @@ -408,55 +418,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?; - crate::services::user::notify_access_added( - state, - user_id, - crate::services::email::AccessItem { - kind: "external_identity", - name: provider.display_name.clone(), - }, - ) - .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")), } } @@ -520,7 +504,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, @@ -531,11 +516,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/tests/integration/api/auth/external.rs b/tests/integration/api/auth/external.rs index c44817a..1207560 100644 --- a/tests/integration/api/auth/external.rs +++ b/tests/integration/api/auth/external.rs @@ -472,3 +472,47 @@ async fn a_github_identity_is_its_numeric_user_id() { .await; assert_eq!(status, 200); } + +/// A callback URL is something anyone can be made to open. An attacker starting +/// a link on their own account and sending the provider URL to someone signed +/// in there must not get that person's identity linked: nothing is linked until +/// the browser holding the binding completes the flow. +#[tokio::test] +async fn a_callback_alone_links_nothing() { + let mock = Provider::start().await; + let app = app_with(&mock, IdentityProviderKind::Oidc).await; + let attacker = fixtures::authenticated_user(&app, 20).await; + + let (code, _binding) = through_provider( + &app, + &mock, + "corp", + ( + "/users/me/external-identities/corp/start", + Some(&attacker.access_token), + ), + "victim-subject", + ) + .await; + let linked: i64 = sqlx::query_scalar("SELECT count(*) FROM external_identities") + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(linked, 0, "the callback linked the identity on its own"); + + // The outcome code lands in the victim's browser; without the attacker's + // binding it completes nothing. + let (status, _) = post( + &app, + "/users/me/external-identities/complete", + Some(&attacker.access_token), + json!({ "code": code, "binding": "not-the-binding" }), + ) + .await; + assert_eq!(status, 401); + let linked: i64 = sqlx::query_scalar("SELECT count(*) FROM external_identities") + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(linked, 0); +} From 2708168a9484ee3db63a6e5f77c7af5c5227e2e2 Mon Sep 17 00:00:00 2001 From: SIIR3X <125802911+SIIR3X@users.noreply.github.com> Date: Sat, 19 Sep 2026 06:34:45 +0200 Subject: [PATCH 05/55] fix(admin): require a second factor proven by the session and restrict admin role grants --- CHANGELOG.md | 14 +++++ docs/dev/api/openapi.yaml | 8 ++- docs/dev/api/routes.md | 2 +- docs/dev/security-model.md | 14 +++-- migrations/0026_session_second_factor.sql | 6 ++ migrations/SHA256SUMS | 1 + src/bin/perf_load.rs | 1 + src/cli.rs | 18 ++++++ src/domain/session.rs | 3 + src/error.rs | 3 + src/handlers/admin/roles.rs | 3 +- src/handlers/extractors.rs | 17 +++--- src/repositories/role.rs | 15 +++++ src/repositories/session.rs | 25 ++++++-- src/repositories/user.rs | 12 ++++ src/services/admin/roles.rs | 35 +++++++++++- src/services/auth/login.rs | 1 + src/services/auth/second_factor.rs | 3 + src/services/auth/session.rs | 1 + src/services/auth/tokens.rs | 7 +++ src/services/passkey.rs | 2 + src/services/personal_access_token.rs | 1 + tests/integration/api/account/passkeys.rs | 55 +++++++++++++++++- tests/integration/api/admin/mod.rs | 12 ++++ tests/integration/api/admin/roles.rs | 60 +++++++++++++++++++- tests/integration/api/admin/users.rs | 24 ++++++++ tests/integration/repositories/role_grant.rs | 15 +++++ 27 files changed, 333 insertions(+), 25 deletions(-) create mode 100644 migrations/0026_session_second_factor.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index f47faa0..d8dd25d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,20 @@ as described in the [versioning policy](docs/dev/guides/versioning.md). 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 + (migration 0026); 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`). + +### Upgrading + +- Administrators signed in before the upgrade sign in again with their second + factor: sessions opened earlier carry no proof of it. ## [2.0.1] - 2026-09-18 diff --git a/docs/dev/api/openapi.yaml b/docs/dev/api/openapi.yaml index c363938..bbc092e 100644 --- a/docs/dev/api/openapi.yaml +++ b/docs/dev/api/openapi.yaml @@ -1142,7 +1142,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 +1153,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: diff --git a/docs/dev/api/routes.md b/docs/dev/api/routes.md index 8b47136..4a7e23b 100644 --- a/docs/dev/api/routes.md +++ b/docs/dev/api/routes.md @@ -10,7 +10,7 @@ the overview. | - | No authentication | | JWT | Access token in `Authorization: Bearer` | | 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 an account with a second factor | +| 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 | diff --git a/docs/dev/security-model.md b/docs/dev/security-model.md index e633e15..1199735 100644 --- a/docs/dev/security-model.md +++ b/docs/dev/security-model.md @@ -170,10 +170,15 @@ the database together, is out of scope. ## 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; nobody + grants a role to their own account. - 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 @@ -302,3 +307,4 @@ when a cited test no longer exists. | 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 registration on a pending address carries its own credentials in its link: registering someone's address first does not choose the password they activate | `the_owner_activates_the_account_with_the_password_they_chose`, `a_resend_repeats_the_latest_registration_not_the_first`, `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` | diff --git a/migrations/0026_session_second_factor.sql b/migrations/0026_session_second_factor.sql new file mode 100644 index 0000000..b2cfec3 --- /dev/null +++ b/migrations/0026_session_second_factor.sql @@ -0,0 +1,6 @@ +-- Whether the sign-in that started a session proved a second factor (TOTP, +-- email code, recovery code, or a passkey with user verification). The +-- administration requires it of the session itself: an administrator's +-- password alone, or a sign-in link alone, must not open it, whatever factors +-- the account has enrolled. Rotations inherit it. +ALTER TABLE sessions ADD COLUMN mfa BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/migrations/SHA256SUMS b/migrations/SHA256SUMS index 5ef9b33..27bfa87 100644 --- a/migrations/SHA256SUMS +++ b/migrations/SHA256SUMS @@ -23,3 +23,4 @@ a1e01ce9c1cde16545191dc1f31ec725dcfc4d15393657077f057efea7f72a20 0020_oauth.sql a3fdddb551010b532efa344548a0b467652649068b939f99de46d4a8fdaf1053 0023_passkeys.sql 075c2bb4a512689cb03c870b347cf1687944b0e9f0ccaeb8be1327b6af9279fb 0024_external_identities.sql 8c16772a3f7f23ebdb38fed5414e59799df768e930ec14972d37dfa69c98b11d 0025_pending_registrations.sql +abd34b501669ad72e0b8ce5ca2b2966e354448eb4b064e0b7eca1b465515f458 0026_session_second_factor.sql 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..94a4fbb 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -169,6 +169,24 @@ pub async fn grant_role(pool: &PgPool, grant: &RoleGrant) -> Result<(), String> .await .map_err(|e| e.to_string())? .ok_or_else(|| format!("no role named {}", grant.role))?; + // 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(pool, granted.id) + .await + .map_err(|e| e.to_string())? + { + let ready = account.status == crate::domain::user::UserStatus::Active + && user::has_second_factor(pool, 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(pool, account.id, granted.id, None).await { Ok(_) => {} diff --git a/src/domain/session.rs b/src/domain/session.rs index 5ee7cf8..fabce47 100644 --- a/src/domain/session.rs +++ b/src/domain/session.rs @@ -84,6 +84,8 @@ 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, } impl Session { @@ -258,6 +260,7 @@ mod tests { session_type: SessionType::Web, client_id: None, compromise_reason: None, + mfa: false, } } diff --git a/src/error.rs b/src/error.rs index 9946c52..04b2e15 100644 --- a/src/error.rs +++ b/src/error.rs @@ -247,6 +247,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." diff --git a/src/handlers/admin/roles.rs b/src/handlers/admin/roles.rs index 9764924..2e209c2 100644 --- a/src/handlers/admin/roles.rs +++ b/src/handlers/admin/roles.rs @@ -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" = [])), )] diff --git a/src/handlers/extractors.rs b/src/handlers/extractors.rs index d15669c..a8844f8 100644 --- a/src/handlers/extractors.rs +++ b/src/handlers/extractors.rs @@ -125,9 +125,10 @@ impl FromRequestParts for FirstPartyUser { } } -/// 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 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, } @@ -147,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/repositories/role.rs b/src/repositories/role.rs index 2190697..618929f 100644 --- a/src/repositories/role.rs +++ b/src/repositories/role.rs @@ -284,3 +284,18 @@ pub async fn permission_held<'e>( .fetch_one(executor) .await } + +/// Whether the role grants at least one administrative permission. +pub async fn grants_administration(pool: &PgPool, 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(pool) + .await +} diff --git a/src/repositories/session.rs b/src/repositories/session.rs index 9ed4392..2b73023 100644 --- a/src/repositories/session.rs +++ b/src/repositories/session.rs @@ -48,6 +48,9 @@ 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)] @@ -101,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) @@ -117,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 } @@ -143,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) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING *", ) .bind(input.user_id) @@ -159,6 +163,7 @@ 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) .fetch_one(&mut *tx) .await?; @@ -238,6 +243,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) diff --git a/src/repositories/user.rs b/src/repositories/user.rs index 19a73a7..a3cdd7b 100644 --- a/src/repositories/user.rs +++ b/src/repositories/user.rs @@ -173,6 +173,18 @@ pub async fn adopt_pending_credentials<'e>( Ok(()) } +/// Whether the account can prove a second factor: a verified TOTP or email +/// method, or a passkey. +pub async fn has_second_factor(pool: &PgPool, 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(pool) + .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. diff --git a/src/services/admin/roles.rs b/src/services/admin/roles.rs index d9e6353..03af4bb 100644 --- a/src/services/admin/roles.rs +++ b/src/services/admin/roles.rs @@ -120,11 +120,13 @@ pub async fn assign( user_id: Uuid, name: &str, ) -> Result<(), AppError> { + refuse_own_account(actor, user_id)?; let role = find(state, name).await?; - user_repo::find_by_id(&state.db, user_id) + let user = user_repo::find_by_id(&state.db, user_id) .await? .ok_or(AppError::NotFound)?; require_reauth(state, actor, "admin_assign_role").await?; + ensure_can_administer(state, &user, &role).await?; let mut tx = state.db.begin().await?; match role_repo::assign_to_user(&mut *tx, user_id, role.id, Some(actor.user_id)).await { @@ -165,6 +167,37 @@ pub async fn unassign( 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( + state: &AppState, + user: &crate::domain::user::User, + role: &Role, +) -> Result<(), AppError> { + if !role_repo::grants_administration(&state.db, role.id).await? { + return Ok(()); + } + if user.status != crate::domain::user::UserStatus::Active + || !user_repo::has_second_factor(&state.db, user.id).await? + { + return Err(AppError::Conflict("administrator_without_second_factor")); + } + Ok(()) +} + async fn find(state: &AppState, name: &str) -> Result { role_repo::find_by_name(&state.db, name) .await? diff --git a/src/services/auth/login.rs b/src/services/auth/login.rs index cac3f0d..7a50e8f 100644 --- a/src/services/auth/login.rs +++ b/src/services/auth/login.rs @@ -288,6 +288,7 @@ pub(crate) async fn first_factor_proven( identifier, request_id, audit_metadata, + second_factor: false, }), ) .await?; diff --git a/src/services/auth/second_factor.rs b/src/services/auth/second_factor.rs index 7ed6820..c11c4a1 100644 --- a/src/services/auth/second_factor.rs +++ b/src/services/auth/second_factor.rs @@ -137,6 +137,7 @@ pub async fn complete_two_factor_login( identifier: None, request_id, audit_metadata: json!({"two_factor": true}), + second_factor: true, }), ) .await?; @@ -205,6 +206,7 @@ pub async fn complete_email_2fa_login( identifier: None, request_id, audit_metadata: json!({"two_factor": "email"}), + second_factor: true, }), ) .await?; @@ -316,6 +318,7 @@ pub async fn complete_login_with_recovery( identifier: None, request_id, audit_metadata: json!({"two_factor": "recovery_code"}), + second_factor: true, }), ) .await?; diff --git a/src/services/auth/session.rs b/src/services/auth/session.rs index da3df67..cb6cd61 100644 --- a/src/services/auth/session.rs +++ b/src/services/auth/session.rs @@ -159,6 +159,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 diff --git a/src/services/auth/tokens.rs b/src/services/auth/tokens.rs index b717039..59d3121 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 diff --git a/src/services/passkey.rs b/src/services/passkey.rs index b11a727..6b5e966 100644 --- a/src/services/passkey.rs +++ b/src/services/passkey.rs @@ -375,6 +375,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 diff --git a/src/services/personal_access_token.rs b/src/services/personal_access_token.rs index 1f12397..ca643c4 100644 --- a/src/services/personal_access_token.rs +++ b/src/services/personal_access_token.rs @@ -100,6 +100,7 @@ pub async fn create( client_id: None, family_created_at: None, scopes: Some(&scopes), + mfa: false, }, ) .await?; diff --git a/tests/integration/api/account/passkeys.rs b/tests/integration/api/account/passkeys.rs index c6c07c2..fdbb4fb 100644 --- a/tests/integration/api/account/passkeys.rs +++ b/tests/integration/api/account/passkeys.rs @@ -255,10 +255,9 @@ async fn a_removed_passkey_no_longer_signs_in() { } #[tokio::test] -async fn a_passkey_counts_as_the_second_factor_of_an_administrator() { +async fn a_passkey_sign_in_is_the_second_factor_of_an_administrator() { let app = TestApp::spawn().await; let user = fixtures::authenticated_user(&app, 1).await; - let token = crate::api::admin::token_with(&app, &user, &["users:read"]); let role = auth_api::repositories::role::find_by_name(&app.db, "admin") .await .unwrap() @@ -266,8 +265,58 @@ async fn a_passkey_counts_as_the_second_factor_of_an_administrator() { auth_api::repositories::role::assign_to_user(&app.db, user.id, role.id, None) .await .unwrap(); + let mut authenticator = SoftAuthenticator::for_app(&app); + register(&app, &user, &mut authenticator).await; + + // Enrolled is not enough: this session came from the password. + let token = crate::api::admin::token_with(&app, &user, &["users:read"]); assert_eq!(app.get_auth("/admin/users", &token).await.status(), 403); - register(&app, &user, &mut SoftAuthenticator::for_app(&app)).await; + let (_, signed_in) = sign_in(&app, &mut authenticator).await; + let mut claims = app.decode_access_token(signed_in["access_token"].as_str().unwrap()); + claims.permissions = vec!["users:read".into()]; + let token = app.sign(&claims); assert_eq!(app.get_auth("/admin/users", &token).await.status(), 200); } + +/// The administration trusts a session only if its sign-in proved a second +/// factor: a passkey with user verification does, a password alone does not. +#[tokio::test] +async fn only_a_sign_in_with_a_second_factor_marks_its_session() { + let app = TestApp::spawn().await; + let user = fixtures::authenticated_user(&app, 40).await; + let mut authenticator = SoftAuthenticator::for_app(&app); + let (status, _) = register(&app, &user, &mut authenticator).await; + assert_eq!(status, 201); + + let mfa_of = |token: String| { + let app = &app; + async move { + let sid = app.decode_access_token(&token).sid; + sqlx::query_scalar::<_, bool>("SELECT mfa FROM sessions WHERE id = $1") + .bind(sid) + .fetch_one(&app.db) + .await + .unwrap() + } + }; + assert!(!mfa_of(user.access_token.clone()).await, "password sign-in"); + + let (status, signed_in) = sign_in(&app, &mut authenticator).await; + assert_eq!(status, 200, "{signed_in}"); + let token = signed_in["access_token"].as_str().unwrap().to_owned(); + assert!(mfa_of(token).await, "passkey sign-in"); + + // A refresh keeps it. + let refreshed: Value = app + .post( + "/auth/refresh", + &json!({ "refresh_token": signed_in["refresh_token"] }), + ) + .await + .json() + .await + .unwrap(); + let token = refreshed["access_token"].as_str().unwrap().to_owned(); + assert!(mfa_of(token).await, "rotation"); +} diff --git a/tests/integration/api/admin/mod.rs b/tests/integration/api/admin/mod.rs index 59f79c9..6a48fee 100644 --- a/tests/integration/api/admin/mod.rs +++ b/tests/integration/api/admin/mod.rs @@ -34,10 +34,22 @@ pub async fn admin(app: &TestApp, index: usize) -> Admin { .await .unwrap(); enroll_second_factor(app, user.id).await; + prove_second_factor(app, &user).await; let token = token_with(app, &user, &ADMIN_PERMISSIONS); Admin { user, token } } +/// Mark the user's session as signed in with a second factor, as completing +/// the challenge would. +pub async fn prove_second_factor(app: &TestApp, user: &AuthenticatedUser) { + let claims = app.decode_access_token(&user.access_token); + sqlx::query("UPDATE sessions SET mfa = TRUE WHERE id = $1") + .bind(claims.sid) + .execute(&app.db) + .await + .unwrap(); +} + pub async fn enroll_second_factor(app: &TestApp, user_id: Uuid) { sqlx::query( "INSERT INTO two_factor_methods (user_id, method_type, is_primary, is_verified) diff --git a/tests/integration/api/admin/roles.rs b/tests/integration/api/admin/roles.rs index 51af640..8205d6f 100644 --- a/tests/integration/api/admin/roles.rs +++ b/tests/integration/api/admin/roles.rs @@ -3,7 +3,7 @@ use reqwest::Method; use serde_json::{Value, json}; -use super::{admin, body}; +use super::{admin, body, enroll_second_factor}; use crate::common::{app::TestApp, fixtures}; async fn send( @@ -69,6 +69,7 @@ async fn a_role_is_created_granted_changed_taken_back_and_deleted() { let app = TestApp::spawn().await; let admin = admin(&app, 1).await; let member = fixtures::authenticated_user(&app, 2).await; + enroll_second_factor(&app, member.id).await; let (status, created) = send( &app, @@ -257,6 +258,7 @@ async fn nobody_can_remove_the_last_way_to_manage_roles_or_the_default_role() { // With a second administrator, the first can step down. let other = fixtures::authenticated_user(&app, 2).await; + enroll_second_factor(&app, other.id).await; let (status, _) = send( &app, Method::POST, @@ -296,3 +298,59 @@ async fn granting_a_role_needs_a_recent_reauthentication() { assert_eq!(response["code"], "reauthentication_required"); assert!(permissions_of(&app, member.id).await.is_empty()); } + +#[tokio::test] +async fn an_administrative_role_goes_only_to_an_active_account_with_a_second_factor() { + let app = TestApp::spawn().await; + let admin = admin(&app, 1).await; + let member = fixtures::authenticated_user(&app, 2).await; + let path = format!("/admin/users/{}/roles", member.id); + + let (status, response) = send( + &app, + Method::POST, + &path, + &admin.token, + json!({ "role": "admin" }), + ) + .await; + assert_eq!(status, 409, "{response}"); + assert_eq!(response["code"], "administrator_without_second_factor"); + + // A role without administrative permission needs nothing. + let (status, _) = send( + &app, + Method::POST, + &path, + &admin.token, + json!({ "role": "user" }), + ) + .await; + assert_eq!(status, 204); + + enroll_second_factor(&app, member.id).await; + let (status, response) = send( + &app, + Method::POST, + &path, + &admin.token, + json!({ "role": "admin" }), + ) + .await; + assert_eq!(status, 204, "{response}"); +} + +#[tokio::test] +async fn an_administrator_never_grants_a_role_to_their_own_account() { + let app = TestApp::spawn().await; + let admin = admin(&app, 1).await; + let (status, response) = send( + &app, + Method::POST, + &format!("/admin/users/{}/roles", admin.user.id), + &admin.token, + json!({ "role": "user" }), + ) + .await; + assert_eq!(status, 403, "{response}"); +} diff --git a/tests/integration/api/admin/users.rs b/tests/integration/api/admin/users.rs index 94224aa..62ba966 100644 --- a/tests/integration/api/admin/users.rs +++ b/tests/integration/api/admin/users.rs @@ -51,6 +51,30 @@ async fn an_administrator_without_a_second_factor_is_refused() { assert_eq!(response["code"], "two_factor_required"); } +#[tokio::test] +async fn an_administrator_whose_sign_in_skipped_the_second_factor_is_refused() { + // Enrolled, but the session came from the password alone: a passkey-only + // administrator signing in by password, or a sign-in link. + let app = TestApp::spawn().await; + let user = fixtures::authenticated_user(&app, 1).await; + let role = auth_api::repositories::role::find_by_name(&app.db, "admin") + .await + .unwrap() + .unwrap(); + auth_api::repositories::role::assign_to_user(&app.db, user.id, role.id, None) + .await + .unwrap(); + super::enroll_second_factor(&app, user.id).await; + let token = token_with(&app, &user, &["users:read"]); + + let (status, response) = body(app.get_auth("/admin/users", &token).await).await; + assert_eq!(status, 403); + assert_eq!(response["code"], "two_factor_required"); + + super::prove_second_factor(&app, &user).await; + assert_eq!(app.get_auth("/admin/users", &token).await.status(), 200); +} + #[tokio::test] async fn a_permission_revoked_in_the_database_stops_working_before_the_token_expires() { let app = TestApp::spawn().await; diff --git a/tests/integration/repositories/role_grant.rs b/tests/integration/repositories/role_grant.rs index 0858b86..bef4f2d 100644 --- a/tests/integration/repositories/role_grant.rs +++ b/tests/integration/repositories/role_grant.rs @@ -13,6 +13,21 @@ async fn granting_a_role_assigns_it_once_and_audits_it() { email: user.email.clone(), }; + // An administrative role waits for an active account with a second factor. + let refused = grant_role(&app.db, &grant).await.unwrap_err(); + assert!(refused.contains("second factor"), "{refused}"); + fixtures::activate_user(&app.db, user.id).await; + let refused = grant_role(&app.db, &grant).await.unwrap_err(); + assert!(refused.contains("second factor"), "{refused}"); + sqlx::query( + "INSERT INTO two_factor_methods (user_id, method_type, is_primary, is_verified) + VALUES ($1, 'email', TRUE, TRUE)", + ) + .bind(user.id) + .execute(&app.db) + .await + .unwrap(); + grant_role(&app.db, &grant).await.unwrap(); grant_role(&app.db, &grant).await.unwrap(); From e2599ba8be9165c9f28130106cb0c40dff87a013 Mon Sep 17 00:00:00 2001 From: SIIR3X <125802911+SIIR3X@users.noreply.github.com> Date: Sat, 19 Sep 2026 10:46:51 +0200 Subject: [PATCH 06/55] fix(admin): require reauthentication for webhooks, suspension and forced resets and audit atomically --- CHANGELOG.md | 5 + docs/dev/api/openapi.yaml | 44 ++++----- docs/dev/security-model.md | 9 +- src/handlers/admin/audit.rs | 2 +- src/handlers/admin/clients.rs | 6 +- src/handlers/admin/roles.rs | 8 +- src/handlers/admin/users.rs | 14 +-- src/handlers/admin/webhooks.rs | 17 ++-- src/repositories/registered_client.rs | 6 +- src/repositories/user.rs | 7 +- src/repositories/webhook.rs | 33 ++++--- src/services/admin/clients.rs | 26 ++++- src/services/admin/users.rs | 30 +++++- src/services/webhooks.rs | 122 +++++++++++++++++++++--- tests/integration/api/admin/users.rs | 29 ++++++ tests/integration/api/admin/webhooks.rs | 112 ++++++++++++++++++++++ 16 files changed, 384 insertions(+), 86 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d8dd25d..b38667d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,11 @@ as described in the [versioning policy](docs/dev/guides/versioning.md). 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. ### Upgrading diff --git a/docs/dev/api/openapi.yaml b/docs/dev/api/openapi.yaml index bbc092e..f84150f 100644 --- a/docs/dev/api/openapi.yaml +++ b/docs/dev/api/openapi.yaml @@ -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: @@ -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: @@ -433,7 +433,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: @@ -485,7 +485,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 +525,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 +645,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: @@ -820,7 +820,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 +878,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: @@ -1016,7 +1016,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: @@ -1076,7 +1076,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: @@ -1226,7 +1226,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: @@ -1296,7 +1296,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: @@ -1356,7 +1356,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: @@ -1416,7 +1416,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: @@ -1468,7 +1468,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: @@ -1517,7 +1517,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: @@ -1593,7 +1593,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: @@ -1664,7 +1664,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: @@ -1730,7 +1730,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: @@ -1791,7 +1791,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: @@ -1855,7 +1855,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: diff --git a/docs/dev/security-model.md b/docs/dev/security-model.md index 1199735..21f3bdd 100644 --- a/docs/dev/security-model.md +++ b/docs/dev/security-model.md @@ -184,8 +184,12 @@ the database together, is out of scope. - 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 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 +- Granting a role, changing what a role grants, saving a client or changing its + secret, creating, redirecting or re-keying a webhook, suspending an account + and forcing its reset need a recent re-authentication: a stolen administrator + token alone can neither send account events elsewhere nor shut owners out. + Every change is audited in the same transaction; a webhook's audit keeps the + host it points to (never the path or query), and redeliveries are audited. No change may leave the deployment without an account holding `roles:manage`. ## Webhooks @@ -308,3 +312,4 @@ when a cited test no longer exists. | SEC-43 | A registration on a pending address carries its own credentials in its link: registering someone's address first does not choose the password they activate | `the_owner_activates_the_account_with_the_password_they_chose`, `a_resend_repeats_the_latest_registration_not_the_first`, `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` | diff --git a/src/handlers/admin/audit.rs b/src/handlers/admin/audit.rs index 4e7f18a..0a26922 100644 --- a/src/handlers/admin/audit.rs +++ b/src/handlers/admin/audit.rs @@ -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" = [])), diff --git a/src/handlers/admin/clients.rs b/src/handlers/admin/clients.rs index a4e4e00..abce1ea 100644 --- a/src/handlers/admin/clients.rs +++ b/src/handlers/admin/clients.rs @@ -81,7 +81,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" = [])), )] @@ -149,7 +149,7 @@ pub async fn save( responses( (status = 204, description = "Client removed and its sessions revoked"), (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" = [])), @@ -208,7 +208,7 @@ pub async fn rotate_secret( responses( (status = 204, description = "Secret removed; the client is public"), (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 2e209c2..757e52d 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" = [])), )] @@ -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), ), @@ -243,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..7c4e524 100644 --- a/src/handlers/admin/users.rs +++ b/src/handlers/admin/users.rs @@ -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" = [])), @@ -159,7 +159,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" = [])), @@ -187,7 +187,7 @@ 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), ), @@ -212,7 +212,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 +236,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 +260,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" = [])), @@ -284,7 +284,7 @@ pub async fn revoke_sessions( 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), ), security(("bearer" = [])), diff --git a/src/handlers/admin/webhooks.rs b/src/handlers/admin/webhooks.rs index 5d1f500..853a0f3 100644 --- a/src/handlers/admin/webhooks.rs +++ b/src/handlers/admin/webhooks.rs @@ -119,7 +119,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 +140,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" = [])), @@ -171,7 +171,7 @@ pub async fn create( responses( (status = 200, description = "Endpoint updated; the secret is unchanged", 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, 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), ), @@ -197,7 +197,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 +221,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 +245,7 @@ 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), ), security(("bearer" = [])), )] @@ -272,7 +272,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 +280,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/repositories/registered_client.rs b/src/repositories/registered_client.rs index fb53b52..2227c17 100644 --- a/src/repositories/registered_client.rs +++ b/src/repositories/registered_client.rs @@ -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,7 +113,7 @@ pub async fn set_secret_hash( ) .bind(client_id) .bind(secret_hash) - .execute(pool) + .execute(executor) .await?; Ok(result.rows_affected() == 1) } diff --git a/src/repositories/user.rs b/src/repositories/user.rs index a3cdd7b..f9d1747 100644 --- a/src/repositories/user.rs +++ b/src/repositories/user.rs @@ -331,10 +331,13 @@ 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(()) } diff --git a/src/repositories/webhook.rs b/src/repositories/webhook.rs index df792c0..569aa3f 100644 --- a/src/repositories/webhook.rs +++ b/src/repositories/webhook.rs @@ -58,8 +58,8 @@ pub struct ClaimedDelivery { // Endpoints -pub async fn create_endpoint( - pool: &PgPool, +pub async fn create_endpoint<'e>( + executor: impl sqlx::PgExecutor<'e>, settings: &EndpointSettings<'_>, encrypted_secret: &str, ) -> Result { @@ -73,12 +73,12 @@ pub async fn create_endpoint( .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 +93,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) } @@ -271,7 +274,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 +286,7 @@ pub async fn redeliver(pool: &PgPool, endpoint_id: Uuid, id: Uuid) -> Result 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?; + 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 +182,7 @@ pub async fn remove_secret( ), ) .await?; + tx.commit().await?; Ok(()) } diff --git a/src/services/admin/users.rs b/src/services/admin/users.rs index c9bfa8c..ec9d58b 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( @@ -130,13 +131,15 @@ pub async fn reactivate(state: &AppState, actor: &Actor, user_id: Uuid) -> Resul /// End a lockout: the sign-in lockout and the re-authentication one. pub async fn unlock(state: &AppState, actor: &Actor, user_id: Uuid) -> Result<(), AppError> { 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)]).await; Ok(()) } @@ -183,6 +186,7 @@ pub async fn force_password_reset( user_id: Uuid, ) -> 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?; @@ -241,6 +245,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/webhooks.rs b/src/services/webhooks.rs index a0a147e..82213b6 100644 --- a/src/services/webhooks.rs +++ b/src/services/webhooks.rs @@ -267,15 +267,45 @@ fn new_secret(state: &AppState) -> Result<(String, String), AppError> { 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)?; + require_reauth(state, actor, "admin_create_webhook").await?; let (secret, encrypted) = new_secret(state)?; + let mut tx = state.db.begin().await?; let endpoint = webhook_repo::create_endpoint( - &state.db, + &mut *tx, &EndpointSettings { url: input.url, description, @@ -285,7 +315,15 @@ 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), @@ -299,8 +337,13 @@ pub async fn update( input: &EndpointInput<'_>, ) -> 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 +354,106 @@ pub async fn update( ) .await? .ok_or(AppError::NotFound)?; - audit_change(state, actor, AuditAction::WebhookUpdated, id).await?; + audit_change( + &mut tx, + actor, + AuditAction::WebhookUpdated, + id, + json!({ + "previous_host": url_host(&previous.url), + "host": url_host(input.url), + }), + ) + .await?; + tx.commit().await?; Ok(endpoint) } pub async fn rotate_secret(state: &AppState, actor: &Actor, id: Uuid) -> Result { + require_reauth(state, actor, "admin_webhook_secret").await?; let (secret, encrypted) = new_secret(state)?; - if !webhook_repo::replace_secret(&state.db, id, &encrypted).await? { + 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) } pub async fn delete(state: &AppState, actor: &Actor, id: Uuid) -> Result<(), AppError> { - if !webhook_repo::delete_endpoint(&state.db, id).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? { + 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/tests/integration/api/admin/users.rs b/tests/integration/api/admin/users.rs index 62ba966..47f478e 100644 --- a/tests/integration/api/admin/users.rs +++ b/tests/integration/api/admin/users.rs @@ -439,3 +439,32 @@ async fn deleting_an_account_needs_a_recent_reauthentication_and_announces_it() "an administrator does not delete their own account here" ); } + +#[tokio::test] +async fn suspending_or_forcing_a_reset_needs_a_recent_reauthentication() { + let app = TestApp::spawn().await; + let admin = admin(&app, 1).await; + let target = fixtures::authenticated_user(&app, 2).await; + app.clear_recent_reauth(&admin.token).await; + + for action in ["suspend", "password-reset"] { + let (status, response) = body( + app.post_auth( + &format!("/admin/users/{}/{action}", target.id), + &admin.token, + &json!({}), + ) + .await, + ) + .await; + assert_eq!(status, 403, "{action}: {response}"); + assert_eq!(response["code"], "reauthentication_required"); + } + assert_eq!( + app.get_auth("/users/me", &target.access_token) + .await + .status(), + 200, + "the target is untouched" + ); +} diff --git a/tests/integration/api/admin/webhooks.rs b/tests/integration/api/admin/webhooks.rs index 0bd8fb2..b6d883b 100644 --- a/tests/integration/api/admin/webhooks.rs +++ b/tests/integration/api/admin/webhooks.rs @@ -362,3 +362,115 @@ async fn endpoints_are_checked_updated_rotated_and_removed() { ] ); } + +/// A stolen administrator token alone must not point account events +/// somewhere: creating, redirecting or re-keying a webhook needs a recent +/// re-authentication, and the audit keeps where events went. +#[tokio::test] +async fn pointing_a_webhook_somewhere_needs_a_reauthentication_and_is_traced() { + let app = TestApp::spawn().await; + let admin = admin(&app, 1).await; + let (_receiver, url) = Receiver::start().await; + + app.clear_recent_reauth(&admin.token).await; + let (status, response) = send( + &app, + Method::POST, + "/admin/webhooks", + &admin.token, + json!({ "url": url, "events": ["*"] }), + ) + .await; + assert_eq!(status, 403, "{response}"); + assert_eq!(response["code"], "reauthentication_required"); + + let (status, _) = send( + &app, + Method::POST, + "/users/me/reauth", + &admin.user.access_token, + json!({ "current_password": admin.user.password }), + ) + .await; + assert_eq!(status, 204); + let (status, created) = send( + &app, + Method::POST, + "/admin/webhooks", + &admin.token, + json!({ "url": url, "events": ["*"] }), + ) + .await; + assert_eq!(status, 201, "{created}"); + let id = created["id"].as_str().unwrap().to_owned(); + + let metadata: Value = sqlx::query_scalar( + "SELECT metadata FROM audit_log WHERE action = 'webhook_created' AND user_id = $1", + ) + .bind(admin.user.id) + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(metadata["host"], "127.0.0.1"); + assert_eq!(metadata["webhook_id"], id.as_str()); + assert!( + !metadata.to_string().contains("/hook"), + "no path: {metadata}" + ); + + app.clear_recent_reauth(&admin.token).await; + for (method, path) in [ + (Method::PUT, format!("/admin/webhooks/{id}")), + (Method::POST, format!("/admin/webhooks/{id}/secret")), + ] { + let (status, response) = send( + &app, + method, + &path, + &admin.token, + json!({ "url": url, "events": ["*"] }), + ) + .await; + assert_eq!(status, 403, "{path}: {response}"); + assert_eq!(response["code"], "reauthentication_required"); + } +} + +#[tokio::test] +async fn a_redelivery_is_audited() { + let app = TestApp::spawn().await; + let admin = admin(&app, 1).await; + let (receiver, url) = Receiver::start().await; + let (_, created) = send( + &app, + Method::POST, + "/admin/webhooks", + &admin.token, + json!({ "url": url, "events": ["user.created"] }), + ) + .await; + let id = created["id"].as_str().unwrap().to_owned(); + fixtures::register_user(&app, 2).await; + eventually(|| receiver.count() == 1).await; + let deliveries = delivery_state(&app, &admin.token, &id).await; + let delivery = deliveries[0]["id"].as_str().unwrap().to_owned(); + + let (status, response) = send( + &app, + Method::POST, + &format!("/admin/webhooks/{id}/deliveries/{delivery}/retry"), + &admin.token, + json!({}), + ) + .await; + assert_eq!(status, 204, "{response}"); + let redelivered: i64 = sqlx::query_scalar( + "SELECT count(*) FROM audit_log + WHERE action = 'webhook_updated' AND metadata->>'redelivered' = $1", + ) + .bind(&delivery) + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(redelivered, 1); +} From 5f9360b243ca1f42b6bb71af1e135f97795b8848 Mon Sep 17 00:00:00 2001 From: SIIR3X <125802911+SIIR3X@users.noreply.github.com> Date: Sat, 19 Sep 2026 14:58:58 +0200 Subject: [PATCH 07/55] fix(admin): keep an active role manager through suspensions, deletions and concurrent changes --- CHANGELOG.md | 4 + docs/dev/api/openapi.yaml | 18 +++++ docs/dev/security-model.md | 5 +- src/handlers/admin/users.rs | 2 + src/handlers/user.rs | 1 + src/repositories/role.rs | 3 +- src/services/admin/roles.rs | 13 +++- src/services/admin/users.rs | 6 ++ src/services/user.rs | 12 +++ tests/integration/api/admin/roles.rs | 106 +++++++++++++++++++++++++++ 10 files changed, 165 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b38667d..eae4f7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,10 @@ as described in the [versioning policy](docs/dev/guides/versioning.md). (`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. ### Upgrading diff --git a/docs/dev/api/openapi.yaml b/docs/dev/api/openapi.yaml index f84150f..fc05e02 100644 --- a/docs/dev/api/openapi.yaml +++ b/docs/dev/api/openapi.yaml @@ -955,6 +955,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: @@ -1367,6 +1373,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: @@ -3910,6 +3922,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: diff --git a/docs/dev/security-model.md b/docs/dev/security-model.md index 21f3bdd..1784721 100644 --- a/docs/dev/security-model.md +++ b/docs/dev/security-model.md @@ -190,7 +190,9 @@ the database together, is out of scope. token alone can neither send account events elsewhere nor shut owners out. Every change is audited in the same transaction; a webhook's audit keeps the host it points to (never the path or query), and redeliveries are audited. No change may leave the deployment without an - account holding `roles:manage`. + 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. ## Webhooks @@ -313,3 +315,4 @@ when a cited test no longer exists. | 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` | diff --git a/src/handlers/admin/users.rs b/src/handlers/admin/users.rs index 7c4e524..8e6ec2b 100644 --- a/src/handlers/admin/users.rs +++ b/src/handlers/admin/users.rs @@ -190,6 +190,7 @@ pub async fn detail( (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" = [])), )] @@ -311,6 +312,7 @@ pub async fn force_password_reset( (status = 401, description = "Missing, invalid or revoked access token, or wrong password", 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" = [])), )] diff --git a/src/handlers/user.rs b/src/handlers/user.rs index 65e7fd6..f92af5e 100644 --- a/src/handlers/user.rs +++ b/src/handlers/user.rs @@ -396,6 +396,7 @@ 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" = [])), )] diff --git a/src/repositories/role.rs b/src/repositories/role.rs index 618929f..a9ef27c 100644 --- a/src/repositories/role.rs +++ b/src/repositories/role.rs @@ -277,7 +277,8 @@ 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) diff --git a/src/services/admin/roles.rs b/src/services/admin/roles.rs index 03af4bb..bbd31b4 100644 --- a/src/services/admin/roles.rs +++ b/src/services/admin/roles.rs @@ -241,9 +241,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 ec9d58b..0836a98 100644 --- a/src/services/admin/users.rs +++ b/src/services/admin/users.rs @@ -71,10 +71,16 @@ 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 manages_roles = + role_repo::user_has_permission(&state.db, user_id, crate::domain::role::ROLES_MANAGE) + .await?; let mut tx = state.db.begin().await?; if !user_repo::suspend(&mut *tx, user_id).await? { return Ok(()); } + if manages_roles { + super::roles::keep_an_administrator(&mut tx).await?; + } session_repo::revoke_all_by_user(&mut *tx, user_id).await?; audit::append( &mut *tx, diff --git a/src/services/user.rs b/src/services/user.rs index 3dadad2..ea6cdf2 100644 --- a/src/services/user.rs +++ b/src/services/user.rs @@ -392,6 +392,15 @@ pub(crate) async fn erase_account( // 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 manages_roles = crate::repositories::role::user_has_permission( + &state.db, + user_id, + crate::domain::role::ROLES_MANAGE, + ) + .await?; + let mut tx = state.db.begin().await?; // Appended before the deletion: the foreign key then sets its user_id to NULL. @@ -414,6 +423,9 @@ pub(crate) async fn erase_account( user_repo::forget_traces(&mut *tx, user_id).await?; user_repo::delete(&mut *tx, user_id).await?; + if manages_roles { + super::admin::roles::keep_an_administrator(&mut tx).await?; + } tx.commit().await?; events::wake(); diff --git a/tests/integration/api/admin/roles.rs b/tests/integration/api/admin/roles.rs index 8205d6f..49de184 100644 --- a/tests/integration/api/admin/roles.rs +++ b/tests/integration/api/admin/roles.rs @@ -354,3 +354,109 @@ async fn an_administrator_never_grants_a_role_to_their_own_account() { .await; assert_eq!(status, 403, "{response}"); } + +/// An administrator holding `users:manage` only: enough to suspend or delete +/// accounts, not to manage roles. +async fn account_manager(app: &TestApp, index: usize) -> super::Admin { + let user = fixtures::authenticated_user(app, index).await; + let role_id: uuid::Uuid = sqlx::query_scalar( + "INSERT INTO roles (name) VALUES ('account_manager') + ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name RETURNING id", + ) + .fetch_one(&app.db) + .await + .unwrap(); + sqlx::query( + "INSERT INTO role_permissions (role_id, permission_id) + SELECT $1, id FROM permissions WHERE name = 'users:manage' + ON CONFLICT DO NOTHING", + ) + .bind(role_id) + .execute(&app.db) + .await + .unwrap(); + sqlx::query("INSERT INTO user_roles (user_id, role_id) VALUES ($1, $2)") + .bind(user.id) + .bind(role_id) + .execute(&app.db) + .await + .unwrap(); + super::enroll_second_factor(app, user.id).await; + super::prove_second_factor(app, &user).await; + let token = super::token_with(app, &user, &["users:manage"]); + super::Admin { user, token } +} + +#[tokio::test] +async fn the_last_role_manager_is_neither_suspended_nor_deleted() { + let app = TestApp::spawn().await; + let owner = admin(&app, 1).await; + let manager = account_manager(&app, 2).await; + + for (method, path) in [ + ( + Method::POST, + format!("/admin/users/{}/suspend", owner.user.id), + ), + (Method::DELETE, format!("/admin/users/{}", owner.user.id)), + ] { + let (status, response) = send(&app, method, &path, &manager.token, json!({})).await; + assert_eq!(status, 409, "{path}: {response}"); + assert_eq!(response["code"], "last_administrator"); + } + + // Nor by the owner themselves. + let (status, response) = send( + &app, + Method::DELETE, + "/users/me", + &owner.user.access_token, + json!({ "current_password": owner.user.password }), + ) + .await; + assert_eq!(status, 409, "{response}"); + assert_eq!(response["code"], "last_administrator"); + + // Anyone else goes as before. + let (status, _) = send( + &app, + Method::DELETE, + "/users/me", + &manager.user.access_token, + json!({ "current_password": manager.user.password }), + ) + .await; + assert_eq!(status, 204); +} + +#[tokio::test] +async fn concurrent_withdrawals_never_leave_nobody_managing_roles() { + let app = TestApp::spawn().await; + let first = admin(&app, 1).await; + let second = admin(&app, 2).await; + + let withdraw = |target: uuid::Uuid| { + let app = &app; + let token = first.token.clone(); + async move { + send( + app, + Method::DELETE, + &format!("/admin/users/{target}/roles/admin"), + &token, + json!({}), + ) + .await + .0 + } + }; + let (a, b) = tokio::join!(withdraw(first.user.id), withdraw(second.user.id)); + let mut outcomes = [a, b]; + outcomes.sort(); + assert_eq!(outcomes, [204, 409], "one withdrawal must be refused"); + assert!( + auth_api::repositories::role::permission_held(&app.db, "roles:manage") + .await + .unwrap() + ); +} From d5b380f82682f3293c4280d0424155070f60c26b Mon Sep 17 00:00:00 2001 From: SIIR3X <125802911+SIIR3X@users.noreply.github.com> Date: Sat, 19 Sep 2026 19:11:04 +0200 Subject: [PATCH 08/55] fix(db): split schema ownership from the runtime role and stop logging bound values --- CHANGELOG.md | 14 ++ deploy/db/auth-api-grants.sql | 46 +++++++ deploy/db/auth-api-role.sql | 4 +- deploy/db/postgresql.auth-api.conf | 4 + docs/deploy/api/deployment.md | 2 +- docs/deploy/database/deployment.md | 81 +++++++++-- docs/deploy/guides/operations.md | 2 +- docs/deploy/guides/update.md | 2 +- docs/dev/security-model.md | 9 ++ migrations/0027_runtime_role.sql | 23 ++++ migrations/SHA256SUMS | 1 + scripts/restore-db.sh | 5 +- tests/integration/migrations/mod.rs | 1 + tests/integration/migrations/runtime_role.rs | 136 +++++++++++++++++++ 14 files changed, 310 insertions(+), 20 deletions(-) create mode 100644 deploy/db/auth-api-grants.sql create mode 100644 migrations/0027_runtime_role.sql create mode 100644 tests/integration/migrations/runtime_role.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index eae4f7d..4328be3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,11 +52,25 @@ as described in the [versioning policy](docs/dev/guides/versioning.md). 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 (migration 0027). +- `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. ### Upgrading - Administrators signed in before the upgrade sign in again with their second factor: sessions opened earlier carry no proof of it. +- Recommended: move the database to two roles (database deployment guide, + section 2.6): create `auth_api_owner`, `REASSIGN OWNED BY auth_api`, run + `deploy/db/auth-api-grants.sql`, then run migrations with the owner's URL + (`prod/auth-api/database-owner-url`). A single-role deployment keeps working. +- Copy the new `log_parameter_max_length` lines of + `deploy/db/postgresql.auth-api.conf` and reload PostgreSQL. ## [2.0.1] - 2026-09-18 diff --git a/deploy/db/auth-api-grants.sql b/deploy/db/auth-api-grants.sql new file mode 100644 index 0000000..ec0022c --- /dev/null +++ b/deploy/db/auth-api-grants.sql @@ -0,0 +1,46 @@ +-- 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; +GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public 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; +ALTER DEFAULT PRIVILEGES FOR ROLE auth_api_owner IN SCHEMA public + GRANT EXECUTE ON FUNCTIONS TO auth_api; + +-- The audit log is append-only for the application: rows are rewritten only +-- by the functions of migration 0027, 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 +$$; + +-- 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; 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/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/docs/deploy/api/deployment.md b/docs/deploy/api/deployment.md index e2aa068..2782680 100644 --- a/docs/deploy/api/deployment.md +++ b/docs/deploy/api/deployment.md @@ -106,7 +106,7 @@ 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 ``` diff --git a/docs/deploy/database/deployment.md b/docs/deploy/database/deployment.md index 1c316af..1429b73 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 @@ -152,9 +163,16 @@ sudo cp deploy/db/postgresql.auth-api.conf /etc/postgresql/17/main/conf.d/auth-a Edit `/etc/postgresql/17/main/pg_hba.conf` - allow the API VPS via its VPN IP only: ```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,16 +211,51 @@ 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. + +A deployment where a single `auth_api` role owns the schema keeps working, but +without these limits; section 2.6 moves it to two roles. + +--- + +### 2.6 Move a single-role deployment to two roles + +For a database created before 2.1.0, owned by `auth_api`. **On the DB VPS**, +with the API running (ownership changes do not block it): + +```sql +-- sudo -u postgres psql -d auth_api +CREATE ROLE auth_api_owner LOGIN PASSWORD 'owner-strong-password'; +REASSIGN OWNED BY auth_api TO auth_api_owner; +ALTER DATABASE auth_api OWNER TO auth_api_owner; +``` + +Then run `deploy/db/auth-api-grants.sql` as in section 2.5, add the +`auth_api_owner` line to `pg_hba.conf`, and run later migrations with the owner +URL. --- -### 2.6 Size PostgreSQL's memory +### 2.7 Size PostgreSQL's memory Reads barely notice the number of accounts. Writes do, once the indexes they update no longer fit in memory: at 1 million accounts the sign-in transaction @@ -439,14 +492,16 @@ 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" +sudo -u postgres psql -c "CREATE DATABASE auth_api_restore OWNER auth_api_owner" 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" + -d "postgres://auth_api_owner:@10.0.0.2/auth_api_restore" ``` Check the restored data, then point `DATABASE_URL` at it (or rename the diff --git a/docs/deploy/guides/operations.md b/docs/deploy/guides/operations.md index 5f20814..0123a80 100644 --- a/docs/deploy/guides/operations.md +++ b/docs/deploy/guides/operations.md @@ -323,7 +323,7 @@ Measured in [the performance campaign](../../perf/performance-report.md) | `ARGON2_MAX_CONCURRENCY` | The CPU limit of the instance | | Memory per instance | 64 MiB x Argon2 concurrency + 256 MiB, rounded up to a multiple of 128 MiB; reservation half of it | | `DB_MAX_CONNECTIONS`, `REDIS_POOL_SIZE` | 4 x the cores of the instance, at least 8. The sum over every instance, plus 10, stays under PostgreSQL's `max_connections` | -| PostgreSQL memory | Twice the indexes that sign-ins and refreshes update, about 4 KB per account (see [Database Deployment](../database/deployment.md#26-size-postgresqls-memory)) | +| PostgreSQL memory | Twice the indexes that sign-ins and refreshes update, about 4 KB per account (see [Database Deployment](../database/deployment.md#27-size-postgresqls-memory)) | Validated with `make sizing` ([perf/README.md](../../../perf/README.md#sizing-validation)) on 2026-09-15: the production image under each profile's CPU quota and memory diff --git a/docs/deploy/guides/update.md b/docs/deploy/guides/update.md index 640ef6c..006205f 100644 --- a/docs/deploy/guides/update.md +++ b/docs/deploy/guides/update.md @@ -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 ``` diff --git a/docs/dev/security-model.md b/docs/dev/security-model.md index 1784721..f7c039f 100644 --- a/docs/dev/security-model.md +++ b/docs/dev/security-model.md @@ -220,6 +220,14 @@ the database together, is out of scope. 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. PostgreSQL logs slow statements without their bound + values. ## Network edge @@ -316,3 +324,4 @@ when a cited test no longer exists. | 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` | diff --git a/migrations/0027_runtime_role.sql b/migrations/0027_runtime_role.sql new file mode 100644 index 0000000..37ddfc9 --- /dev/null +++ b/migrations/0027_runtime_role.sql @@ -0,0 +1,23 @@ +-- Functions the application calls for work it may not do by itself once the +-- schema belongs to a separate owner role (see deploy/db/auth-api-grants.sql): +-- creating and dropping audit partitions, rewriting audit rows when an address +-- is coarsened or an account forgotten, and purging accounts never verified. +-- They run with the privileges of their owner, with a fixed search path. +-- +-- A deployment where one role owns and uses the schema is unchanged: the +-- owner runs its own functions. +ALTER FUNCTION rotate_audit_log_partitions(INTEGER, INTEGER) + SECURITY DEFINER SET search_path = public, pg_temp; +ALTER FUNCTION coarsen_audit_addresses(INTERVAL, INTEGER) + SECURITY DEFINER SET search_path = public, pg_temp; +ALTER FUNCTION forget_account_traces(UUID) + SECURITY DEFINER SET search_path = public, pg_temp; +ALTER FUNCTION purge_unverified_accounts(INTERVAL, INTEGER) + SECURITY DEFINER SET search_path = public, pg_temp; + +-- A function running with its owner's privileges is callable only by roles +-- the grants name, not by every role of the cluster. +REVOKE EXECUTE ON FUNCTION rotate_audit_log_partitions(INTEGER, INTEGER) FROM PUBLIC; +REVOKE EXECUTE ON FUNCTION coarsen_audit_addresses(INTERVAL, INTEGER) FROM PUBLIC; +REVOKE EXECUTE ON FUNCTION forget_account_traces(UUID) FROM PUBLIC; +REVOKE EXECUTE ON FUNCTION purge_unverified_accounts(INTERVAL, INTEGER) FROM PUBLIC; diff --git a/migrations/SHA256SUMS b/migrations/SHA256SUMS index 27bfa87..ee1f631 100644 --- a/migrations/SHA256SUMS +++ b/migrations/SHA256SUMS @@ -24,3 +24,4 @@ a3fdddb551010b532efa344548a0b467652649068b939f99de46d4a8fdaf1053 0023_passkeys. 075c2bb4a512689cb03c870b347cf1687944b0e9f0ccaeb8be1327b6af9279fb 0024_external_identities.sql 8c16772a3f7f23ebdb38fed5414e59799df768e930ec14972d37dfa69c98b11d 0025_pending_registrations.sql abd34b501669ad72e0b8ce5ca2b2966e354448eb4b064e0b7eca1b465515f458 0026_session_second_factor.sql +524db32bbc3476a82eed8a91d416dd8ed3c617ffb5e6283b10baa349ed837b35 0027_runtime_role.sql diff --git a/scripts/restore-db.sh b/scripts/restore-db.sh index d6d0d38..c3661ea 100755 --- a/scripts/restore-db.sh +++ b/scripts/restore-db.sh @@ -4,8 +4,9 @@ # Usage: # 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, +# 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 diff --git a/tests/integration/migrations/mod.rs b/tests/integration/migrations/mod.rs index 737b230..47b170d 100644 --- a/tests/integration/migrations/mod.rs +++ b/tests/integration/migrations/mod.rs @@ -2,3 +2,4 @@ mod apply_all; mod extensions; mod files; mod query_plans; +mod runtime_role; diff --git a/tests/integration/migrations/runtime_role.rs b/tests/integration/migrations/runtime_role.rs new file mode 100644 index 0000000..ed65bb2 --- /dev/null +++ b/tests/integration/migrations/runtime_role.rs @@ -0,0 +1,136 @@ +//! The runtime role of `deploy/db/auth-api-grants.sql` (SEC-48): it reads and +//! writes data, and nothing else. A compromised application or an SQL +//! injection cannot erase the audit trail, alter the schema, or change the +//! permission catalog. + +use sqlx::{Connection, PgConnection}; +use testkit::TestDb; + +const RUNTIME_PASSWORD: &str = "runtime-role-test"; + +/// The two roles of the grants script, shared by every test database of the +/// cluster (roles are cluster-wide); creating them races between tests. +async fn ensure_roles(db: &TestDb) { + for statement in [ + "CREATE ROLE auth_api_owner NOLOGIN".to_owned(), + format!("CREATE ROLE auth_api LOGIN PASSWORD '{RUNTIME_PASSWORD}'"), + ] { + if let Err(e) = sqlx::query(&statement).execute(&db.pool).await { + let duplicate = e + .as_database_error() + .and_then(|e| e.code()) + .is_some_and(|code| code == "42710" || code == "23505"); + assert!(duplicate, "{statement}: {e}"); + } + } +} + +async fn runtime_connection(db: &TestDb) -> PgConnection { + ensure_roles(db).await; + let grants = + std::fs::read_to_string(testkit::workspace_path("deploy/db/auth-api-grants.sql")).unwrap(); + sqlx::raw_sql(&grants).execute(&db.pool).await.unwrap(); + + let mut url = reqwest::Url::parse(&db.url).unwrap(); + url.set_username("auth_api").unwrap(); + url.set_password(Some(RUNTIME_PASSWORD)).unwrap(); + PgConnection::connect(url.as_str()).await.unwrap() +} + +async fn refused(conn: &mut PgConnection, statement: &str) { + let error = sqlx::raw_sql(statement) + .execute(&mut *conn) + .await + .expect_err(statement); + let code = error + .as_database_error() + .and_then(|e| e.code()) + .map(|c| c.into_owned()); + assert_eq!( + code.as_deref(), + Some("42501"), + "{statement} failed otherwise: {error}" + ); +} + +#[tokio::test] +async fn the_runtime_role_cannot_erase_the_audit_trail_or_alter_the_schema() { + let db = TestDb::new().await; + let mut runtime = runtime_connection(&db).await; + + for statement in [ + "ALTER TABLE audit_log DISABLE TRIGGER ALL", + "TRUNCATE audit_log", + "DROP TABLE audit_log_default", + "UPDATE audit_log SET metadata = '{}'", + "DELETE FROM audit_log", + "DELETE FROM audit_log_default", + "DELETE FROM permissions", + "UPDATE permissions SET description = 'planted'", + "DELETE FROM _sqlx_migrations", + "CREATE TABLE planted (id INT)", + "ALTER TABLE users ADD COLUMN planted TEXT", + ] { + refused(&mut runtime, statement).await; + } +} + +#[tokio::test] +async fn the_runtime_role_does_everything_the_service_needs() { + let db = TestDb::new().await; + let user_id: uuid::Uuid = sqlx::query_scalar( + "INSERT INTO users (username, email, password_hash) + VALUES ('runtime_role', 'runtime.role@example.com', repeat('h', 60)) RETURNING id", + ) + .fetch_one(&db.pool) + .await + .unwrap(); + let mut runtime = runtime_connection(&db).await; + + // Writes data, the audit log included. + sqlx::query( + "INSERT INTO audit_log (user_id, action, ip_address) VALUES ($1, 'login', '203.0.113.9')", + ) + .bind(user_id) + .execute(&mut runtime) + .await + .unwrap(); + sqlx::query("UPDATE users SET preferred_locale = 'fr' WHERE id = $1") + .bind(user_id) + .execute(&mut runtime) + .await + .unwrap(); + + // Runs the maintenance the service schedules, through the functions that + // hold the owner's privileges. + for statement in [ + "SELECT rotate_audit_log_partitions(12, 2)", + "SELECT coarsen_audit_addresses('0 seconds'::interval, 100)", + "SELECT purge_unverified_accounts('3650 days'::interval, 100)", + ] { + sqlx::raw_sql(statement) + .execute(&mut runtime) + .await + .unwrap_or_else(|e| panic!("{statement}: {e}")); + } + + // Erases an account: its traces through the function, then the row; the + // audit entries lose their account through the foreign key. + sqlx::query("SELECT forget_account_traces($1)") + .bind(user_id) + .execute(&mut runtime) + .await + .unwrap(); + sqlx::query("DELETE FROM users WHERE id = $1") + .bind(user_id) + .execute(&mut runtime) + .await + .unwrap(); + let orphaned: i64 = sqlx::query_scalar( + "SELECT count(*) FROM audit_log WHERE user_id IS NULL AND action = 'login'", + ) + .fetch_one(&db.pool) + .await + .unwrap(); + assert_eq!(orphaned, 1); +} From 82e256b420061883b1d4e7122a112a2708e5b62e Mon Sep 17 00:00:00 2001 From: SIIR3X <125802911+SIIR3X@users.noreply.github.com> Date: Sat, 19 Sep 2026 23:23:10 +0200 Subject: [PATCH 09/55] fix(crypto): key one-time code digests, bind ciphertexts to their rows and compare in constant time --- CHANGELOG.md | 2 + Cargo.lock | 1 + Cargo.toml | 1 + benches/core_benches.rs | 4 +- crates/testkit/src/app.rs | 18 +- docs/deploy/guides/operations.md | 8 +- docs/dev/guides/commands.md | 2 +- docs/dev/security-model.md | 12 +- src/domain/user.rs | 26 +- src/fuzzing.rs | 35 +- src/main.rs | 12 + src/repositories/email_2fa.rs | 9 +- src/repositories/two_factor.rs | 9 +- src/repositories/webhook.rs | 10 +- src/services/auth/second_factor.rs | 1 + src/services/authorize.rs | 2 +- src/services/device.rs | 13 +- src/services/email_2fa.rs | 17 +- src/services/email_change.rs | 53 ++- src/services/external_identity.rs | 6 +- src/services/key_rotation.rs | 69 ++-- src/services/oauth.rs | 2 +- src/services/two_factor.rs | 3 +- src/services/webhooks.rs | 13 +- src/utils/crypto.rs | 314 ++++++++++++++---- src/utils/totp.rs | 104 +++++- tests/integration/api/mail.rs | 17 +- tests/integration/api/two_factor/email.rs | 19 +- .../integration/api/two_factor/management.rs | 8 +- .../repositories/email_2fa_codes.rs | 8 +- tests/integration/services/key_rotation.rs | 71 +++- tests/security/regressions/second_factor.rs | 14 +- 32 files changed, 679 insertions(+), 204 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4328be3..4292c86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,8 @@ as described in the [versioning policy](docs/dev/guides/versioning.md). section 2.6): create `auth_api_owner`, `REASSIGN OWNED BY auth_api`, run `deploy/db/auth-api-grants.sql`, then run migrations with the owner's URL (`prod/auth-api/database-owner-url`). A single-role deployment keeps working. +- Run `auth-api --rotate-totp-keys` once, without `PREVIOUS_ENCRYPTION_KEY`, to + bind the secrets written before the upgrade to their rows. - Copy the new `log_parameter_max_length` lines of `deploy/db/postgresql.auth-api.conf` and reload PostgreSQL. diff --git a/Cargo.lock b/Cargo.lock index 70d12ab..79c10dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -224,6 +224,7 @@ dependencies = [ "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..51da843 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" 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..c3aa4f1 100644 --- a/crates/testkit/src/app.rs +++ b/crates/testkit/src/app.rs @@ -389,6 +389,7 @@ impl TestApp { .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,7 +397,11 @@ 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. @@ -600,11 +605,13 @@ pub fn test_config(db_url: &str, redis_url: &str, nats_url: &str) -> Config { } /// 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 +666,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/docs/deploy/guides/operations.md b/docs/deploy/guides/operations.md index 0123a80..13cc869 100644 --- a/docs/deploy/guides/operations.md +++ b/docs/deploy/guides/operations.md @@ -93,11 +93,17 @@ and resumed. Run it until it reports `rotated=0 failed=0`: secrets already under the new key are skipped, and a secret changed during the run is left as the service wrote it. + + Without `PREVIOUS_ENCRYPTION_KEY`, the same command rewrites secrets written + before 2.1.0 in the current format, which binds each one to its account or + endpoint; run it once after upgrading. 4. Remove the previous key and redeploy: `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 diff --git a/docs/dev/guides/commands.md b/docs/dev/guides/commands.md index ab5f834..b2db987 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; without `PREVIOUS_ENCRYPTION_KEY` it only upgrades secrets written in an older format (see the [operations runbook](../../deploy/guides/operations.md)) | `--register-client` options: diff --git a/docs/dev/security-model.md b/docs/dev/security-model.md index f7c039f..fb5b6e0 100644 --- a/docs/dev/security-model.md +++ b/docs/dev/security-model.md @@ -102,7 +102,16 @@ the database together, is out of scope. - 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 + service refuses to start while a secret names a key it no longer holds. +- 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 @@ -325,3 +334,4 @@ when a cited test no longer exists. | 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` | 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/fuzzing.rs b/src/fuzzing.rs index 891ac14..72384ba 100644 --- a/src/fuzzing.rs +++ b/src/fuzzing.rs @@ -282,6 +282,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 +297,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 +336,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 +351,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/main.rs b/src/main.rs index 1f7c68f..4005ed6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -94,6 +94,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 diff --git a/src/repositories/email_2fa.rs b/src/repositories/email_2fa.rs index 0d962b1..b577f6e 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,19 +43,19 @@ 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 } diff --git a/src/repositories/two_factor.rs b/src/repositories/two_factor.rs index 0eac4f5..d3ae149 100644 --- a/src/repositories/two_factor.rs +++ b/src/repositories/two_factor.rs @@ -200,9 +200,12 @@ 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 +/// `(method id, account id, ciphertext)` of every TOTP secret. +pub async fn find_all_totp_secrets( + pool: &PgPool, +) -> Result, sqlx::Error> { + let rows: Vec<(Uuid, Uuid, String)> = sqlx::query_as( + "SELECT id, user_id, totp_secret FROM two_factor_methods WHERE method_type = 'totp' AND totp_secret IS NOT NULL", ) diff --git a/src/repositories/webhook.rs b/src/repositories/webhook.rs index 569aa3f..b08dad8 100644 --- a/src/repositories/webhook.rs +++ b/src/repositories/webhook.rs @@ -52,22 +52,26 @@ pub struct ClaimedDelivery { pub payload: Value, pub occurred_at: OffsetDateTime, pub attempts: i32, + pub endpoint_id: Uuid, pub url: String, pub secret: String, } // Endpoints +/// `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) @@ -206,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) diff --git a/src/services/auth/second_factor.rs b/src/services/auth/second_factor.rs index c11c4a1..70ba23b 100644 --- a/src/services/auth/second_factor.rs +++ b/src/services/auth/second_factor.rs @@ -73,6 +73,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, diff --git a/src/services/authorize.rs b/src/services/authorize.rs index dc860e5..4c9fc6b 100644 --- a/src/services/authorize.rs +++ b/src/services/authorize.rs @@ -295,7 +295,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 diff --git a/src/services/device.rs b/src/services/device.rs index 3e6cbc9..433d162 100644 --- a/src/services/device.rs +++ b/src/services/device.rs @@ -137,18 +137,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}") } diff --git a/src/services/email_2fa.rs b/src/services/email_2fa.rs index 6adee4f..9053399 100644 --- a/src/services/email_2fa.rs +++ b/src/services/email_2fa.rs @@ -187,7 +187,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, user_id.as_bytes(), &code); email_2fa::create( &state.db, @@ -241,6 +244,9 @@ pub async fn verify_login_code( verify_otp(state, user_id, submitted_code, &fail_key).await } +/// Separates the digests of these codes from any other flow's. +const OTP_PURPOSE: &str = "email_2fa"; + // Shared OTP verification logic async fn verify_otp( @@ -272,8 +278,13 @@ async fn verify_otp( 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, user_id.as_bytes(), 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()))?; diff --git a/src/services/email_change.rs b/src/services/email_change.rs index 8a50cf5..a9d98be 100644 --- a/src/services/email_change.rs +++ b/src/services/email_change.rs @@ -115,7 +115,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, @@ -180,7 +180,14 @@ pub async fn verify_current( }; let fail_key = format!("email_change_fail:{}", flow_token); - verify_otp(state, submitted_code, flow.otp_hash.as_deref(), &fail_key).await?; + verify_otp( + state, + user_id, + submitted_code, + flow.otp_hash.as_deref(), + &fail_key, + ) + .await?; flow.step = next; flow.otp_hash = None; @@ -214,7 +221,7 @@ pub async fn submit_new( } 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); @@ -286,7 +293,14 @@ 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?; + 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 @@ -451,6 +465,7 @@ async fn load_flow( async fn verify_otp( state: &AppState, + user_id: Uuid, submitted_code: &str, expected_hash: Option<&str>, fail_key: &str, @@ -470,7 +485,16 @@ async fn verify_otp( 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); } @@ -479,8 +503,19 @@ async fn verify_otp( 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) +/// 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 85672ac..999afbc 100644 --- a/src/services/external_identity.rs +++ b/src/services/external_identity.rs @@ -461,7 +461,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() { diff --git a/src/services/key_rotation.rs b/src/services/key_rotation.rs index ca83154..3aabd22 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) => { @@ -152,3 +147,25 @@ pub async fn rotate_totp_encryption_key(state: &AppState) -> 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 ~ '^v[12]:' AND split_part(totp_secret, ':', 2) <> ALL($1)) + + (SELECT count(*) FROM webhook_endpoints + WHERE secret ~ '^v[12]:' AND 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..f1ebfb1 100644 --- a/src/services/oauth.rs +++ b/src/services/oauth.rs @@ -181,7 +181,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 diff --git a/src/services/two_factor.rs b/src/services/two_factor.rs index dadb848..cb0e118 100644 --- a/src/services/two_factor.rs +++ b/src/services/two_factor.rs @@ -155,7 +155,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 +213,7 @@ pub async fn verify_setup( let valid = totp::verify_code( encrypted_secret, + user_id, code, &state.keyring, state.config.crypto.totp_skew, diff --git a/src/services/webhooks.rs b/src/services/webhooks.rs index 82213b6..921e195 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)) { @@ -258,11 +258,12 @@ 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)) } @@ -302,10 +303,12 @@ pub async fn create( ) -> Result { let (events, description) = checked(state, input)?; require_reauth(state, actor, "admin_create_webhook").await?; - let (secret, encrypted) = new_secret(state)?; + 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( &mut *tx, + id, &EndpointSettings { url: input.url, description, @@ -371,7 +374,7 @@ pub async fn update( pub async fn rotate_secret(state: &AppState, actor: &Actor, id: Uuid) -> Result { require_reauth(state, actor, "admin_webhook_secret").await?; - let (secret, encrypted) = new_secret(state)?; + 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); diff --git a/src/utils/crypto.rs b/src/utils/crypto.rs index 5a1c3bc..ed224b3 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}; @@ -37,19 +40,37 @@ pub fn sha256(data: &[u8]) -> [u8; 32] { 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 +80,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 +119,28 @@ pub fn decode_encryption_key(b64: &str) -> Result<[u8; 32], CryptoError> { // Keyring -/// Prefix of versioned ciphertexts: `v1:{kid}:{base64(nonce || ciphertext)}`. +/// Prefix of versioned ciphertexts without associated data: +/// `v1:{kid}:{base64(nonce || ciphertext)}`. Still read, never written. 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 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,6 +151,7 @@ pub struct Keyring { struct KeyEntry { kid: String, key: [u8; 32], + otp_key: [u8; 32], } impl KeyEntry { @@ -128,8 +163,36 @@ impl KeyEntry { 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,15 +216,24 @@ 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 { + /// Decrypt a value written by [`Keyring::encrypt`] for `context`, or an + /// older value (`v1`, or unversioned) that carries no context. + pub fn decrypt(&self, stored: &str, context: &[u8]) -> Result { + if let Some(rest) = stored.strip_prefix(V2_PREFIX) { + let (kid, body) = rest.split_once(':').ok_or(CryptoError::InvalidInput)?; + let key = self.key_for(kid).ok_or(CryptoError::UnknownKey)?; + return decrypt_with_aad(body, key, &v2_aad(context)); + } match stored.strip_prefix(V1_PREFIX) { Some(rest) => { let (kid, body) = rest.split_once(':').ok_or(CryptoError::InvalidInput)?; @@ -175,14 +247,51 @@ impl Keyring { } } - /// 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` names a key this keyring holds. Unversioned values name + /// none and are not judged. + pub fn knows_key_of(&self, stored: &str) -> bool { + let named = stored + .strip_prefix(V2_PREFIX) + .or_else(|| stored.strip_prefix(V1_PREFIX)) + .and_then(|rest| rest.split_once(':')) + .map(|(kid, _)| kid); + named.is_none_or(|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 +302,25 @@ impl Keyring { // AES-256-GCM +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. Returns base64(nonce || ciphertext). pub fn encrypt(plaintext: &str, key: &[u8; 32]) -> Result { + encrypt_with_aad(plaintext, key, &[]) +} + +/// [`encrypt`] with associated data: authenticated, not encrypted, and required +/// again to decrypt. +pub 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 +329,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 +346,13 @@ 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`. pub fn decrypt(encoded: &str, key: &[u8; 32]) -> Result { + decrypt_with_aad(encoded, key, &[]) +} + +/// Decrypts a value produced by [`encrypt_with_aad`] with the same data. +pub 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 +365,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) @@ -349,33 +481,99 @@ 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 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_the_legacy_format() { + fn keyring_reads_the_previous_key_and_the_older_formats() { let old = Keyring::new([1u8; 32], None); - let versioned_old = old.encrypt("secret").unwrap(); + let v2_old = old.encrypt("secret", b"row").unwrap(); + let v1_old = format!( + "v1:{}:{}", + old.current_kid(), + encrypt("secret", &[1u8; 32]).unwrap() + ); let legacy_old = encrypt("secret", &[1u8; 32]).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)); + for stored in [&v2_old, &v1_old, &legacy_old] { + assert_eq!(rotating.decrypt(stored, b"row").unwrap(), "secret"); + assert!(rotating.needs_rotation(stored)); + } + // A v1 value under the current key is rewritten too, to gain its context. + assert!(old.needs_rotation(&v1_old)); } #[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)); + assert!(other.knows_key_of(&encrypt("legacy", &[1u8; 32]).unwrap())); + } + + #[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 +592,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 +627,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/totp.rs b/src/utils/totp.rs index 6eb5bda..1cb7772 100644 --- a/src/utils/totp.rs +++ b/src/utils/totp.rs @@ -41,6 +41,7 @@ pub fn qr_uri(base32_secret: &str, email: &str, issuer: &str) -> String { /// `now` (Unix timestamp, from the application clock) are accepted. pub fn verify_code( encrypted_secret: &str, + owner: uuid::Uuid, code: &str, keyring: &Keyring, skew: u8, @@ -55,7 +56,7 @@ pub fn verify_code( if code.len() != 6 || !code.bytes().all(|b| b.is_ascii_digit()) { return Ok(false); } - let plaintext = keyring.decrypt(encrypted_secret)?; + let plaintext = keyring.decrypt(encrypted_secret, owner.as_bytes())?; Ok(totp(&plaintext, skew)?.check(code, now).is_some()) } @@ -170,7 +171,17 @@ mod tests { #[test] fn verify_correct_code_returns_true() { let code = code_at(NOW); - assert!(verify_code(&encrypted_rfc_secret(), &code, &keyring(), 1, NOW).unwrap()); + assert!( + verify_code( + &encrypted_rfc_secret(), + uuid::Uuid::nil(), + &code, + &keyring(), + 1, + NOW + ) + .unwrap() + ); } #[test] @@ -180,16 +191,27 @@ mod tests { assert_ne!(previous, code_at(NOW)); assert_ne!(two_back, previous); - assert!(verify_code(&encrypted, &previous, &keyring(), 1, NOW).unwrap()); - assert!(verify_code(&encrypted, &next, &keyring(), 1, NOW).unwrap()); - assert!(!verify_code(&encrypted, &previous, &keyring(), 0, NOW).unwrap()); - assert!(!verify_code(&encrypted, &two_back, &keyring(), 1, NOW).unwrap()); + assert!(verify_code(&encrypted, uuid::Uuid::nil(), &previous, &keyring(), 1, NOW).unwrap()); + assert!(verify_code(&encrypted, uuid::Uuid::nil(), &next, &keyring(), 1, NOW).unwrap()); + assert!( + !verify_code(&encrypted, uuid::Uuid::nil(), &previous, &keyring(), 0, NOW).unwrap() + ); + assert!( + !verify_code(&encrypted, uuid::Uuid::nil(), &two_back, &keyring(), 1, NOW).unwrap() + ); } #[test] fn a_time_before_the_epoch_is_an_error() { assert!(matches!( - verify_code(&encrypted_rfc_secret(), "123456", &keyring(), 1, -1), + verify_code( + &encrypted_rfc_secret(), + uuid::Uuid::nil(), + "123456", + &keyring(), + 1, + -1 + ), Err(TotpError::TimeError) )); } @@ -202,24 +224,51 @@ mod tests { } else { "000000" }; - assert!(!verify_code(&encrypted, wrong, &keyring(), 0, NOW).unwrap()); + assert!(!verify_code(&encrypted, uuid::Uuid::nil(), wrong, &keyring(), 0, NOW).unwrap()); } #[test] fn verify_with_wrong_key_fails() { let wrong_key = Keyring::new([99u8; 32], None); - assert!(verify_code(&encrypted_rfc_secret(), &code_at(NOW), &wrong_key, 1, NOW).is_err()); + assert!( + verify_code( + &encrypted_rfc_secret(), + uuid::Uuid::nil(), + &code_at(NOW), + &wrong_key, + 1, + NOW + ) + .is_err() + ); } #[test] fn times_within_the_skew_of_the_epoch_are_refused_without_panicking() { for now in [0, 29, 59] { assert!(matches!( - verify_code(&encrypted_rfc_secret(), "287082", &keyring(), 2, now), + verify_code( + &encrypted_rfc_secret(), + uuid::Uuid::nil(), + "287082", + &keyring(), + 2, + now + ), Err(TotpError::TimeError) )); } - assert!(verify_code(&encrypted_rfc_secret(), &code_at(60), &keyring(), 2, 60).unwrap()); + assert!( + verify_code( + &encrypted_rfc_secret(), + uuid::Uuid::nil(), + &code_at(60), + &keyring(), + 2, + 60 + ) + .unwrap() + ); } #[test] @@ -235,7 +284,17 @@ mod tests { code[..5].to_owned(), fullwidth, ] { - assert!(!verify_code(&encrypted_rfc_secret(), &candidate, &keyring(), 1, NOW).unwrap()); + assert!( + !verify_code( + &encrypted_rfc_secret(), + uuid::Uuid::nil(), + &candidate, + &keyring(), + 1, + NOW + ) + .unwrap() + ); } } @@ -247,12 +306,29 @@ mod tests { for code in ["12345", "abcdef", "1234567", ""] { assert!( matches!( - verify_code(&encrypted_rfc_secret(), code, &wrong_key, 1, NOW), + verify_code( + &encrypted_rfc_secret(), + uuid::Uuid::nil(), + code, + &wrong_key, + 1, + NOW + ), Ok(false) ), "{code:?}" ); } - assert!(verify_code(&encrypted_rfc_secret(), "123456", &wrong_key, 1, NOW).is_err()); + assert!( + verify_code( + &encrypted_rfc_secret(), + uuid::Uuid::nil(), + "123456", + &wrong_key, + 1, + NOW + ) + .is_err() + ); } } diff --git a/tests/integration/api/mail.rs b/tests/integration/api/mail.rs index 7f1d6c2..83760c5 100644 --- a/tests/integration/api/mail.rs +++ b/tests/integration/api/mail.rs @@ -13,7 +13,8 @@ use crate::common::{app::TestApp, fixtures}; // helpers /// Extract the OTP code from the `email_2fa_codes` table for a user. -/// Tokens are stored as SHA-256 hashes, so we brute-force the 6-digit space. +/// Codes are stored as keyed digests; the test holds the key and walks the +/// 6-digit space. async fn otp_from_db(app: &TestApp, user_id: uuid::Uuid) -> String { let hash: Vec = sqlx::query_scalar( "SELECT code_hash FROM email_2fa_codes @@ -25,15 +26,11 @@ async fn otp_from_db(app: &TestApp, user_id: uuid::Uuid) -> String { .await .expect("no email_2fa_code found"); - use sha2::{Digest, Sha256}; - for n in 0u32..1_000_000 { - let candidate = format!("{:06}", n); - let digest = Sha256::digest(candidate.as_bytes()); - if digest.as_slice() == hash { - return candidate; - } - } - panic!("could not find OTP matching hash"); + testkit::app::brute_force_otp(&hash, |code| { + app.state + .keyring + .otp_digest("email_2fa", user_id.as_bytes(), code) + }) } // 1. Registration verification email diff --git a/tests/integration/api/two_factor/email.rs b/tests/integration/api/two_factor/email.rs index 8b7ee45..7624d1a 100644 --- a/tests/integration/api/two_factor/email.rs +++ b/tests/integration/api/two_factor/email.rs @@ -1,7 +1,6 @@ use crate::common::{app::TestApp, fixtures}; use auth_api::repositories::{email_2fa as email_2fa_repo, recovery_code as recovery_code_repo}; use serde_json::Value; -use sha2::{Digest, Sha256}; // helpers @@ -51,19 +50,11 @@ async fn read_otp_from_db(app: &TestApp, user_id: uuid::Uuid) -> String { .await .expect("no active email_2fa_code found"); - brute_force_otp(&row.0) -} - -/// Brute-force a 6-digit OTP from its SHA-256 hash. -fn brute_force_otp(expected_hash: &[u8]) -> String { - for n in 0u32..1_000_000 { - let candidate = format!("{:06}", n); - let h = Sha256::digest(candidate.as_bytes()); - if h.as_slice() == expected_hash { - return candidate; - } - } - panic!("OTP not found in 6-digit space - unexpected hash"); + testkit::app::brute_force_otp(&row.0, |code| { + app.state + .keyring + .otp_digest("email_2fa", user_id.as_bytes(), code) + }) } async fn active_email_code_hashes(app: &TestApp, user_id: uuid::Uuid) -> Vec> { diff --git a/tests/integration/api/two_factor/management.rs b/tests/integration/api/two_factor/management.rs index f7a85a8..45aa312 100644 --- a/tests/integration/api/two_factor/management.rs +++ b/tests/integration/api/two_factor/management.rs @@ -8,7 +8,6 @@ //! - signing in alone never grants sensitive actions. use serde_json::{Value, json}; -use sha2::{Digest, Sha256}; use crate::common::{ app::TestApp, @@ -57,7 +56,12 @@ async fn enable_email_2fa(app: &TestApp, user: &AuthenticatedUser) -> String { sqlx::query("UPDATE email_2fa_codes SET code_hash = $2 WHERE user_id = $1 AND used_at IS NULL") .bind(user.id) - .bind(Sha256::digest(b"424242").to_vec()) + .bind( + app.state + .keyring + .otp_digest("email_2fa", user.id.as_bytes(), "424242") + .to_vec(), + ) .execute(&app.db) .await .unwrap(); diff --git a/tests/integration/repositories/email_2fa_codes.rs b/tests/integration/repositories/email_2fa_codes.rs index e980ddc..7cb3b18 100644 --- a/tests/integration/repositories/email_2fa_codes.rs +++ b/tests/integration/repositories/email_2fa_codes.rs @@ -32,7 +32,7 @@ async fn a_new_code_replaces_the_live_one() { .expect("a live code"); assert_eq!(live.id, second); assert!( - email_2fa::find_active_by_user_and_hash(&db.pool, user, &[1u8; 32]) + email_2fa::find_active_by_user_and_hash(&db.pool, user, &[vec![1u8; 32]]) .await .unwrap() .is_none(), @@ -57,13 +57,13 @@ async fn a_code_verifies_only_for_the_user_it_was_sent_to() { issue(&db, owner, &hash).await; assert!( - email_2fa::find_active_by_user_and_hash(&db.pool, owner, &hash) + email_2fa::find_active_by_user_and_hash(&db.pool, owner, &[hash.to_vec()]) .await .unwrap() .is_some() ); assert!( - email_2fa::find_active_by_user_and_hash(&db.pool, other, &hash) + email_2fa::find_active_by_user_and_hash(&db.pool, other, &[hash.to_vec()]) .await .unwrap() .is_none(), @@ -111,7 +111,7 @@ async fn an_expired_code_is_neither_found_nor_consumed() { .is_none() ); assert!( - email_2fa::find_active_by_user_and_hash(&db.pool, user, &hash) + email_2fa::find_active_by_user_and_hash(&db.pool, user, &[hash.to_vec()]) .await .unwrap() .is_none() diff --git a/tests/integration/services/key_rotation.rs b/tests/integration/services/key_rotation.rs index c79e5b1..7344853 100644 --- a/tests/integration/services/key_rotation.rs +++ b/tests/integration/services/key_rotation.rs @@ -23,14 +23,13 @@ async fn rotation_state(app: &TestApp, active: &str, previous: &str) -> AppState // Error paths +/// Without a previous key, a run upgrades older ciphertexts in place and +/// leaves `v2` ones alone. #[tokio::test] -async fn rotate_fails_when_no_previous_key_configured() { +async fn without_a_previous_key_a_run_only_upgrades_older_formats() { let app = TestApp::spawn().await; - let result = rotate_totp_encryption_key(&app.state).await; - assert!( - result.is_err(), - "must fail when previous_encryption_key is absent" - ); + let result = rotate_totp_encryption_key(&app.state).await.unwrap(); + assert_eq!((result.rotated, result.failed), (0, 0)); } #[tokio::test] @@ -101,7 +100,7 @@ async fn rotate_re_encrypts_totp_secret_with_new_key() { // Confirm it decrypts under KEY_A. let plaintext = crypto::Keyring::new(key_a, None) - .decrypt(&before) + .decrypt(&before, user.id.as_bytes()) .expect("must decrypt under KEY_A"); // Rotate KEY_A --> KEY_B on the same isolated DB via a shared-pool state. @@ -126,11 +125,13 @@ async fn rotate_re_encrypts_totp_secret_with_new_key() { assert_ne!(before, after, "secret must change after rotation"); let rotated_plaintext = crypto::Keyring::new(key_b, None) - .decrypt(&after) + .decrypt(&after, user.id.as_bytes()) .expect("must decrypt under KEY_B"); assert_eq!(plaintext, rotated_plaintext, "plaintext must be preserved"); assert!( - crypto::Keyring::new(key_a, None).decrypt(&after).is_err(), + crypto::Keyring::new(key_a, None) + .decrypt(&after, user.id.as_bytes()) + .is_err(), "re-encrypted secret must not be readable with old key" ); } @@ -226,7 +227,9 @@ async fn rotate_upgrades_secrets_written_before_ciphertexts_were_versioned() { .fetch_one(&app.db) .await .unwrap(); - let plaintext = crypto::Keyring::new(key_a, None).decrypt(&stored).unwrap(); + let plaintext = crypto::Keyring::new(key_a, None) + .decrypt(&stored, user.id.as_bytes()) + .unwrap(); let legacy = crypto::encrypt(&plaintext, &key_a).unwrap(); sqlx::query("UPDATE two_factor_methods SET totp_secret = $1 WHERE user_id = $2") .bind(&legacy) @@ -246,9 +249,14 @@ async fn rotate_upgrades_secrets_written_before_ciphertexts_were_versioned() { .fetch_one(&app.db) .await .unwrap(); - assert!(after.starts_with("v1:"), "rotated secrets are versioned"); + assert!( + after.starts_with("v2:"), + "rotated secrets are bound to their row" + ); assert_eq!( - crypto::Keyring::new(key_b, None).decrypt(&after).unwrap(), + crypto::Keyring::new(key_b, None) + .decrypt(&after, user.id.as_bytes()) + .unwrap(), plaintext ); @@ -260,3 +268,42 @@ async fn rotate_upgrades_secrets_written_before_ciphertexts_were_versioned() { .unwrap(); assert_eq!(audited, 1, "a rotation is audited under its own action"); } + +/// A secret written under a key the configuration no longer holds is found +/// before the service starts serving (the service then refuses to start). +#[tokio::test] +async fn secrets_under_a_removed_key_are_detected() { + let app = TestApp::builder() + .config(|c| c.crypto.encryption_key = KEY_A.into()) + .spawn() + .await; + let user = fixtures::authenticated_user(&app, 900).await; + let setup = app + .post_auth( + "/users/me/two-factor/totp/setup", + &user.access_token, + &serde_json::json!({}), + ) + .await; + assert_eq!(setup.status().as_u16(), 200); + assert_eq!( + auth_api::services::key_rotation::secrets_under_unknown_keys(&app.state) + .await + .unwrap(), + 0 + ); + + // The key changes and the previous one is dropped too early. + let mut config = (*app.state.config).clone(); + config.crypto.encryption_key = KEY_B.into(); + config.crypto.previous_encryption_key = None; + let state = AppState::from_config_with_pool(config, app.db.clone()) + .await + .unwrap(); + assert_eq!( + auth_api::services::key_rotation::secrets_under_unknown_keys(&state) + .await + .unwrap(), + 1 + ); +} diff --git a/tests/security/regressions/second_factor.rs b/tests/security/regressions/second_factor.rs index 592dccb..3500d82 100644 --- a/tests/security/regressions/second_factor.rs +++ b/tests/security/regressions/second_factor.rs @@ -8,7 +8,6 @@ use deadpool_redis::redis::AsyncCommands; use serde_json::{Value, json}; -use sha2::{Digest, Sha256}; use crate::common::{ app::TestApp, @@ -245,7 +244,16 @@ async fn email_code_lookup_is_scoped_to_the_challenged_user() { let app = TestApp::spawn().await; let alice = fixtures::authenticated_user(&app, 605).await; let bob = fixtures::authenticated_user(&app, 606).await; - let known_hash = Sha256::digest(b"123456").to_vec(); + let known_hash = app + .state + .keyring + .otp_digest("email_2fa", alice.id.as_bytes(), "123456") + .to_vec(); + let bob_hash = app + .state + .keyring + .otp_digest("email_2fa", bob.id.as_bytes(), "123456") + .to_vec(); // Enable email 2FA for Alice with a code we control. let res = app @@ -280,7 +288,7 @@ async fn email_code_lookup_is_scoped_to_the_challenged_user() { VALUES ($1, $2, NOW() - INTERVAL '1 minute', NOW() + INTERVAL '5 minutes')", ) .bind(bob.id) - .bind(&known_hash) + .bind(&bob_hash) .execute(&app.db) .await .unwrap(); From e8a7f4676c17588a9536e19d122beea67d582ac8 Mon Sep 17 00:00:00 2001 From: SIIR3X <125802911+SIIR3X@users.noreply.github.com> Date: Sun, 20 Sep 2026 03:35:16 +0200 Subject: [PATCH 10/55] fix(config): bound settings that weaken controls and rehash weaker passwords on sign-in --- CHANGELOG.md | 2 + docs/deploy/guides/prometheus-alerts.yml | 17 +++++ docs/dev/guides/configuration.md | 12 ++- docs/dev/security-model.md | 9 ++- src/config/tests.rs | 73 +++++++++++++++++- src/config/validate.rs | 97 +++++++++++++++++++++++- src/repositories/two_factor.rs | 11 +-- src/repositories/user.rs | 18 +++++ src/services/auth/login.rs | 28 ++++++- src/utils/password.rs | 54 +++++++++++++ tests/integration/api/admin/webhooks.rs | 14 +++- tests/integration/api/auth/login.rs | 41 ++++++++++ tests/security/headers.rs | 2 + 13 files changed, 361 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4292c86..ff94756 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,8 @@ as described in the [versioning policy](docs/dev/guides/versioning.md). section 2.6): create `auth_api_owner`, `REASSIGN OWNED BY auth_api`, run `deploy/db/auth-api-grants.sql`, then run migrations with the owner's URL (`prod/auth-api/database-owner-url`). A single-role deployment keeps working. +- Check the settings refused at start-up (listed under Security) against your + environment before upgrading. - Run `auth-api --rotate-totp-keys` once, without `PREVIOUS_ENCRYPTION_KEY`, to bind the secrets written before the upgrade to their rows. - Copy the new `log_parameter_max_length` lines of 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/dev/guides/configuration.md b/docs/dev/guides/configuration.md index 86fd088..dae17bd 100644 --- a/docs/dev/guides/configuration.md +++ b/docs/dev/guides/configuration.md @@ -249,4 +249,14 @@ 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`. + +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`. diff --git a/docs/dev/security-model.md b/docs/dev/security-model.md index fb5b6e0..6ef09ea 100644 --- a/docs/dev/security-model.md +++ b/docs/dev/security-model.md @@ -22,9 +22,11 @@ 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. - **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 @@ -335,3 +337,4 @@ when a cited test no longer exists. | 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` | diff --git a/src/config/tests.rs b/src/config/tests.rs index 9a16993..c581811 100644 --- a/src/config/tests.rs +++ b/src/config/tests.rs @@ -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(), @@ -1089,3 +1089,72 @@ 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:?}" + ); + } +} diff --git a/src/config/validate.rs b/src/config/validate.rs index 79cdc7e..579209a 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())?; @@ -64,6 +65,7 @@ impl Config { }); } + 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)?; @@ -373,10 +375,65 @@ 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(()) +} + +/// 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(()) +} + pub(super) fn validate_security(security: &SecurityConfig) -> Result<(), ConfigError> { // 0 would not disable the lockout: every wrong password would lock the account. if security.lockout_threshold == 0 { @@ -386,10 +443,12 @@ pub(super) fn validate_security(security: &SecurityConfig) -> Result<(), ConfigE }); } - 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}"), }); } @@ -421,7 +480,17 @@ pub(super) fn validate_device_auth(device: &DeviceAuthConfig) -> Result<(), Conf Ok(()) } -/// A zero absolute lifetime would refuse every refresh. +/// 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 +498,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> { diff --git a/src/repositories/two_factor.rs b/src/repositories/two_factor.rs index d3ae149..02b3210 100644 --- a/src/repositories/two_factor.rs +++ b/src/repositories/two_factor.rs @@ -200,11 +200,12 @@ 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. -/// `(method id, account id, ciphertext)` of every TOTP secret. -pub async fn find_all_totp_secrets( - pool: &PgPool, -) -> Result, sqlx::Error> { - let rows: Vec<(Uuid, Uuid, String)> = sqlx::query_as( +/// 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", diff --git a/src/repositories/user.rs b/src/repositories/user.rs index f9d1747..6e5aecb 100644 --- a/src/repositories/user.rs +++ b/src/repositories/user.rs @@ -37,6 +37,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, diff --git a/src/services/auth/login.rs b/src/services/auth/login.rs index 7a50e8f..584bf51 100644 --- a/src/services/auth/login.rs +++ b/src/services/auth/login.rs @@ -186,7 +186,10 @@ pub async fn login( apply_backoff(failures + 1).await; return Err(AppError::InvalidCredentials); } - (Some(u), true) => u, + (Some(u), true) => { + rehash_if_weaker(state, &u, password_plaintext); + u + } }; // Account status checks @@ -206,6 +209,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)] diff --git a/src/utils/password.rs b/src/utils/password.rs index ea6ac12..efa0990 100644 --- a/src/utils/password.rs +++ b/src/utils/password.rs @@ -75,6 +75,27 @@ 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, + } +} + /// 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`). @@ -194,6 +215,39 @@ 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))); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/tests/integration/api/admin/webhooks.rs b/tests/integration/api/admin/webhooks.rs index b6d883b..284506e 100644 --- a/tests/integration/api/admin/webhooks.rs +++ b/tests/integration/api/admin/webhooks.rs @@ -232,11 +232,21 @@ async fn internal_addresses_are_never_called() { fixtures::register_user(&app, 2).await; webhooks::deliver_once(&app.state).await.unwrap(); - let deliveries = delivery_state(&app, &admin.token, created["id"].as_str().unwrap()).await; + // The background dispatcher may have claimed the delivery first: wait for + // the attempt to be recorded, whoever made it. + let id = created["id"].as_str().unwrap(); + let mut deliveries = delivery_state(&app, &admin.token, id).await; + for _ in 0..50 { + if deliveries[0]["last_error"].is_string() { + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + deliveries = delivery_state(&app, &admin.token, id).await; + } assert!( deliveries[0]["last_error"] .as_str() - .unwrap() + .unwrap_or_default() .contains("blocked address"), "{deliveries}" ); diff --git a/tests/integration/api/auth/login.rs b/tests/integration/api/auth/login.rs index cf784f4..20870c7 100644 --- a/tests/integration/api/auth/login.rs +++ b/tests/integration/api/auth/login.rs @@ -331,3 +331,44 @@ async fn logout_invalidates_token() { res2.status() ); } + +/// Raising `ARGON2_*` protects existing accounts as they sign in: a stored +/// hash weaker than the configuration is replaced after a successful sign-in. +#[tokio::test] +async fn a_weaker_password_hash_is_replaced_after_sign_in() { + let app = TestApp::spawn().await; + let user = fixtures::register_user(&app, 950).await; + fixtures::activate_user(&app.db, user.id).await; + let mut weak = app.state.config.crypto.clone(); + weak.argon2_memory_kib = 1024; + let weak_hash = auth_api::utils::password::hash(&user.password, &weak).unwrap(); + sqlx::query("UPDATE users SET password_hash = $2 WHERE id = $1") + .bind(user.id) + .bind(&weak_hash) + .execute(&app.db) + .await + .unwrap(); + + let response = app + .post( + "/auth/login", + &serde_json::json!({ "identifier": user.email, "password": user.password }), + ) + .await; + assert_eq!(response.status().as_u16(), 200); + + let configured = format!("m={}", app.state.config.crypto.argon2_memory_kib); + for _ in 0..50 { + let stored: String = sqlx::query_scalar("SELECT password_hash FROM users WHERE id = $1") + .bind(user.id) + .fetch_one(&app.db) + .await + .unwrap(); + if stored.contains(&configured) { + assert!(auth_api::utils::password::verify(&user.password, &stored).unwrap()); + return; + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + panic!("the weaker hash was not replaced"); +} diff --git a/tests/security/headers.rs b/tests/security/headers.rs index 6fa7bc1..475c982 100644 --- a/tests/security/headers.rs +++ b/tests/security/headers.rs @@ -212,6 +212,8 @@ async fn security_headers_enable_hsts_for_https_production() { config.external_login_uri = "https://app.example.com/external-login".into(); config.webauthn.origins = vec!["https://app.example.com".into()]; config.webhooks.allow_private_networks = false; + config.crypto.argon2_memory_kib = 19_456; + config.crypto.argon2_iterations = 2; // The committed development AES key is refused in production. config.crypto.encryption_key = "6M+xtK7VzYMoz/3mc3vJf2e6h9b9yLyx3Eabo/236YE=".into(); }) From 48be030945758d41a32f73c5401ce35f9d554979 Mon Sep 17 00:00:00 2001 From: SIIR3X <125802911+SIIR3X@users.noreply.github.com> Date: Sun, 20 Sep 2026 07:47:22 +0200 Subject: [PATCH 11/55] fix(auth): bound password guesses per session and move client endpoints to per-client budgets --- docs/dev/api/routes.md | 35 ++++---- docs/dev/security-model.md | 13 ++- src/handlers/mod.rs | 76 +++++++++++------- src/handlers/oauth.rs | 4 +- src/handlers/two_factor.rs | 9 ++- src/openapi.rs | 2 +- src/services/oauth.rs | 79 ++++++++++++++++++- src/services/reauth.rs | 2 +- src/services/two_factor.rs | 14 +++- src/services/user.rs | 65 ++++++++++----- tests/security/rate_limits.rs | 77 ++++++++++++++++++ tests/security/regressions/second_factor.rs | 12 ++- .../security/regressions/session_hardening.rs | 79 +++++++++++++++++++ 13 files changed, 384 insertions(+), 83 deletions(-) diff --git a/docs/dev/api/routes.md b/docs/dev/api/routes.md index 4a7e23b..35d157e 100644 --- a/docs/dev/api/routes.md +++ b/docs/dev/api/routes.md @@ -17,9 +17,14 @@ the overview. |------------|---------| | 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`. @@ -88,10 +93,10 @@ 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 (account) (+ reauth for non-primary clients) | Strict | @@ -186,10 +191,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 @@ -224,8 +229,8 @@ every other session and notifies the previous address. | 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 | ## External identities @@ -238,7 +243,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. @@ -267,7 +272,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 | @@ -290,14 +295,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 | +| DELETE | `/users/me/two-factor/email/{id}` | JWT + reauth | Strict | +| POST | `/users/me/two-factor/recovery-codes` | JWT + reauth | Strict | | POST | `/users/me/two-factor/recovery-codes/use` | JWT | General | `GET /users/me/two-factor` lists the configured methods (with the ids the other diff --git a/docs/dev/security-model.md b/docs/dev/security-model.md index 6ef09ea..35bd429 100644 --- a/docs/dev/security-model.md +++ b/docs/dev/security-model.md @@ -85,6 +85,11 @@ the database together, is out of scope. `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 @@ -97,8 +102,9 @@ 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 @@ -298,7 +304,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`, `recovery_code_guesses_through_the_account_leave_the_sign_in_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` | @@ -338,3 +344,4 @@ when a cited test no longer exists. | 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, the authenticated recovery route has its own budget, 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`, `the_authenticated_recovery_route_spends_its_own_budget`, `client_endpoints_are_bounded_per_client_and_per_wrong_secret` | diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 642865f..f5b7cae 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -256,7 +256,7 @@ 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, )); @@ -271,10 +271,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) @@ -454,11 +459,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 +474,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 +500,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 +535,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 +546,26 @@ 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,10 +574,6 @@ 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)] diff --git a/src/handlers/oauth.rs b/src/handlers/oauth.rs index 82be957..2ea3236 100644 --- a/src/handlers/oauth.rs +++ b/src/handlers/oauth.rs @@ -532,11 +532,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()) } diff --git a/src/handlers/two_factor.rs b/src/handlers/two_factor.rs index 05baf5d..bfa2f04 100644 --- a/src/handlers/two_factor.rs +++ b/src/handlers/two_factor.rs @@ -226,7 +226,14 @@ pub async fn use_recovery_code( auth: FirstPartyUser, Json(body): Json, ) -> Result { - tf_svc::use_recovery_code(&state, auth.user_id, &body.code, auth.request_id).await?; + tf_svc::use_recovery_code( + &state, + auth.user_id, + auth.session_id, + &body.code, + auth.request_id, + ) + .await?; Ok(StatusCode::NO_CONTENT) } diff --git a/src/openapi.rs b/src/openapi.rs index 5fc1758..49bf435 100644 --- a/src/openapi.rs +++ b/src/openapi.rs @@ -321,7 +321,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 { diff --git a/src/services/oauth.rs b/src/services/oauth.rs index f1ebfb1..b8a11de 100644 --- a/src/services/oauth.rs +++ b/src/services/oauth.rs @@ -109,13 +109,83 @@ 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; + +fn client_failure_key(ip: IpNetwork) -> String { + format!( + "oauth_client_fail:{}", + crate::middleware::rate_limit::ip_bucket(ip.ip()) + ) +} + /// 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 { + if let Some(ip) = ip + && crate::utils::redis_counter::peek(&state.redis, &client_failure_key(ip)) + .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(ip) = ip { + let key = client_failure_key(ip); + let _ = crate::utils::redis_counter::consume( + &state.redis, + &[crate::utils::redis_counter::Budget { + key: &key, + limit: MAX_CLIENT_AUTH_FAILURES_BY_IP, + window_secs: CLIENT_AUTH_FAILURE_WINDOW_SECS, + }], + ) + .await; + } + } + Ok(client) => { + let key = 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| { @@ -499,7 +569,7 @@ pub async fn token( ) .into()); } - let client = authenticate_client(state, authorization, parameters).await?; + let client = authenticate_client(state, authorization, parameters, ip).await?; let required = |name: &'static str| { param(name).ok_or_else(|| { EndpointError::OAuth(OAuthError::new( @@ -685,7 +755,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))?; @@ -742,8 +812,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, @@ -862,7 +933,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| { diff --git a/src/services/reauth.rs b/src/services/reauth.rs index dfa16f2..4818203 100644 --- a/src/services/reauth.rs +++ b/src/services/reauth.rs @@ -63,7 +63,7 @@ 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?; mark_recent_reauth(state, session_id).await; record_reauth_event(state, user_id, ip, request_id, reason).await } diff --git a/src/services/two_factor.rs b/src/services/two_factor.rs index cb0e118..d3509db 100644 --- a/src/services/two_factor.rs +++ b/src/services/two_factor.rs @@ -39,6 +39,9 @@ const MAX_TOTP_SETUP_FAILURES: i64 = 5; const TOTP_SETUP_FAILURE_WINDOW_SECS: u64 = 900; /// Redis key prefix for the TOTP setup failure budget. const TOTP_SETUP_FAIL_PREFIX: &str = "totp_setup_fail:"; +/// Wrong recovery codes one session may submit to the authenticated route per +/// day. +const MAX_AUTHENTICATED_RECOVERY_FAILURES: i64 = 5; /// Minimum delay between two recovery code regenerations (24 hours). const RC_REGEN_COOLDOWN_SECS: u64 = 86_400; @@ -355,18 +358,21 @@ 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); + // A budget of its own, per session: sharing the sign-in challenge's would + // let a stolen access token exhaust it and keep the owner from signing in + // with a recovery code for a day. Recovery codes carry 80 bits: the budget + // bounds volume. + let fail_key = format!("rc_route_fail:{user_id}:{session_id}"); let attempt = redis_counter::consume( &state.redis, &[Budget { key: &fail_key, - limit: super::auth::MAX_RECOVERY_FAILURES_BY_USER, + limit: MAX_AUTHENTICATED_RECOVERY_FAILURES, window_secs: super::auth::RECOVERY_FAILURE_USER_WINDOW_SECS, }], ) diff --git a/src/services/user.rs b/src/services/user.rs index ea6cdf2..77264c5 100644 --- a/src/services/user.rs +++ b/src/services/user.rs @@ -21,30 +21,45 @@ use crate::{ 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,26 @@ 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( &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 +99,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(()) } diff --git a/tests/security/rate_limits.rs b/tests/security/rate_limits.rs index 42eb6b1..1e13e61 100644 --- a/tests/security/rate_limits.rs +++ b/tests/security/rate_limits.rs @@ -205,3 +205,80 @@ async fn a_refused_request_consumes_nothing() { assert_ne!(me().await, 429); assert_eq!(me().await, 429); } + +/// Every route accepting `current_password` guesses the password like +/// `/users/me/reauth` does: it shares the strict bucket. +#[tokio::test] +async fn routes_taking_the_current_password_count_against_the_strict_bucket() { + // Registering, signing in and re-authenticating take the three first. + let app = TestApp::spawn_with_config(|config| { + config.rate_limit.auth_requests_per_minute = 3; + }) + .await; + let user = crate::common::fixtures::authenticated_user(&app, 0).await; + app.clear_auth_rate_limit_key(&app.client_ip).await; + + let body = serde_json::json!({ + "current_password": "Not-The-Password-1!", + "new_password": "Brand-New-Pass-2!", + }); + for _ in 0..3 { + let res = app + .patch_auth("/users/me/password", &user.access_token, &body) + .await; + assert_ne!(res.status().as_u16(), 429); + } + let res = app + .patch_auth("/users/me/password", &user.access_token, &body) + .await; + assert_eq!(res.status().as_u16(), 429); +} + +/// A resource server introspecting from one address is bounded per client, +/// not by the strict per-address bucket; wrong client secrets still are. +#[tokio::test] +async fn client_endpoints_are_bounded_per_client_and_per_wrong_secret() { + let app = TestApp::spawn_with_config(|config| { + config.rate_limit.auth_requests_per_minute = 1; + }) + .await; + let secret = "aacs_resource-server-secret"; + sqlx::query( + "INSERT INTO registered_clients (client_id, display_name, client_secret_hash) + VALUES ('resource-server', 'Resource server', $1)", + ) + .bind(auth_api::utils::crypto::sha256(secret.as_bytes()).to_vec()) + .execute(&app.db) + .await + .unwrap(); + + let introspect = |secret: &'static str| { + let app = &app; + async move { + app.client + .post(app.url("/oauth/introspect")) + .basic_auth("resource-server", Some(secret)) + .form(&[("token", "not-a-token")]) + .send() + .await + .unwrap() + .status() + .as_u16() + } + }; + for _ in 0..5 { + assert_eq!( + introspect(secret).await, + 200, + "introspection is not strict-limited" + ); + } + for _ in 0..20 { + assert_eq!(introspect("aacs_wrong").await, 401); + } + assert_eq!( + introspect("aacs_wrong").await, + 429, + "wrong secrets are bounded" + ); +} diff --git a/tests/security/regressions/second_factor.rs b/tests/security/regressions/second_factor.rs index 3500d82..4c8e635 100644 --- a/tests/security/regressions/second_factor.rs +++ b/tests/security/regressions/second_factor.rs @@ -458,13 +458,16 @@ async fn confirming_a_new_method_has_an_attempt_budget() { ); } +/// Guesses through the authenticated route spend a budget of their own: a +/// stolen access token cannot keep the owner from signing in with a recovery +/// code (each code carries 80 bits; the budgets bound volume). #[tokio::test] -async fn recovery_code_guesses_share_one_budget_across_routes() { +async fn recovery_code_guesses_through_the_account_leave_the_sign_in_budget() { let app = TestApp::spawn().await; let user = fixtures::authenticated_user(&app, 612).await; let (_, recovery_codes) = enable_totp(&app, &user).await; - for attempt in 1..=10 { + for attempt in 1..=6 { let res = app .post_auth( "/users/me/two-factor/recovery-codes/use", @@ -472,7 +475,8 @@ async fn recovery_code_guesses_share_one_budget_across_routes() { &json!({ "code": "XXXX-XXXX-XXXX-XXXX" }), ) .await; - assert_eq!(res.status().as_u16(), 401, "attempt {attempt}"); + let expected = if attempt <= 5 { 401 } else { 429 }; + assert_eq!(res.status().as_u16(), expected, "attempt {attempt}"); } let challenge = login_challenge(&app, &user).await; @@ -485,7 +489,7 @@ async fn recovery_code_guesses_share_one_budget_across_routes() { }), ) .await; - assert_eq!(res.status().as_u16(), 429); + assert_eq!(res.status().as_u16(), 200); } #[tokio::test] diff --git a/tests/security/regressions/session_hardening.rs b/tests/security/regressions/session_hardening.rs index 1108e2a..7bbab4c 100644 --- a/tests/security/regressions/session_hardening.rs +++ b/tests/security/regressions/session_hardening.rs @@ -242,3 +242,82 @@ async fn a_deleted_account_leaves_no_address_or_sign_in_attempt_behind() { "audit entries of the account keep its addresses" ); } + +/// A stolen session guessing the password locks itself out, not the owner: +/// the owner's own session still re-authenticates and revokes it. +#[tokio::test] +async fn a_stolen_session_guessing_the_password_does_not_lock_the_owner_out() { + let app = TestApp::spawn().await; + let owner = fixtures::authenticated_user(&app, 700).await; + let stolen: Value = app + .post( + "/auth/login", + &json!({ "identifier": owner.email, "password": owner.password }), + ) + .await + .json() + .await + .unwrap(); + let stolen_token = stolen["access_token"].as_str().unwrap(); + let stolen_sid = app.decode_access_token(stolen_token).sid; + + // The test configuration locks after 3 failures. + for _ in 0..3 { + app.post_auth( + "/users/me/reauth", + stolen_token, + &json!({ "current_password": "Guess-Guess-1!" }), + ) + .await; + } + let res = app + .post_auth( + "/users/me/reauth", + stolen_token, + &json!({ "current_password": owner.password }), + ) + .await; + assert_eq!(res.status().as_u16(), 403, "the stolen session is locked"); + + let res = app + .delete_auth_json( + &format!("/users/me/sessions/{stolen_sid}"), + &owner.access_token, + &json!({ "current_password": owner.password }), + ) + .await; + assert_eq!(res.status().as_u16(), 204, "the owner still acts"); + assert_eq!(app.get_auth("/users/me", stolen_token).await.status(), 401); +} + +/// Wrong recovery codes sent through the authenticated route spend a budget of +/// their own: a stolen token cannot exhaust the sign-in budget of the owner. +#[tokio::test] +async fn the_authenticated_recovery_route_spends_its_own_budget() { + use deadpool_redis::redis::AsyncCommands; + + let app = TestApp::spawn().await; + let user = fixtures::authenticated_user(&app, 701).await; + for _ in 0..5 { + let res = app + .post_auth( + "/users/me/two-factor/recovery-codes/use", + &user.access_token, + &json!({ "code": "AAAA-BBBB-CCCC-DDDD-EEEE" }), + ) + .await; + assert_eq!(res.status().as_u16(), 401); + } + let res = app + .post_auth( + "/users/me/two-factor/recovery-codes/use", + &user.access_token, + &json!({ "code": "AAAA-BBBB-CCCC-DDDD-EEEE" }), + ) + .await; + assert_eq!(res.status().as_u16(), 429); + + let mut conn = app.redis.get().await.unwrap(); + let sign_in_budget: Option = conn.get(format!("rc_user_fail:{}", user.id)).await.unwrap(); + assert_eq!(sign_in_budget, None, "the sign-in budget is untouched"); +} From 6296833fcd41d431be81d3c5b4022c841edbc1d1 Mon Sep 17 00:00:00 2001 From: SIIR3X <125802911+SIIR3X@users.noreply.github.com> Date: Sun, 20 Sep 2026 11:59:28 +0200 Subject: [PATCH 12/55] fix(session): make revocations take effect at once and consume pre-auth tokens atomically --- docs/dev/api/routes.md | 6 +- docs/dev/security-model.md | 16 +++-- src/middleware/security_headers.rs | 8 +++ src/repositories/session.rs | 11 ++++ src/services/auth/mod.rs | 5 +- src/services/auth/second_factor.rs | 66 ++++++++++++------- src/services/auth/session.rs | 66 ++++++++++++------- src/services/auth/tokens.rs | 63 +++++++++++------- src/services/authorize.rs | 2 +- src/services/session.rs | 2 +- tests/security/headers.rs | 20 ++++++ .../security/regressions/session_hardening.rs | 46 +++++++++++++ 12 files changed, 233 insertions(+), 78 deletions(-) diff --git a/docs/dev/api/routes.md b/docs/dev/api/routes.md index 35d157e..3da2988 100644 --- a/docs/dev/api/routes.md +++ b/docs/dev/api/routes.md @@ -68,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. @@ -232,6 +232,10 @@ every other session and notifies the previous address. | 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 | Method | Route | Auth | Rate limit | diff --git a/docs/dev/security-model.md b/docs/dev/security-model.md index 35bd429..e1413a8 100644 --- a/docs/dev/security-model.md +++ b/docs/dev/security-model.md @@ -67,11 +67,16 @@ 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 it is treated as a concurrent refresh from the same client (two + tabs), refused without revocation and counted + (`auth_refresh_concurrent_total`). 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 @@ -272,7 +277,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 @@ -345,3 +350,4 @@ when a cited test no longer exists. | 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, the authenticated recovery route has its own budget, 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`, `the_authenticated_recovery_route_spends_its_own_budget`, `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` | 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/repositories/session.rs b/src/repositories/session.rs index 2b73023..29a0bc7 100644 --- a/src/repositories/session.rs +++ b/src/repositories/session.rs @@ -233,6 +233,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], diff --git a/src/services/auth/mod.rs b/src/services/auth/mod.rs index aaac9ef..7dc07c2 100644 --- a/src/services/auth/mod.rs +++ b/src/services/auth/mod.rs @@ -113,8 +113,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; diff --git a/src/services/auth/second_factor.rs b/src/services/auth/second_factor.rs index 70ba23b..e55ba3c 100644 --- a/src/services/auth/second_factor.rs +++ b/src/services/auth/second_factor.rs @@ -115,14 +115,9 @@ pub async fn complete_two_factor_login( redis_counter::reset(&state.redis, &[&user_fail_key]).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, @@ -186,12 +181,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, @@ -296,14 +286,14 @@ 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, &user_fail_key], + ) + .await?; let tokens = issue_tokens( state, @@ -346,3 +336,35 @@ 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(|e| AppError::Internal(e.into()))?; + let removed: i64 = conn + .del(redis_key) + .await + .map_err(|e| AppError::Internal(e.into()))?; + 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), pre_auth_token) + .await; + Ok(()) +} diff --git a/src/services/auth/session.rs b/src/services/auth/session.rs index cb6cd61..9daefc1 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,17 @@ 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 => { + // Two tabs, or a retried request - or a thief refreshing first, + // which leaves the owner signed out: measured, so a spike shows. + 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::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 +99,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, @@ -173,9 +170,7 @@ pub async fn refresh_token( { 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); @@ -212,6 +207,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, @@ -232,7 +250,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; diff --git a/src/services/auth/tokens.rs b/src/services/auth/tokens.rs index 59d3121..bafd20d 100644 --- a/src/services/auth/tokens.rs +++ b/src/services/auth/tokens.rs @@ -357,12 +357,15 @@ pub async fn verify_token_state( .ok_or(AppError::Unauthorized)?; let active = session.is_active(state.clock.now()); let first_party = session.first_party(); - let _: Result<(), _> = conn - .set_ex( - &cache_key, - crate::domain::session::cached_value(active, first_party), - SESSION_CACHE_TTL_SECS, - ) + // 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, first_party) } @@ -375,17 +378,14 @@ pub async fn verify_token_state( } } -/// 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]) { @@ -394,10 +394,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 4c9fc6b..08bbd94 100644 --- a/src/services/authorize.rs +++ b/src/services/authorize.rs @@ -246,7 +246,7 @@ async fn revoke_on_replay(state: &AppState, code_hash: &[u8]) { } 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"); } diff --git a/src/services/session.rs b/src/services/session.rs index 5ba68c3..e5f74f7 100644 --- a/src/services/session.rs +++ b/src/services/session.rs @@ -62,7 +62,7 @@ pub async fn revoke( // 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); + auth_svc::invalidate_session_cache(state, session.id).await; reauth_svc::clear_recent_reauth(state, session.id).await; audit::append( diff --git a/tests/security/headers.rs b/tests/security/headers.rs index 475c982..c0eb5a7 100644 --- a/tests/security/headers.rs +++ b/tests/security/headers.rs @@ -234,3 +234,23 @@ async fn security_headers_enable_hsts_for_https_production() { Some("max-age=63072000; includeSubDomains") ); } + +/// RFC 6749 section 5.1: token responses carry `Cache-Control: no-store` and +/// `Pragma: no-cache`; cacheable public documents carry neither. +#[tokio::test] +async fn token_responses_forbid_every_cache() { + let app = TestApp::spawn().await; + let user = crate::common::fixtures::register_user(&app, 1).await; + crate::common::fixtures::activate_user(&app.db, user.id).await; + let response = app + .post( + "/auth/login", + &serde_json::json!({ "identifier": user.email, "password": user.password }), + ) + .await; + assert_eq!(response.headers()["cache-control"], "no-store"); + assert_eq!(response.headers()["pragma"], "no-cache"); + + let jwks = app.get("/.well-known/jwks.json").await; + assert!(jwks.headers().get("pragma").is_none()); +} diff --git a/tests/security/regressions/session_hardening.rs b/tests/security/regressions/session_hardening.rs index 7bbab4c..5f0dbe2 100644 --- a/tests/security/regressions/session_hardening.rs +++ b/tests/security/regressions/session_hardening.rs @@ -321,3 +321,49 @@ async fn the_authenticated_recovery_route_spends_its_own_budget() { let sign_in_budget: Option = conn.get(format!("rc_user_fail:{}", user.id)).await.unwrap(); assert_eq!(sign_in_budget, None, "the sign-in budget is untouched"); } + +/// A replayed refresh token revokes its family, and the access tokens of that +/// family stop working at once, not when the validity cache expires. +#[tokio::test] +async fn a_revoked_family_loses_its_cached_validity_at_once() { + let app = TestApp::spawn().await; + let user = fixtures::authenticated_user(&app, 720).await; + + let rotated: Value = refresh(&app, &user.refresh_token) + .await + .json() + .await + .unwrap(); + let access = rotated["access_token"].as_str().unwrap(); + // Cached as active by this request. + assert_eq!(app.get_auth("/users/me", access).await.status(), 200); + + // Past the grace window, the first token comes back: a replay. + app.clock.advance(time::Duration::seconds(5)); + assert_eq!(refresh(&app, &user.refresh_token).await.status(), 401); + assert_eq!( + app.get_auth("/users/me", access).await.status(), + 401, + "the family's access token still worked from the cache" + ); +} + +/// Replayed and foreign refresh tokens count against the address, like +/// unknown ones. +#[tokio::test] +async fn replayed_refresh_tokens_count_against_the_address() { + use deadpool_redis::redis::AsyncCommands; + + let app = TestApp::spawn().await; + let user = fixtures::authenticated_user(&app, 721).await; + refresh(&app, &user.refresh_token).await; + app.clock.advance(time::Duration::seconds(5)); + refresh(&app, &user.refresh_token).await; + + let mut conn = app.redis.get().await.unwrap(); + let failures: Option = conn + .get(format!("refresh_fail:{}", app.client_ip)) + .await + .unwrap(); + assert_eq!(failures, Some(1)); +} From 78f6ad9bbb627b474d087db2f30eca192591ae8e Mon Sep 17 00:00:00 2001 From: SIIR3X <125802911+SIIR3X@users.noreply.github.com> Date: Sun, 20 Sep 2026 16:11:35 +0200 Subject: [PATCH 13/55] fix(oauth): hold authorization codes until linked and claim cooldowns and quotas atomically --- docs/dev/security-model.md | 5 +- src/repositories/authorization_code.rs | 14 ++-- src/services/auth/guards.rs | 11 +++- src/services/authorize.rs | 64 ++++++++++++++----- src/services/email_2fa.rs | 16 ++--- src/services/personal_access_token.rs | 6 ++ src/services/two_factor.rs | 27 ++++---- src/utils/redis_counter.rs | 20 ++++++ tests/integration/api/account/tokens.rs | 29 +++++++++ .../api/clients/authorization_code.rs | 24 +++++++ tests/security/regressions/second_factor.rs | 32 ++++++++++ 11 files changed, 196 insertions(+), 52 deletions(-) diff --git a/docs/dev/security-model.md b/docs/dev/security-model.md index e1413a8..e4c257e 100644 --- a/docs/dev/security-model.md +++ b/docs/dev/security-model.md @@ -174,7 +174,9 @@ the database together, is out of scope. own application requires a re-authentication, like consenting to it. - **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. The redemption + holds the code until its session is linked, so even a replay racing it finds + the session to revoke. - **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. @@ -351,3 +353,4 @@ when a cited test no longer exists. | 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, the authenticated recovery route has its own budget, 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`, `the_authenticated_recovery_route_spends_its_own_budget`, `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` | diff --git a/src/repositories/authorization_code.rs b/src/repositories/authorization_code.rs index 3d5980c..cb264d8 100644 --- a/src/repositories/authorization_code.rs +++ b/src/repositories/authorization_code.rs @@ -55,8 +55,8 @@ 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 +66,7 @@ pub async fn consume( RETURNING {COLUMNS}" )) .bind(code_hash) - .fetch_optional(pool) + .fetch_optional(executor) .await } @@ -84,11 +84,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/services/auth/guards.rs b/src/services/auth/guards.rs index 2de8e91..dda1f8d 100644 --- a/src/services/auth/guards.rs +++ b/src/services/auth/guards.rs @@ -85,8 +85,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"); diff --git a/src/services/authorize.rs b/src/services/authorize.rs index 08bbd94..cae6bbb 100644 --- a/src/services/authorize.rs +++ b/src/services/authorize.rs @@ -178,6 +178,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,10 +190,16 @@ 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 { + drop(tx); revoke_on_replay(state, &hash).await; return Err(AppError::InvalidAuthorizationCode); }; @@ -197,17 +208,29 @@ 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?; + 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( @@ -224,13 +247,12 @@ pub async fn redeem( ) .await?; - lock.commit() + code_repo::attach_session(&mut *tx, entry.id, tokens.session.id) + .await + .map_err(|e| AppError::Internal(e.into()))?; + 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)) } @@ -361,12 +383,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. diff --git a/src/services/email_2fa.rs b/src/services/email_2fa.rs index 9053399..627c39e 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; @@ -172,13 +171,11 @@ pub async fn disable( /// 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 + // Anti-spam cooldown, claimed before the send: concurrent requests cannot + // all find it free and all send. 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 { - return Err(AppError::RateLimitExceeded); - } + if !redis_counter::claim_cooldown(&state.redis, &cooldown_key, SEND_COOLDOWN_SECS).await { + return Err(AppError::RateLimitExceeded); } let user = user_repo::find_by_id(&state.db, user_id) @@ -203,11 +200,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(); diff --git a/src/services/personal_access_token.rs b/src/services/personal_access_token.rs index ca643c4..e59d419 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")); } diff --git a/src/services/two_factor.rs b/src/services/two_factor.rs index d3509db..b4683cf 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, @@ -296,24 +294,21 @@ 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(&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. diff --git a/src/utils/redis_counter.rs b/src/utils/redis_counter.rs index fefb02d..24ce002 100644 --- a/src/utils/redis_counter.rs +++ b/src/utils/redis_counter.rs @@ -129,6 +129,26 @@ pub async fn peek(redis: &RedisPool, key: &str) -> Result { /// Clear budgets after a success. Best-effort: a stale counter only delays the /// user until its window expires, it never lets an attacker through. +/// Claim a cooldown: `true` when `key` was free and is now held for +/// `ttl_secs`, `false` when a previous claim still holds it. One `SET NX EX`, +/// before the guarded action: concurrent requests cannot all see the key free +/// and all act. Fails open (a Redis outage claims nothing and allows the +/// action), like the volume budgets. +pub async fn claim_cooldown(redis: &RedisPool, key: &str, ttl_secs: u64) -> bool { + let Ok(mut conn) = redis.get().await else { + return true; + }; + let claimed: Result, _> = deadpool_redis::redis::cmd("SET") + .arg(key) + .arg(1) + .arg("NX") + .arg("EX") + .arg(ttl_secs) + .query_async(&mut *conn) + .await; + claimed.map(|reply| reply.is_some()).unwrap_or(true) +} + pub async fn reset(redis: &RedisPool, keys: &[&str]) { use deadpool_redis::redis::AsyncCommands; diff --git a/tests/integration/api/account/tokens.rs b/tests/integration/api/account/tokens.rs index ac6d6b2..92c0ef2 100644 --- a/tests/integration/api/account/tokens.rs +++ b/tests/integration/api/account/tokens.rs @@ -203,3 +203,32 @@ async fn creation_is_checked() { assert_eq!(exchange(&app, malformed).await.0, 401, "{malformed}"); } } + +/// Concurrent creations cannot pass the per-account limit together. +#[tokio::test] +async fn concurrent_creations_respect_the_token_limit() { + let app = TestApp::spawn().await; + let user = account(&app, 30).await; + let limit = auth_api::domain::personal_access_token::MAX_ACTIVE_PER_ACCOUNT as usize; + let creations = (0..limit + 5).map(|i| { + let app = &app; + let user = &user; + async move { + create(app, user, json!({ "name": format!("parallel-{i}") })) + .await + .0 + } + }); + let statuses = futures::future::join_all(creations).await; + assert_eq!(statuses.iter().filter(|s| **s == 201).count(), limit); + let active: i64 = sqlx::query_scalar( + "SELECT count(*) FROM personal_access_tokens t + JOIN sessions s ON s.id = t.session_id + WHERE t.user_id = $1 AND s.revoked_at IS NULL", + ) + .bind(user.id) + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(active as usize, limit); +} diff --git a/tests/integration/api/clients/authorization_code.rs b/tests/integration/api/clients/authorization_code.rs index 186e587..b718ca3 100644 --- a/tests/integration/api/clients/authorization_code.rs +++ b/tests/integration/api/clients/authorization_code.rs @@ -274,6 +274,30 @@ async fn a_replayed_code_is_refused_and_revokes_its_session() { assert_eq!(status, 400, "the replayed code's session must be revoked"); } +/// A code replayed while its first redemption is still issuing the session +/// waits for it, then revokes that session: the replay can never slip in before +/// the code is linked to what it produced. +#[tokio::test] +async fn a_code_redeemed_twice_at_once_leaves_no_session_alive() { + let app = TestApp::spawn().await; + register_client(&app, PRIMARY, true, &[], 5).await; + let user = fixtures::authenticated_user(&app, 722).await; + let p = pkce(); + let code = code_for(&app, &user, PRIMARY, CALLBACK, &p).await; + + let (first, second) = tokio::join!( + redeem(&app, &code, &p.verifier, PRIMARY, CALLBACK), + redeem(&app, &code, &p.verifier, PRIMARY, CALLBACK), + ); + let mut statuses = [first.0, second.0]; + statuses.sort(); + assert_eq!(statuses, [200, 400], "exactly one redemption succeeds"); + + let tokens = if first.0 == 200 { first.1 } else { second.1 }; + let (status, _) = refresh(&app, tokens["refresh_token"].as_str().unwrap(), PRIMARY).await; + assert_eq!(status, 400, "the replay revoked the session it raced"); +} + #[tokio::test] async fn a_wrong_verifier_burns_the_code() { let app = TestApp::spawn().await; diff --git a/tests/security/regressions/second_factor.rs b/tests/security/regressions/second_factor.rs index 4c8e635..8354293 100644 --- a/tests/security/regressions/second_factor.rs +++ b/tests/security/regressions/second_factor.rs @@ -518,3 +518,35 @@ async fn a_second_factor_answers_an_inactive_account_like_the_password_sign_in() let body: Value = res.json().await.unwrap(); assert_eq!(body["code"], "account_inactive"); } + +/// Concurrent regenerations cannot each replace the codes the previous one +/// just showed: the cooldown is claimed before regenerating. +#[tokio::test] +async fn concurrent_recovery_code_regenerations_run_once() { + let app = TestApp::spawn().await; + let user = fixtures::authenticated_user(&app, 640).await; + enable_totp(&app, &user).await; + app.post_auth( + "/users/me/reauth", + &user.access_token, + &json!({ "current_password": user.password }), + ) + .await; + + let regenerate = || async { + app.post_auth( + "/users/me/two-factor/recovery-codes", + &user.access_token, + &json!({}), + ) + .await + .status() + .as_u16() + }; + let statuses = futures::future::join_all((0..5).map(|_| regenerate())).await; + assert_eq!( + statuses.iter().filter(|s| **s == 200).count(), + 1, + "{statuses:?}" + ); +} From c4805cfc8ee6f1f594c1eeb067253bc4717f9508 Mon Sep 17 00:00:00 2001 From: SIIR3X <125802911+SIIR3X@users.noreply.github.com> Date: Sun, 20 Sep 2026 20:23:41 +0200 Subject: [PATCH 14/55] fix(email): remove the email change oracle, budget notices and codes, and bind captcha tokens to the site --- crates/testkit/src/app.rs | 2 + crates/testkit/src/mailpit.rs | 6 +- docs/dev/api/openapi.yaml | 8 +- docs/dev/api/routes.md | 8 +- docs/dev/guides/configuration.md | 2 + docs/dev/security-model.md | 16 +++- src/bin/bench_support.rs | 2 + src/config/mod.rs | 8 ++ src/config/tests.rs | 2 + src/handlers/auth.rs | 10 +-- src/handlers/user.rs | 3 +- src/repositories/token.rs | 25 ++++++ src/services/auth/mod.rs | 4 + src/services/auth/password_reset.rs | 4 +- src/services/auth/register.rs | 43 +++++++++- src/services/captcha.rs | 61 +++++++++++-- src/services/email.rs | 36 +++++++- src/services/email_change.rs | 57 +++++++++++-- src/services/user.rs | 2 + templates/emails/en/email_change_new_otp.html | 9 ++ .../emails/en/email_change_new_otp.subject | 1 + templates/emails/fr/email_change_new_otp.html | 9 ++ .../emails/fr/email_change_new_otp.subject | 1 + tests/integration/api/account/email.rs | 61 ++++++++++++- tests/integration/api/auth/captcha.rs | 55 ++++++++++++ .../security/regressions/account_hardening.rs | 85 +++++++++++++++++++ 26 files changed, 480 insertions(+), 40 deletions(-) create mode 100644 templates/emails/en/email_change_new_otp.html create mode 100644 templates/emails/en/email_change_new_otp.subject create mode 100644 templates/emails/fr/email_change_new_otp.html create mode 100644 templates/emails/fr/email_change_new_otp.subject diff --git a/crates/testkit/src/app.rs b/crates/testkit/src/app.rs index c3aa4f1..80c1b03 100644 --- a/crates/testkit/src/app.rs +++ b/crates/testkit/src/app.rs @@ -531,6 +531,8 @@ pub fn test_config(db_url: &str, redis_url: &str, nats_url: &str) -> Config { 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()], 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/docs/dev/api/openapi.yaml b/docs/dev/api/openapi.yaml index fc05e02..db48c5f 100644 --- a/docs/dev/api/openapi.yaml +++ b/docs/dev/api/openapi.yaml @@ -4177,7 +4177,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: @@ -4196,12 +4196,6 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorBody' - '409': - description: Address taken - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorBody' '413': description: Body larger than 64 KB content: diff --git a/docs/dev/api/routes.md b/docs/dev/api/routes.md index 3da2988..9982b99 100644 --- a/docs/dev/api/routes.md +++ b/docs/dev/api/routes.md @@ -221,8 +221,12 @@ 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 diff --git a/docs/dev/guides/configuration.md b/docs/dev/guides/configuration.md index dae17bd..9597cf4 100644 --- a/docs/dev/guides/configuration.md +++ b/docs/dev/guides/configuration.md @@ -128,6 +128,8 @@ and `occurred_at`. The broker ships in the compose files. | `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 | diff --git a/docs/dev/security-model.md b/docs/dev/security-model.md index e4c257e..c47b210 100644 --- a/docs/dev/security-model.md +++ b/docs/dev/security-model.md @@ -29,9 +29,18 @@ the database together, is out of scope. the next successful sign-in. - **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. + 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, and the link applies @@ -354,3 +363,4 @@ when a cited test no longer exists. | 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, the authenticated recovery route has its own budget, 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`, `the_authenticated_recovery_route_spends_its_own_budget`, `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` | diff --git a/src/bin/bench_support.rs b/src/bin/bench_support.rs index 2ab039e..9a3a11c 100644 --- a/src/bin/bench_support.rs +++ b/src/bin/bench_support.rs @@ -388,6 +388,8 @@ fn fallback_config(db_url: &str, redis_url: &str) -> Config { 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()], diff --git a/src/config/mod.rs b/src/config/mod.rs index 3ea9c2e..342d0ee 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -355,6 +355,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)] @@ -564,6 +570,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), diff --git a/src/config/tests.rs b/src/config/tests.rs index c581811..9883ad0 100644 --- a/src/config/tests.rs +++ b/src/config/tests.rs @@ -92,6 +92,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, diff --git a/src/handlers/auth.rs b/src/handlers/auth.rs index 2ea9f91..8ce95d7 100644 --- a/src/handlers/auth.rs +++ b/src/handlers/auth.rs @@ -165,7 +165,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, @@ -219,7 +219,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, @@ -349,7 +349,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 +374,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) @@ -431,7 +431,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) diff --git a/src/handlers/user.rs b/src/handlers/user.rs index f92af5e..7f4fc1d 100644 --- a/src/handlers/user.rs +++ b/src/handlers/user.rs @@ -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" = [])), diff --git a/src/repositories/token.rs b/src/repositories/token.rs index ac45ced..a8cf69f 100644 --- a/src/repositories/token.rs +++ b/src/repositories/token.rs @@ -131,6 +131,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( diff --git a/src/services/auth/mod.rs b/src/services/auth/mod.rs index 7dc07c2..60b87ac 100644 --- a/src/services/auth/mod.rs +++ b/src/services/auth/mod.rs @@ -215,6 +215,10 @@ const MAGIC_LINK_IP_WINDOW_SECS: u64 = 900; 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 463f6da..570b800 100644 --- a/src/services/auth/password_reset.rs +++ b/src/services/auth/password_reset.rs @@ -176,8 +176,8 @@ pub async fn reset_password( // 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 diff --git a/src/services/auth/register.rs b/src/services/auth/register.rs index 66e4dfb..ef2c2a2 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,6 +15,35 @@ pub async fn register( ip: Option, user_agent: Option<&str>, request_id: Option, +) -> Result, AppError> { + 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 @@ -54,7 +86,16 @@ pub async fn register( request_id, ) .await?; - } else { + } 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); 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/email.rs b/src/services/email.rs index 80fbabd..c621169 100644 --- a/src/services/email.rs +++ b/src/services/email.rs @@ -35,6 +35,7 @@ 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 @@ -166,6 +167,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, @@ -609,7 +642,7 @@ async fn send( mod tests { use super::*; - const ALL_TEMPLATES: [&str; 13] = [ + const ALL_TEMPLATES: [&str; 14] = [ TNAME_VERIFICATION, TNAME_EMAIL_CHANGE_OTP, TNAME_PASSWORD_RESET, @@ -623,6 +656,7 @@ mod tests { TNAME_NEW_DEVICE_LOGIN, TNAME_MAGIC_LINK, TNAME_ACCESS_ADDED, + TNAME_EMAIL_CHANGE_NEW_OTP, ]; #[test] diff --git a/src/services/email_change.rs b/src/services/email_change.rs index a9d98be..6946cb5 100644 --- a/src/services/email_change.rs +++ b/src/services/email_change.rs @@ -197,7 +197,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, @@ -212,14 +217,40 @@ 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(_) => {} + Err(error) => tracing::warn!(%error, "email change budget unavailable, failing open"), + } + 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(state, user_id, &otp); @@ -246,19 +277,21 @@ pub async fn submit_new( .await .map_err(|e| AppError::Internal(e.into()))?; + 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, ) @@ -327,6 +360,9 @@ 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?; @@ -503,6 +539,11 @@ async fn verify_otp( Ok(()) } +/// 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"; diff --git a/src/services/user.rs b/src/services/user.rs index 77264c5..00fb501 100644 --- a/src/services/user.rs +++ b/src/services/user.rs @@ -216,6 +216,8 @@ pub async fn change_password( let mut tx = state.db.begin().await?; user_repo::update_password_hash(&mut *tx, user_id, &new_hash).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. diff --git a/templates/emails/en/email_change_new_otp.html b/templates/emails/en/email_change_new_otp.html new file mode 100644 index 0000000..067ac71 --- /dev/null +++ b/templates/emails/en/email_change_new_otp.html @@ -0,0 +1,9 @@ + + + +

Hello,

+

Someone asked to use this address for their {{ app_name }} account. The code confirming it is: {{ code }}

+

This code expires in {{ expires_in_minutes }} minutes. Do not share it with anyone.

+

If this was not you, ignore this message: nothing changes without this code.

+ + diff --git a/templates/emails/en/email_change_new_otp.subject b/templates/emails/en/email_change_new_otp.subject new file mode 100644 index 0000000..1fae32c --- /dev/null +++ b/templates/emails/en/email_change_new_otp.subject @@ -0,0 +1 @@ +Confirm this address for your account diff --git a/templates/emails/fr/email_change_new_otp.html b/templates/emails/fr/email_change_new_otp.html new file mode 100644 index 0000000..1b4807a --- /dev/null +++ b/templates/emails/fr/email_change_new_otp.html @@ -0,0 +1,9 @@ + + + +

Bonjour,

+

Quelqu'un a demandé à utiliser cette adresse pour son compte {{ app_name }}. Le code qui le confirme est : {{ code }}

+

Ce code expire dans {{ expires_in_minutes }} minutes. Ne le partagez avec personne.

+

Si ce n'est pas vous, ignorez ce message : rien ne change sans ce code.

+ + diff --git a/templates/emails/fr/email_change_new_otp.subject b/templates/emails/fr/email_change_new_otp.subject new file mode 100644 index 0000000..2847678 --- /dev/null +++ b/templates/emails/fr/email_change_new_otp.subject @@ -0,0 +1 @@ +Confirmez cette adresse pour votre compte diff --git a/tests/integration/api/account/email.rs b/tests/integration/api/account/email.rs index dba8188..14afb5f 100644 --- a/tests/integration/api/account/email.rs +++ b/tests/integration/api/account/email.rs @@ -176,7 +176,7 @@ async fn email_change_submit_invalid_email_format_rejected() { } #[tokio::test] -async fn email_change_submit_taken_email_rejected() { +async fn email_change_submit_taken_email_answers_like_a_free_one() { let app = TestApp::spawn().await; let user1 = fixtures::authenticated_user(&app, 5).await; let user2 = fixtures::authenticated_user(&app, 6).await; @@ -209,7 +209,64 @@ async fn email_change_submit_taken_email_rejected() { &serde_json::json!({ "flow_token": flow_token, "new_email": user1.email }), ) .await; - assert_eq!(res.status().as_u16(), 409); + // No oracle: the same answer as for a free address, and no code sent to + // the address of the other account. + assert_eq!(res.status().as_u16(), 204); + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + assert!( + app.mail + .messages_to(&user1.email) + .iter() + .all(|mail| mail.subject != "Confirm this address for your account"), + "a code went to the taken address" + ); +} + +/// Codes to new addresses are budgeted per account: the flow cannot send +/// them to anyone in bulk. +#[tokio::test] +async fn email_change_submissions_are_budgeted() { + let app = TestApp::spawn().await; + let user = fixtures::authenticated_user(&app, 60).await; + let mut statuses = Vec::new(); + for attempt in 0..4 { + app.post_auth( + "/users/me/reauth", + &user.access_token, + &serde_json::json!({ "current_password": user.password }), + ) + .await; + let started = app + .post_auth( + "/users/me/email/start", + &user.access_token, + &serde_json::json!({}), + ) + .await; + let flow_token = started.json::().await.unwrap()["flow_token"] + .as_str() + .unwrap() + .to_owned(); + let otp = app.read_email_change_otp(&flow_token).await; + app.post_auth( + "/users/me/email/verify-current", + &user.access_token, + &serde_json::json!({ "flow_token": flow_token, "code": otp }), + ) + .await; + let res = app + .post_auth( + "/users/me/email/submit", + &user.access_token, + &serde_json::json!({ + "flow_token": flow_token, + "new_email": format!("target{attempt}@example.com"), + }), + ) + .await; + statuses.push(res.status().as_u16()); + } + assert_eq!(statuses, [204, 204, 204, 429]); } // New-email OTP verification diff --git a/tests/integration/api/auth/captcha.rs b/tests/integration/api/auth/captcha.rs index 662a72d..d94b492 100644 --- a/tests/integration/api/auth/captcha.rs +++ b/tests/integration/api/auth/captcha.rs @@ -320,3 +320,58 @@ async fn captcha_fail_closed_returns_503_when_upstream_returns_invalid_json() { "fail_open=false must return 503 when upstream returns invalid JSON" ); } + +/// A mock recording what it was asked and answering with `hostname`. +async fn spawn_recording_captcha_mock( + hostname: &'static str, +) -> (String, std::sync::Arc>>) { + let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let recorded = seen.clone(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + let app = Router::new().route( + "/verify", + post(move |body: String| { + let recorded = recorded.clone(); + async move { + recorded.lock().unwrap().push(body); + Json(serde_json::json!({ "success": true, "hostname": hostname })) + } + }), + ); + axum::serve(listener, app).await.unwrap(); + }); + (format!("http://127.0.0.1:{port}/verify"), seen) +} + +/// A token solved on another site is refused, and the provider hears the +/// client's address and the site key. +#[tokio::test] +async fn a_token_solved_on_another_site_is_refused() { + let (verify_url, seen) = spawn_recording_captcha_mock("evil.example").await; + let app = TestApp::spawn_with_config(move |c| { + c.captcha.secret = Some("test_secret".into()); + c.captcha.verify_url = verify_url.clone(); + c.captcha.fail_open_on_error = false; + c.captcha.site_key = Some("site-key-1".into()); + }) + .await; + assert_eq!(try_register(&app, 40, Some("foreign-token")).await, 422); + let request = seen.lock().unwrap()[0].clone(); + assert!(request.contains("remoteip="), "{request}"); + assert!(request.contains("sitekey=site-key-1"), "{request}"); + + let (verify_url, _) = spawn_recording_captcha_mock("localhost").await; + let app = TestApp::spawn_with_config(move |c| { + c.captcha.secret = Some("test_secret".into()); + c.captcha.verify_url = verify_url.clone(); + c.captcha.fail_open_on_error = false; + }) + .await; + assert_eq!( + try_register(&app, 41, Some("our-token")).await, + 202, + "solved on the host of FRONTEND_URL" + ); +} diff --git a/tests/security/regressions/account_hardening.rs b/tests/security/regressions/account_hardening.rs index fe222e6..a8ba53b 100644 --- a/tests/security/regressions/account_hardening.rs +++ b/tests/security/regressions/account_hardening.rs @@ -296,3 +296,88 @@ async fn concurrent_reauthentication_guesses_never_exceed_the_budget() { assert_eq!(checked, 2, "{checked} guesses were checked past the budget"); assert_eq!(locked, 18); } + +/// Registering an active address again and again does not flood its owner: +/// the "someone tried to register" notice is budgeted per account. +#[tokio::test] +async fn registering_a_taken_address_repeatedly_notifies_its_owner_a_few_times() { + let app = TestApp::spawn().await; + let owner = fixtures::register_user(&app, 660).await; + fixtures::activate_user(&app.db, owner.id).await; + + for attempt in 0..6 { + let res = app + .post( + "/auth/register", + &json!({ + "username": format!("flooder_{attempt}"), + "email": owner.email, + "password": "Password660!ok", + }), + ) + .await; + assert_eq!(res.status().as_u16(), 202); + } + tokio::time::sleep(Duration::from_millis(300)).await; + let notices = app + .mail + .messages_to(&owner.email) + .into_iter() + .filter(|mail| mail.subject != "Verify your email address") + .count(); + assert_eq!(notices, 3); +} + +/// A reset link or sign-in link mailed before a password change stops working: +/// it would otherwise bypass the change. +#[tokio::test] +async fn a_password_change_ends_the_links_already_mailed() { + let app = TestApp::spawn().await; + let user = fixtures::authenticated_user(&app, 661).await; + let token = fixtures::create_password_reset_token(&app.db, user.id).await; + + let res = app + .patch_auth( + "/users/me/password", + &user.access_token, + &json!({ + "current_password": user.password, + "new_password": "Changed-Pass-661!", + }), + ) + .await; + assert_eq!(res.status().as_u16(), 204); + + let res = app + .post( + "/auth/reset-password", + &json!({ "token": token.raw, "new_password": "Attacker-Pass-661!" }), + ) + .await; + assert_eq!( + res.status().as_u16(), + 401, + "the old reset link still worked" + ); +} + +/// Registration takes the same minimum time whether the address is new or +/// taken: the work differs, the response time must not. +#[tokio::test] +async fn registration_takes_a_constant_minimum_time() { + let app = TestApp::spawn().await; + let owner = fixtures::register_user(&app, 662).await; + fixtures::activate_user(&app.db, owner.id).await; + + for email in [owner.email.clone(), "fresh662@example.com".to_owned()] { + let started = Instant::now(); + let res = app + .post( + "/auth/register", + &json!({ "username": "timing_662", "email": email, "password": "Password662!ok" }), + ) + .await; + assert_eq!(res.status().as_u16(), 202); + assert!(started.elapsed() >= Duration::from_millis(250), "{email}"); + } +} From c9ef5f467f21defab2d1db037643332d307f5b40 Mon Sep 17 00:00:00 2001 From: SIIR3X <125802911+SIIR3X@users.noreply.github.com> Date: Mon, 21 Sep 2026 00:35:47 +0200 Subject: [PATCH 15/55] fix(users): make usernames unique regardless of case and stop recording unrecognized identifiers --- CHANGELOG.md | 2 + docs/dev/privacy.md | 7 +++ docs/dev/security-model.md | 1 + .../0028_case_insensitive_usernames.sql | 14 +++++ migrations/SHA256SUMS | 1 + src/domain/login_attempt.rs | 31 +++++++++++ src/repositories/user.rs | 3 +- src/services/auth/guards.rs | 2 +- src/services/auth/register.rs | 1 + src/services/user.rs | 8 ++- tests/integration/api/account/email.rs | 33 ++++++++--- tests/integration/migrations/query_plans.rs | 2 +- .../security/regressions/account_hardening.rs | 55 +++++++++++++++++++ .../security/regressions/session_hardening.rs | 4 +- 14 files changed, 152 insertions(+), 12 deletions(-) create mode 100644 migrations/0028_case_insensitive_usernames.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index ff94756..7250486 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,8 @@ as described in the [versioning policy](docs/dev/guides/versioning.md). section 2.6): create `auth_api_owner`, `REASSIGN OWNED BY auth_api`, run `deploy/db/auth-api-grants.sql`, then run migrations with the owner's URL (`prod/auth-api/database-owner-url`). A single-role deployment keeps working. +- Migration 0028 stops if two usernames differ only in case; its message gives + the query that lists them. Rename all but one of each, then migrate again. - Check the settings refused at start-up (listed under Security) against your environment before upgrading. - Run `auth-api --rotate-totp-keys` once, without `PREVIOUS_ENCRYPTION_KEY`, to diff --git a/docs/dev/privacy.md b/docs/dev/privacy.md index f6cde2b..bf8c37f 100644 --- a/docs/dev/privacy.md +++ b/docs/dev/privacy.md @@ -73,3 +73,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 c47b210..d5b8f5e 100644 --- a/docs/dev/security-model.md +++ b/docs/dev/security-model.md @@ -364,3 +364,4 @@ when a cited test no longer exists. | 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` | diff --git a/migrations/0028_case_insensitive_usernames.sql b/migrations/0028_case_insensitive_usernames.sql new file mode 100644 index 0000000..297b9cc --- /dev/null +++ b/migrations/0028_case_insensitive_usernames.sql @@ -0,0 +1,14 @@ +-- Usernames are unique whatever their case: `Alice` and `alice` no longer +-- coexist, one impersonating the other. The sign-in lookup and the brute-force +-- counters already treat them as one identifier. +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM users GROUP BY lower(username) HAVING count(*) > 1 + ) THEN + RAISE EXCEPTION 'usernames differing only in case exist; rename all but one of each before migrating. List them with: SELECT lower(username), array_agg(username) FROM users GROUP BY 1 HAVING count(*) > 1'; + END IF; +END +$$; + +CREATE UNIQUE INDEX users_username_lower_key ON users (lower(username)); diff --git a/migrations/SHA256SUMS b/migrations/SHA256SUMS index ee1f631..967f46f 100644 --- a/migrations/SHA256SUMS +++ b/migrations/SHA256SUMS @@ -25,3 +25,4 @@ a3fdddb551010b532efa344548a0b467652649068b939f99de46d4a8fdaf1053 0023_passkeys. 8c16772a3f7f23ebdb38fed5414e59799df768e930ec14972d37dfa69c98b11d 0025_pending_registrations.sql abd34b501669ad72e0b8ce5ca2b2966e354448eb4b064e0b7eca1b465515f458 0026_session_second_factor.sql 524db32bbc3476a82eed8a91d416dd8ed3c617ffb5e6283b10baa349ed837b35 0027_runtime_role.sql +b9e17ad7ce9512f38318f6818b9149d356e9be1058a07a2c201068b6b6bf1173 0028_case_insensitive_usernames.sql diff --git a/src/domain/login_attempt.rs b/src/domain/login_attempt.rs index ec52161..93985b4 100644 --- a/src/domain/login_attempt.rs +++ b/src/domain/login_attempt.rs @@ -101,3 +101,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/repositories/user.rs b/src/repositories/user.rs index 6e5aecb..329a16d 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 diff --git a/src/services/auth/guards.rs b/src/services/auth/guards.rs index dda1f8d..4d35a7c 100644 --- a/src/services/auth/guards.rs +++ b/src/services/auth/guards.rs @@ -113,7 +113,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, diff --git a/src/services/auth/register.rs b/src/services/auth/register.rs index ef2c2a2..7eeae0a 100644 --- a/src/services/auth/register.rs +++ b/src/services/auth/register.rs @@ -131,6 +131,7 @@ async fn register_account( &[ ("users_email_key", "email_taken"), ("users_username_key", "username_taken"), + ("users_username_lower_key", "username_taken"), ], ) { AppError::Conflict("email_taken") => Ok(None), diff --git a/src/services/user.rs b/src/services/user.rs index 00fb501..616a74f 100644 --- a/src/services/user.rs +++ b/src/services/user.rs @@ -145,7 +145,13 @@ pub async fn change_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_key", "username_taken"), + ("users_username_lower_key", "username_taken"), + ], + ) })?; audit::append( diff --git a/tests/integration/api/account/email.rs b/tests/integration/api/account/email.rs index 14afb5f..8e34828 100644 --- a/tests/integration/api/account/email.rs +++ b/tests/integration/api/account/email.rs @@ -6,7 +6,8 @@ use crate::common::{app::TestApp, fixtures}; async fn email_change_full_flow_success() { let app = TestApp::spawn().await; let user = fixtures::authenticated_user(&app, 1).await; - let new_email = "new1@example.com"; + // Unique per run: new addresses are budgeted globally, per target. + let new_email = &(fixtures::unique("new1_") + "@example.com"); // Step 1: start - sends OTP to current email let res = app @@ -122,7 +123,7 @@ async fn email_change_steps_cannot_be_skipped() { .post_auth( "/users/me/email/submit", &user.access_token, - &serde_json::json!({ "flow_token": flow_token, "new_email": "skip@example.com" }), + &serde_json::json!({ "flow_token": flow_token, "new_email": fixtures::unique("skip") + "@example.com" }), ) .await; assert_eq!(res.status().as_u16(), 401); @@ -202,6 +203,21 @@ async fn email_change_submit_taken_email_answers_like_a_free_one() { ) .await; + // The per-target budget is global, and this address is the same on every + // run: start from a clean one. + { + use deadpool_redis::redis::AsyncCommands; + let digest: String = + auth_api::utils::crypto::sha256(user1.email.to_ascii_lowercase().as_bytes()) + .iter() + .map(|b| format!("{b:02x}")) + .collect(); + let mut conn = app.redis.get().await.unwrap(); + let _: () = conn + .del(format!("ec_submit_target:{digest}")) + .await + .unwrap(); + } let res = app .post_auth( "/users/me/email/submit", @@ -260,7 +276,7 @@ async fn email_change_submissions_are_budgeted() { &user.access_token, &serde_json::json!({ "flow_token": flow_token, - "new_email": format!("target{attempt}@example.com"), + "new_email": fixtures::unique(&format!("target{attempt}")) + "@example.com", }), ) .await; @@ -275,7 +291,8 @@ async fn email_change_submissions_are_budgeted() { async fn email_change_confirm_wrong_code_rejected() { let app = TestApp::spawn().await; let user = fixtures::authenticated_user(&app, 7).await; - let new_email = "confirm_wrong7@example.com"; + // Unique per run: new addresses are budgeted globally, per target. + let new_email = &(fixtures::unique("confirm_wrong7_") + "@example.com"); let flow_token = start_and_verify_current(&app, &user.access_token).await; @@ -381,7 +398,8 @@ async fn email_change_requires_verified_email() { async fn email_change_cooldown_prevents_immediate_second_change() { let app = TestApp::spawn().await; let user = fixtures::authenticated_user(&app, 12).await; - let new_email = "cooldown12@example.com"; + // Unique per run: new addresses are budgeted globally, per target. + let new_email = &(fixtures::unique("cooldown12_") + "@example.com"); // Complete a full flow. run_full_flow(&app, &user, new_email).await; @@ -401,7 +419,8 @@ async fn email_change_cooldown_prevents_immediate_second_change() { async fn email_change_cooldown_lifted_allows_new_flow() { let app = TestApp::spawn().await; let user = fixtures::authenticated_user(&app, 13).await; - let new_email = "cooldown_lifted13@example.com"; + // Unique per run: new addresses are budgeted globally, per target. + let new_email = &(fixtures::unique("cooldown_lifted13_") + "@example.com"); run_full_flow(&app, &user, new_email).await; @@ -537,7 +556,7 @@ async fn email_change_confirm_new_lockout_after_max_failures() { app.post_auth( "/users/me/email/submit", &user.access_token, - &serde_json::json!({ "flow_token": flow_token, "new_email": "new13@example.com" }), + &serde_json::json!({ "flow_token": flow_token, "new_email": fixtures::unique("new13") + "@example.com" }), ) .await; diff --git a/tests/integration/migrations/query_plans.rs b/tests/integration/migrations/query_plans.rs index fccbbfa..9de874c 100644 --- a/tests/integration/migrations/query_plans.rs +++ b/tests/integration/migrations/query_plans.rs @@ -27,7 +27,7 @@ async fn identifier_lookup_plan_uses_user_indexes() { pg_args![&username], ) .await; - assert_plan_contains(&username_plan, "users_username_key"); + assert_plan_contains(&username_plan, "users_username_lower_key"); } #[tokio::test] diff --git a/tests/security/regressions/account_hardening.rs b/tests/security/regressions/account_hardening.rs index a8ba53b..7d0e4bd 100644 --- a/tests/security/regressions/account_hardening.rs +++ b/tests/security/regressions/account_hardening.rs @@ -381,3 +381,58 @@ async fn registration_takes_a_constant_minimum_time() { assert!(started.elapsed() >= Duration::from_millis(250), "{email}"); } } + +/// Usernames are unique whatever their case: `Alice` cannot impersonate +/// `alice`, and a sign-in finds the account whatever the case typed. +#[tokio::test] +async fn usernames_differing_only_in_case_cannot_coexist() { + let app = TestApp::spawn().await; + let alice = fixtures::register_user(&app, 670).await; + fixtures::activate_user(&app.db, alice.id).await; + + let res = app + .post( + "/auth/register", + &json!({ + "username": alice.username.to_uppercase(), + "email": "impostor670@example.com", + "password": "Password670!ok", + }), + ) + .await; + assert_eq!(res.status().as_u16(), 409); + let body: Value = res.json().await.unwrap(); + assert_eq!(body["code"], "username_taken"); + + let res = app + .post( + "/auth/login", + &json!({ "identifier": alice.username.to_uppercase(), "password": alice.password }), + ) + .await; + assert_eq!(res.status().as_u16(), 200); +} + +/// Something typed in the identifier field that is neither an address nor a +/// username (a password typed there by mistake) is not kept. +#[tokio::test] +async fn an_unrecognized_identifier_is_not_recorded() { + let app = TestApp::spawn().await; + let res = app + .post( + "/auth/login", + &json!({ "identifier": "My Secret Pa$$word!", "password": "whatever" }), + ) + .await; + assert_eq!(res.status().as_u16(), 401); + let recorded: Vec = + sqlx::query_scalar("SELECT attempted_identifier::text FROM login_attempts") + .fetch_all(&app.db) + .await + .unwrap(); + assert!( + !recorded.iter().any(|value| value.contains("Secret")), + "{recorded:?}" + ); + assert!(recorded.iter().any(|value| value == "")); +} diff --git a/tests/security/regressions/session_hardening.rs b/tests/security/regressions/session_hardening.rs index 5f0dbe2..3338159 100644 --- a/tests/security/regressions/session_hardening.rs +++ b/tests/security/regressions/session_hardening.rs @@ -129,7 +129,9 @@ async fn an_email_change_keeps_the_status_and_audits_no_address() { .await .unwrap(); - change_email(&app, &user.access_token, "moved663@example.com").await; + // Unique per run: new addresses are budgeted globally, per target. + let new_email = fixtures::unique("moved663_") + "@example.com"; + change_email(&app, &user.access_token, &new_email).await; let status: String = sqlx::query_scalar("SELECT status::text FROM users WHERE id = $1") .bind(user.id) From 695642a408adf519e72bf1f5b0b895bfaa81bb04 Mon Sep 17 00:00:00 2001 From: SIIR3X <125802911+SIIR3X@users.noreply.github.com> Date: Mon, 21 Sep 2026 04:47:53 +0200 Subject: [PATCH 16/55] fix(surface): fold unmatched paths, keep readiness detail internal, and read secrets from files --- CHANGELOG.md | 18 ++ .../monitoring/rules/infrastructure.test.yml | 9 + deploy/monitoring/rules/infrastructure.yml | 8 + docs/deploy/api/secrets.md | 8 + docs/deploy/guides/monitoring.md | 2 +- docs/deploy/guides/operations.md | 9 +- docs/dev/api/openapi.yaml | 14 +- docs/dev/api/routes.md | 2 +- docs/dev/guides/configuration.md | 5 + docs/dev/security-model.md | 2 + docs/dev/threat-model.md | 9 +- scripts/stack-smoke.sh | 2 + src/config/env_vars.rs | 44 ++++- src/config/mod.rs | 9 +- src/config/tests.rs | 55 ++++++ src/domain/webhook.rs | 10 + src/handlers/mod.rs | 183 ++++++++++++++---- src/openapi.rs | 2 + src/repositories/export.rs | 10 +- src/services/auth/login.rs | 28 ++- src/services/passkey.rs | 11 +- tests/integration/api/account/export.rs | 38 ++++ tests/integration/api/account/passkeys.rs | 19 ++ tests/integration/api/admin/roles.rs | 7 +- tests/integration/api/probes.rs | 24 ++- tests/simulation/dependencies.rs | 7 +- 26 files changed, 451 insertions(+), 84 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7250486..a826503 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,22 @@ as described in the [versioning policy](docs/dev/guides/versioning.md). - `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. ### Upgrading @@ -75,6 +91,8 @@ as described in the [versioning policy](docs/dev/guides/versioning.md). environment before upgrading. - Run `auth-api --rotate-totp-keys` once, without `PREVIOUS_ENCRYPTION_KEY`, to bind the secrets written before the upgrade to their rows. +- Monitoring that reads the dependencies from the public `/ready` must query + the internal listener instead (`http://10.0.0.1:9465/ready`). - Copy the new `log_parameter_max_length` lines of `deploy/db/postgresql.auth-api.conf` and reload PostgreSQL. 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/docs/deploy/api/secrets.md b/docs/deploy/api/secrets.md index ec41716..0f6cba3 100644 --- a/docs/deploy/api/secrets.md +++ b/docs/deploy/api/secrets.md @@ -4,6 +4,14 @@ All secrets are stored in `pass` on the API VPS and exported as environment variables before deployment. +An orchestrator that mounts secrets as files (Docker Swarm, Kubernetes, +systemd credentials) can pass any of them as `X_FILE`, the path of the file, +instead of `X`: the value then appears neither in `docker inspect` nor in the +process environment. The image runs as UID 65532, which must be able to read +the file. The reference `docker-compose.api.yml` keeps environment variables: +compose mounts file secrets into a read-only container only from host files, +which would put the secrets on the API VPS's disk. + ## Setup Initialize `pass` if not already done: diff --git a/docs/deploy/guides/monitoring.md b/docs/deploy/guides/monitoring.md index d411744..da534c3 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), 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 | diff --git a/docs/deploy/guides/operations.md b/docs/deploy/guides/operations.md index 13cc869..9b87631 100644 --- a/docs/deploy/guides/operations.md +++ b/docs/deploy/guides/operations.md @@ -157,8 +157,8 @@ 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 10.0.0.1:9465/ready` +and `curl -f 10.0.0.1:9466/ready` 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 @@ -213,8 +213,9 @@ only, never behind nginx). 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) @@ -436,7 +437,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/dev/api/openapi.yaml b/docs/dev/api/openapi.yaml index db48c5f..9803926 100644 --- a/docs/dev/api/openapi.yaml +++ b/docs/dev/api/openapi.yaml @@ -3847,7 +3847,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: @@ -7133,20 +7133,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.' diff --git a/docs/dev/api/routes.md b/docs/dev/api/routes.md index 9982b99..c37373e 100644 --- a/docs/dev/api/routes.md +++ b/docs/dev/api/routes.md @@ -34,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 diff --git a/docs/dev/guides/configuration.md b/docs/dev/guides/configuration.md index 9597cf4..4367166 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). diff --git a/docs/dev/security-model.md b/docs/dev/security-model.md index d5b8f5e..48999b3 100644 --- a/docs/dev/security-model.md +++ b/docs/dev/security-model.md @@ -365,3 +365,5 @@ when a cited test no longer exists. | 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` | diff --git a/docs/dev/threat-model.md b/docs/dev/threat-model.md index 4596696..72ebf80 100644 --- a/docs/dev/threat-model.md +++ b/docs/dev/threat-model.md @@ -50,7 +50,7 @@ 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 | | 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) | @@ -108,7 +108,7 @@ at least once a year. | 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 | | 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 +130,11 @@ 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. +- 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. Budgets on + registration bound how fast usernames can be tried; a deployment that treats + usernames as secret should let users sign in by email only. ## 6. Verification diff --git a/scripts/stack-smoke.sh b/scripts/stack-smoke.sh index 61fbdfb..ca2e630 100755 --- a/scripts/stack-smoke.sh +++ b/scripts/stack-smoke.sh @@ -113,6 +113,8 @@ 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 "public ready names no dependency" '{"status":"ready"}' "$(curl -s http://127.0.0.1:3001/ready)" +check "internal ready details dependencies" up "$(curl -s 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 -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}'; } diff --git a/src/config/env_vars.rs b/src/config/env_vars.rs index 00706e8..dd6de89 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; @@ -74,6 +74,48 @@ 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) +} + 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 342d0ee..f5e70be 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -18,7 +18,7 @@ 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 }, @@ -429,9 +429,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| std::env::var(key).ok().or_else(|| files.get(key).cloned())) } /// Load configuration from `lookup`, which returns the value of a variable: diff --git a/src/config/tests.rs b/src/config/tests.rs index 9883ad0..543cfe8 100644 --- a/src/config/tests.rs +++ b/src/config/tests.rs @@ -1160,3 +1160,58 @@ fn validate_bounds_lifetimes_windows_and_limits() { ); } } + +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}"); +} 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/handlers/mod.rs b/src/handlers/mod.rs index f5b7cae..8857107 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,22 +121,76 @@ 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" }, + 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) { + let readiness = readiness(&state).await; + ( + ready_status(&readiness), axum::Json(ReadyResponse { - status: if all { "ready" } else { "unavailable" }, - database: up(database), - redis: up(redis), - nats: up(nats), + status: readiness.status, }), ) } +/// 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)) +} + +/// 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( get, path = "/.well-known/jwks.json", @@ -156,9 +212,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 +223,21 @@ 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() } - }), - ); + 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)) + .with_state(state); - (app, metrics) + (app, internal) } pub fn router(state: AppState) -> Router { @@ -236,6 +296,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, @@ -262,6 +331,7 @@ fn build_router( let router = probes .merge(public) + .merge(unmatched) .nest( "/auth", auth_router().layer(middleware::from_fn_with_state( @@ -316,6 +386,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() @@ -578,22 +653,44 @@ fn me_router() -> Router { #[cfg(test)] mod tests { + use tower::ServiceExt; + #[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/openapi.rs b/src/openapi.rs index 49bf435..9c96f1e 100644 --- a/src/openapi.rs +++ b/src/openapi.rs @@ -288,6 +288,8 @@ mod tests { /// Every `.route(...)` of the router, with its nesting prefix. fn routed_endpoints() -> BTreeSet<(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, _)| { diff --git a/src/repositories/export.rs b/src/repositories/export.rs index 3b74aa6..6e01736 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) 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; @@ -95,9 +96,12 @@ 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' = 'administrator' + THEN NULL ELSE host(l.ip_address) END, 'request_id', l.request_id, - 'metadata', l.metadata + 'metadata', l.metadata - 'administrator_id' ) ORDER BY l.created_at, l.id) FROM audit_log l WHERE l.user_id = $1 AND l.created_at <= NOW() ), '[]'::jsonb) diff --git a/src/services/auth/login.rs b/src/services/auth/login.rs index 584bf51..9bcd45d 100644 --- a/src/services/auth/login.rs +++ b/src/services/auth/login.rs @@ -156,10 +156,22 @@ 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, @@ -167,9 +179,10 @@ pub async fn login( 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( + if let Err(e) = user_repo::set_locked_until(&state.db, u.id, locked_until).await { + lockout_failed("lock", &e); + } + if let Err(e) = audit::append( &state.db, &NewAuditEntry { user_id: Some(u.id), @@ -179,7 +192,10 @@ pub async fn login( metadata: json!({"reason": "lockout", "locked_until": locked_until.unix_timestamp()}), }, ) - .await; + .await + { + lockout_failed("audit", &e); + } } metrics::counter!("auth_logins_total", "outcome" => "invalid_credentials").increment(1); diff --git a/src/services/passkey.rs b/src/services/passkey.rs index 6b5e966..309f3b7 100644 --- a/src/services/passkey.rs +++ b/src/services/passkey.rs @@ -430,9 +430,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/tests/integration/api/account/export.rs b/tests/integration/api/account/export.rs index 8fc2742..99e4da1 100644 --- a/tests/integration/api/account/export.rs +++ b/tests/integration/api/account/export.rs @@ -67,3 +67,41 @@ async fn exporting_needs_a_recent_reauthentication() { let body: Value = response.json().await.unwrap(); assert_eq!(body["code"], "reauthentication_required"); } + +#[tokio::test] +async fn the_export_names_no_administrator_nor_their_address() { + let app = TestApp::spawn().await; + let user = fixtures::authenticated_user(&app, 1).await; + let administrator = uuid::Uuid::new_v4(); + sqlx::query( + "INSERT INTO audit_log (user_id, action, ip_address, metadata) + VALUES ($1, 'account_suspended', '203.0.113.77', + jsonb_build_object('by', 'administrator', 'administrator_id', $2::text))", + ) + .bind(user.id) + .bind(administrator) + .execute(&app.db) + .await + .unwrap(); + + let text = app + .get_auth("/users/me/export", &user.access_token) + .await + .text() + .await + .unwrap(); + assert!(!text.contains(&administrator.to_string()), "{text}"); + assert!(!text.contains("203.0.113.77"), "{text}"); + let document: Value = serde_json::from_str(&text).unwrap(); + let entry = document["audit_log"] + .as_array() + .unwrap() + .iter() + .find(|entry| entry["action"] == "account_suspended") + .expect("the change stays in the history"); + assert_eq!( + entry["metadata"], + serde_json::json!({ "by": "administrator" }) + ); + assert_eq!(entry["ip_address"], Value::Null); +} diff --git a/tests/integration/api/account/passkeys.rs b/tests/integration/api/account/passkeys.rs index fdbb4fb..39f061c 100644 --- a/tests/integration/api/account/passkeys.rs +++ b/tests/integration/api/account/passkeys.rs @@ -213,6 +213,25 @@ async fn forged_replayed_or_cloned_assertions_are_refused() { impostor.counter = 100; assert_eq!(sign_in(&app, &mut impostor).await.0, 401); + // Without the user handle, which a discoverable credential always carries. + let (_, options) = post(&app, "/auth/passkeys/options", None, json!({})).await; + let mut credential = authenticator.get(&options); + credential["response"] + .as_object_mut() + .unwrap() + .remove("userHandle"); + let (status, refused) = post( + &app, + "/auth/passkeys/sign-in", + None, + json!({ "credential": credential }), + ) + .await; + assert_eq!( + (status, refused["code"].as_str()), + (401, Some("invalid_credentials")) + ); + // An unknown credential. let mut stranger = SoftAuthenticator::for_app(&app); stranger.user_handle = authenticator.user_handle.clone(); diff --git a/tests/integration/api/admin/roles.rs b/tests/integration/api/admin/roles.rs index 49de184..34f3e04 100644 --- a/tests/integration/api/admin/roles.rs +++ b/tests/integration/api/admin/roles.rs @@ -453,7 +453,12 @@ async fn concurrent_withdrawals_never_leave_nobody_managing_roles() { let (a, b) = tokio::join!(withdraw(first.user.id), withdraw(second.user.id)); let mut outcomes = [a, b]; outcomes.sort(); - assert_eq!(outcomes, [204, 409], "one withdrawal must be refused"); + // The refusal is the guard (409), or the permission check (403) when the + // actor's own withdrawal committed before the other request was checked. + assert!( + outcomes == [204, 409] || outcomes == [204, 403], + "one withdrawal must be refused: {outcomes:?}" + ); assert!( auth_api::repositories::role::permission_held(&app.db, "roles:manage") .await diff --git a/tests/integration/api/probes.rs b/tests/integration/api/probes.rs index 6357746..4f5f336 100644 --- a/tests/integration/api/probes.rs +++ b/tests/integration/api/probes.rs @@ -15,17 +15,37 @@ async fn liveness_answers_without_checking_dependencies() { } #[tokio::test] -async fn readiness_reports_every_dependency() { +async fn public_readiness_says_ready_without_naming_dependencies() { let app = TestApp::spawn().await; let res = app.get("/ready").await; assert_eq!(res.status().as_u16(), 200); let body: Value = res.json().await.unwrap(); + assert_eq!(body, serde_json::json!({ "status": "ready" })); + + // The detail stays on the internal listener. + let detail = auth_api::handlers::readiness(&app.state).await; assert_eq!( - body, + serde_json::to_value(&detail).unwrap(), serde_json::json!({ "status": "ready", "database": "up", "redis": "up", "nats": "up" }) ); } +#[tokio::test] +async fn unknown_paths_spend_the_general_budget() { + let app = TestApp::spawn_with_config(|config| { + config.rate_limit.requests_per_minute = 3; + }) + .await; + app.clear_rate_limit_key(&app.client_ip).await; + for i in 0..3 { + let res = app.get(&format!("/scan-{i}")).await; + assert_eq!(res.status().as_u16(), 404); + let body: Value = res.json().await.unwrap(); + assert_eq!(body["code"], "not_found"); + } + assert_eq!(app.get("/auth/scan-nested").await.status().as_u16(), 429); +} + #[tokio::test] async fn probes_are_not_rate_limited() { let app = TestApp::spawn_with_config(|config| { diff --git a/tests/simulation/dependencies.rs b/tests/simulation/dependencies.rs index 1667dd9..8d22761 100644 --- a/tests/simulation/dependencies.rs +++ b/tests/simulation/dependencies.rs @@ -201,10 +201,11 @@ async fn a_refresh_goes_through_without_redis() { ); } +/// The public status code with the internal detail of each dependency. async fn ready(app: &TestApp) -> (u16, serde_json::Value) { - let res = app.get("/ready").await; - let status = res.status().as_u16(); - (status, res.json().await.unwrap_or_default()) + let status = app.get("/ready").await.status().as_u16(); + let detail = auth_api::handlers::readiness(&app.state).await; + (status, serde_json::to_value(detail).unwrap()) } /// Poll `/ready` until `check` holds, for up to ten seconds. From 949c1f1098cca843ac5120824b6ffa8084418b05 Mon Sep 17 00:00:00 2001 From: SIIR3X <125802911+SIIR3X@users.noreply.github.com> Date: Mon, 21 Sep 2026 08:59:59 +0200 Subject: [PATCH 17/55] docs: align the security model and route reference with the audit fixes --- docs/dev/api/routes.md | 12 ++++++++---- docs/dev/security-model.md | 29 ++++++++++++++++++----------- 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/docs/dev/api/routes.md b/docs/dev/api/routes.md index c37373e..51ad061 100644 --- a/docs/dev/api/routes.md +++ b/docs/dev/api/routes.md @@ -324,11 +324,11 @@ 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}/suspend` | Admin `users:manage` + reauth | 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}/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 | @@ -341,9 +341,12 @@ are regenerated. | PUT | `/admin/clients/{client_id}` | Admin `clients:manage` + reauth | General | | DELETE | `/admin/clients/{client_id}` | Admin `clients:manage` | 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). + `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`. @@ -355,7 +358,8 @@ 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`. Creating, updating +and re-keying a webhook need 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/security-model.md b/docs/dev/security-model.md index 48999b3..cd6aef3 100644 --- a/docs/dev/security-model.md +++ b/docs/dev/security-model.md @@ -215,17 +215,19 @@ the database together, is out of scope. - 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, saving a client or changing its secret, creating, redirecting or re-keying a webhook, suspending an account and forcing its reset need a recent re-authentication: a stolen administrator token alone can neither send account events elsewhere nor shut owners out. - Every change is audited in the same transaction; a webhook's audit keeps the - host it points to (never the path or query), and redeliveries are audited. 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. +- Every change is audited in the same transaction; a webhook's audit keeps the + host it points to (never the path or query), and redeliveries are audited. +- 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. ## Webhooks @@ -235,7 +237,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. @@ -267,18 +270,22 @@ the database together, is out of scope. - 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; the public `/ready` says only whether + the instance is ready. ## 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 From acf17089e67083827a7346c37658935031dff9d7 Mon Sep 17 00:00:00 2001 From: SIIR3X <125802911+SIIR3X@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:12:05 +0200 Subject: [PATCH 18/55] chore(release): 2.1.0 --- CHANGELOG.md | 8 ++++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- docs/dev/api/openapi.yaml | 2 +- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a826503..7c901fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ 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. +Some fixes refuse what was unsafe to accept, as the versioning policy allows +for security fixes: each such change is listed under **Security**. Migrations +0025 to 0028 are additive; read **Upgrading** before deploying, in particular +migration 0028 and the settings now refused at start-up. + ### Security - Tokens delegated to a client application (another client than the diff --git a/Cargo.lock b/Cargo.lock index 79c10dd..56da1fb 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", diff --git a/Cargo.toml b/Cargo.toml index 51da843..0f9adac 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 diff --git a/docs/dev/api/openapi.yaml b/docs/dev/api/openapi.yaml index 9803926..d9aa538 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 From 05c0ecc355c5f51c265038a217a3df0343b9ae2e Mon Sep 17 00:00:00 2001 From: SIIR3X <125802911+SIIR3X@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:24:12 +0200 Subject: [PATCH 19/55] refactor(db): define each table whole in the migration that creates it --- CHANGELOG.md | 31 +++++----- deploy/db/auth-api-grants.sql | 2 +- docs/deploy/database/deployment.md | 23 +------- docs/deploy/guides/operations.md | 2 +- docs/dev/database/schema.md | 7 ++- migrations/0002_users.sql | 18 +++++- migrations/0003_rbac.sql | 21 ++++++- migrations/0004_registered_clients.sql | 18 +++++- migrations/0005_sessions.sql | 11 +++- migrations/0006_authorization_codes.sql | 5 +- migrations/0008_account_tokens.sql | 28 ++++++++-- migrations/0010_audit_log.sql | 56 ++++++++++++++++--- ...rsonal_data.sql => 0012_personal_data.sql} | 52 ++++++----------- migrations/0012_purge_unverified_accounts.sql | 44 --------------- ...own_devices.sql => 0013_known_devices.sql} | 0 ...7_magic_links.sql => 0014_magic_links.sql} | 2 - migrations/0015_administration.sql | 49 ---------------- ...ns.sql => 0015_personal_access_tokens.sql} | 4 -- migrations/0016_data_export.sql | 2 - .../{0019_webhooks.sql => 0016_webhooks.sql} | 5 -- .../{0023_passkeys.sql => 0017_passkeys.sql} | 3 - ...ities.sql => 0018_external_identities.sql} | 3 - migrations/0020_oauth.sql | 9 --- migrations/0021_client_credentials.sql | 8 --- migrations/0022_openid_connect.sql | 5 -- migrations/0025_pending_registrations.sql | 25 --------- migrations/0026_session_second_factor.sql | 6 -- migrations/0027_runtime_role.sql | 23 -------- .../0028_case_insensitive_usernames.sql | 14 ----- migrations/SHA256SUMS | 38 +++++-------- src/services/auth/register.rs | 1 - src/services/user.rs | 8 +-- tests/integration/schema/users/constraints.rs | 2 +- 33 files changed, 195 insertions(+), 330 deletions(-) rename migrations/{0013_personal_data.sql => 0012_personal_data.sql} (71%) delete mode 100644 migrations/0012_purge_unverified_accounts.sql rename migrations/{0014_known_devices.sql => 0013_known_devices.sql} (100%) rename migrations/{0017_magic_links.sql => 0014_magic_links.sql} (95%) delete mode 100644 migrations/0015_administration.sql rename migrations/{0018_personal_access_tokens.sql => 0015_personal_access_tokens.sql} (84%) delete mode 100644 migrations/0016_data_export.sql rename migrations/{0019_webhooks.sql => 0016_webhooks.sql} (91%) rename migrations/{0023_passkeys.sql => 0017_passkeys.sql} (89%) rename migrations/{0024_external_identities.sql => 0018_external_identities.sql} (84%) delete mode 100644 migrations/0020_oauth.sql delete mode 100644 migrations/0021_client_credentials.sql delete mode 100644 migrations/0022_openid_connect.sql delete mode 100644 migrations/0025_pending_registrations.sql delete mode 100644 migrations/0026_session_second_factor.sql delete mode 100644 migrations/0027_runtime_role.sql delete mode 100644 migrations/0028_case_insensitive_usernames.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c901fd..766bfe9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,9 +9,10 @@ as described in the [versioning policy](docs/dev/guides/versioning.md). Security release: fixes every finding of the security audit of 2026-09-26. Some fixes refuse what was unsafe to accept, as the versioning policy allows -for security fixes: each such change is listed under **Security**. Migrations -0025 to 0028 are additive; read **Upgrading** before deploying, in particular -migration 0028 and the settings now refused at start-up. +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 @@ -31,7 +32,7 @@ migration 0028 and the settings now refused at start-up. 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 (migration 0025). + 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 @@ -43,8 +44,8 @@ migration 0028 and the settings now refused at start-up. 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 - (migration 0026); an enrolled factor is no longer enough. An administrator + 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 @@ -64,7 +65,7 @@ migration 0028 and the settings now refused at start-up. 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 (migration 0027). + 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. @@ -89,12 +90,12 @@ migration 0028 and the settings now refused at start-up. - Administrators signed in before the upgrade sign in again with their second factor: sessions opened earlier carry no proof of it. -- Recommended: move the database to two roles (database deployment guide, - section 2.6): create `auth_api_owner`, `REASSIGN OWNED BY auth_api`, run - `deploy/db/auth-api-grants.sql`, then run migrations with the owner's URL - (`prod/auth-api/database-owner-url`). A single-role deployment keeps working. -- Migration 0028 stops if two usernames differ only in case; its message gives - the query that lists them. Rename all but one of each, then migrate again. +- 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. - Run `auth-api --rotate-totp-keys` once, without `PREVIOUS_ENCRYPTION_KEY`, to @@ -476,8 +477,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/deploy/db/auth-api-grants.sql b/deploy/db/auth-api-grants.sql index ec0022c..8ba0a5b 100644 --- a/deploy/db/auth-api-grants.sql +++ b/deploy/db/auth-api-grants.sql @@ -23,7 +23,7 @@ ALTER DEFAULT PRIVILEGES FOR ROLE auth_api_owner IN SCHEMA public GRANT EXECUTE ON FUNCTIONS TO auth_api; -- The audit log is append-only for the application: rows are rewritten only --- by the functions of migration 0027, which run as the owner, and removed 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 $$ diff --git a/docs/deploy/database/deployment.md b/docs/deploy/database/deployment.md index 1429b73..2f53a1c 100644 --- a/docs/deploy/database/deployment.md +++ b/docs/deploy/database/deployment.md @@ -232,30 +232,9 @@ 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. -A deployment where a single `auth_api` role owns the schema keeps working, but -without these limits; section 2.6 moves it to two roles. - ---- - -### 2.6 Move a single-role deployment to two roles - -For a database created before 2.1.0, owned by `auth_api`. **On the DB VPS**, -with the API running (ownership changes do not block it): - -```sql --- sudo -u postgres psql -d auth_api -CREATE ROLE auth_api_owner LOGIN PASSWORD 'owner-strong-password'; -REASSIGN OWNED BY auth_api TO auth_api_owner; -ALTER DATABASE auth_api OWNER TO auth_api_owner; -``` - -Then run `deploy/db/auth-api-grants.sql` as in section 2.5, add the -`auth_api_owner` line to `pg_hba.conf`, and run later migrations with the owner -URL. - --- -### 2.7 Size PostgreSQL's memory +### 2.6 Size PostgreSQL's memory Reads barely notice the number of accounts. Writes do, once the indexes they update no longer fit in memory: at 1 million accounts the sign-in transaction diff --git a/docs/deploy/guides/operations.md b/docs/deploy/guides/operations.md index 9b87631..9989e9b 100644 --- a/docs/deploy/guides/operations.md +++ b/docs/deploy/guides/operations.md @@ -330,7 +330,7 @@ Measured in [the performance campaign](../../perf/performance-report.md) | `ARGON2_MAX_CONCURRENCY` | The CPU limit of the instance | | Memory per instance | 64 MiB x Argon2 concurrency + 256 MiB, rounded up to a multiple of 128 MiB; reservation half of it | | `DB_MAX_CONNECTIONS`, `REDIS_POOL_SIZE` | 4 x the cores of the instance, at least 8. The sum over every instance, plus 10, stays under PostgreSQL's `max_connections` | -| PostgreSQL memory | Twice the indexes that sign-ins and refreshes update, about 4 KB per account (see [Database Deployment](../database/deployment.md#27-size-postgresqls-memory)) | +| PostgreSQL memory | Twice the indexes that sign-ins and refreshes update, about 4 KB per account (see [Database Deployment](../database/deployment.md#26-size-postgresqls-memory)) | Validated with `make sizing` ([perf/README.md](../../../perf/README.md#sizing-validation)) on 2026-09-15: the production image under each profile's CPU quota and memory diff --git a/docs/dev/database/schema.md b/docs/dev/database/schema.md index 84b3337..0772e37 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 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..6d3d672 100644 --- a/migrations/0004_registered_clients.sql +++ b/migrations/0004_registered_clients.sql @@ -10,7 +10,13 @@ -- - 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. -- user_client_quotas: per-user override of a client's session limit. CREATE TABLE registered_clients ( client_id VARCHAR(100) PRIMARY KEY, @@ -20,10 +26,18 @@ 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, 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..63bd199 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,10 @@ 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, CONSTRAINT sessions_token_hash_key UNIQUE (token_hash), CONSTRAINT sessions_expires_after_creation CHECK (expires_at > created_at), @@ -74,6 +80,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..783bdfb 100644 --- a/migrations/0006_authorization_codes.sql +++ b/migrations/0006_authorization_codes.sql @@ -15,6 +15,8 @@ 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, -- 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 +24,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..5d05dea 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. A password +-- reset keeps at most one unused token per user. 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,8 +51,6 @@ 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); CREATE OR REPLACE FUNCTION cleanup_expired_email_verification_tokens( diff --git a/migrations/0010_audit_log.sql b/migrations/0010_audit_log.sql index 8bb3f24..db8f68e 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,29 @@ 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', + -- Administrative changes. + 'account_unlocked', + 'password_reset_forced', + '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 ( @@ -106,24 +134,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 +175,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/0013_personal_data.sql b/migrations/0012_personal_data.sql similarity index 71% rename from migrations/0013_personal_data.sql rename to migrations/0012_personal_data.sql index 9fb9052..61ee33b 100644 --- a/migrations/0013_personal_data.sql +++ b/migrations/0012_personal_data.sql @@ -1,34 +1,7 @@ --- Personal data: what a deleted account leaves behind, and how long the audit --- log keeps full client addresses. - --- 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; +-- 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. -- 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 @@ -54,9 +27,14 @@ BEGIN AND attempted_identifier IN (identity.email, identity.username::CITEXT); END IF; END; -$$ LANGUAGE plpgsql; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public, pg_temp; + +REVOKE EXECUTE ON FUNCTION forget_account_traces(UUID) FROM PUBLIC; --- Purge of never-verified accounts (0012), forgetting their traces too. +-- 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 @@ -94,7 +72,9 @@ BEGIN GET DIAGNOSTICS purged = ROW_COUNT; 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. @@ -131,4 +111,6 @@ BEGIN 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 91% rename from migrations/0019_webhooks.sql rename to migrations/0016_webhooks.sql index 410c838..31d075e 100644 --- a/migrations/0019_webhooks.sql +++ b/migrations/0016_webhooks.sql @@ -68,8 +68,3 @@ BEGIN RETURN deleted; END; $$ LANGUAGE plpgsql; - -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'; 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/0025_pending_registrations.sql b/migrations/0025_pending_registrations.sql deleted file mode 100644 index ed9a119..0000000 --- a/migrations/0025_pending_registrations.sql +++ /dev/null @@ -1,25 +0,0 @@ --- 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 no longer lets an attacker --- decide the password the owner will activate. --- --- The links of a pending account therefore coexist until one of them is used: --- a later registration or resend must not revoke the owner's own link. The --- verification revokes the others. -ALTER TABLE email_verification_tokens - ADD COLUMN password_hash TEXT, - ADD COLUMN username VARCHAR(50), - ADD COLUMN preferred_locale VARCHAR(10), - ADD CONSTRAINT email_verification_tokens_credentials_together CHECK ( - (password_hash IS NULL) = (username IS NULL) - AND (password_hash IS NULL) = (preferred_locale IS NULL) - ), - ADD CONSTRAINT email_verification_tokens_username_format CHECK ( - username IS NULL OR username ~ '^[a-zA-Z0-9_]{3,50}$' - ), - ADD CONSTRAINT email_verification_tokens_locale_format CHECK ( - preferred_locale IS NULL OR preferred_locale ~ '^[a-z]{2}(_[A-Z]{2})?$' - ); - -DROP INDEX idx_email_verification_tokens_user_active; diff --git a/migrations/0026_session_second_factor.sql b/migrations/0026_session_second_factor.sql deleted file mode 100644 index b2cfec3..0000000 --- a/migrations/0026_session_second_factor.sql +++ /dev/null @@ -1,6 +0,0 @@ --- Whether the sign-in that started a session proved a second factor (TOTP, --- email code, recovery code, or a passkey with user verification). The --- administration requires it of the session itself: an administrator's --- password alone, or a sign-in link alone, must not open it, whatever factors --- the account has enrolled. Rotations inherit it. -ALTER TABLE sessions ADD COLUMN mfa BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/migrations/0027_runtime_role.sql b/migrations/0027_runtime_role.sql deleted file mode 100644 index 37ddfc9..0000000 --- a/migrations/0027_runtime_role.sql +++ /dev/null @@ -1,23 +0,0 @@ --- Functions the application calls for work it may not do by itself once the --- schema belongs to a separate owner role (see deploy/db/auth-api-grants.sql): --- creating and dropping audit partitions, rewriting audit rows when an address --- is coarsened or an account forgotten, and purging accounts never verified. --- They run with the privileges of their owner, with a fixed search path. --- --- A deployment where one role owns and uses the schema is unchanged: the --- owner runs its own functions. -ALTER FUNCTION rotate_audit_log_partitions(INTEGER, INTEGER) - SECURITY DEFINER SET search_path = public, pg_temp; -ALTER FUNCTION coarsen_audit_addresses(INTERVAL, INTEGER) - SECURITY DEFINER SET search_path = public, pg_temp; -ALTER FUNCTION forget_account_traces(UUID) - SECURITY DEFINER SET search_path = public, pg_temp; -ALTER FUNCTION purge_unverified_accounts(INTERVAL, INTEGER) - SECURITY DEFINER SET search_path = public, pg_temp; - --- A function running with its owner's privileges is callable only by roles --- the grants name, not by every role of the cluster. -REVOKE EXECUTE ON FUNCTION rotate_audit_log_partitions(INTEGER, INTEGER) FROM PUBLIC; -REVOKE EXECUTE ON FUNCTION coarsen_audit_addresses(INTERVAL, INTEGER) FROM PUBLIC; -REVOKE EXECUTE ON FUNCTION forget_account_traces(UUID) FROM PUBLIC; -REVOKE EXECUTE ON FUNCTION purge_unverified_accounts(INTERVAL, INTEGER) FROM PUBLIC; diff --git a/migrations/0028_case_insensitive_usernames.sql b/migrations/0028_case_insensitive_usernames.sql deleted file mode 100644 index 297b9cc..0000000 --- a/migrations/0028_case_insensitive_usernames.sql +++ /dev/null @@ -1,14 +0,0 @@ --- Usernames are unique whatever their case: `Alice` and `alice` no longer --- coexist, one impersonating the other. The sign-in lookup and the brute-force --- counters already treat them as one identifier. -DO $$ -BEGIN - IF EXISTS ( - SELECT 1 FROM users GROUP BY lower(username) HAVING count(*) > 1 - ) THEN - RAISE EXCEPTION 'usernames differing only in case exist; rename all but one of each before migrating. List them with: SELECT lower(username), array_agg(username) FROM users GROUP BY 1 HAVING count(*) > 1'; - END IF; -END -$$; - -CREATE UNIQUE INDEX users_username_lower_key ON users (lower(username)); diff --git a/migrations/SHA256SUMS b/migrations/SHA256SUMS index 967f46f..ce7522d 100644 --- a/migrations/SHA256SUMS +++ b/migrations/SHA256SUMS @@ -1,28 +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 +b5c48c512a330e12b1fdc5a648f7b82e079a9e02285c39b41d1a38894fbc1d58 0004_registered_clients.sql +f49cf1363738d98e0faab782cf8db3907097da2897ef60aec0969d0ea41bc47f 0005_sessions.sql +18981b163c5fef8eaa8ec9c8df12b11c10464c4794bf4fbcb875e7a0f304005e 0006_authorization_codes.sql 9a77f658341cc377e103c9f95687816d35e4eb8d30d04b1e79f48364ae8296c6 0007_two_factor.sql -e0eb86b8960dad4b71c3cd2b451ef248ab72cc51bc9be7a7264deeb676f7953e 0008_account_tokens.sql +3e94ecb956ad6cd865df8f6f719e8d95a4cadcd9d9a11ff1f5b9110d43c81007 0008_account_tokens.sql 6f3bb804844530fdc7e3bcf3dceefb1080c3b033a0f2a2f38c3fbcde2d943a12 0009_login_attempts.sql -93ee032de3ecb20f74e90f74961c605715c97e14eceb4965857665f2dff55a4b 0010_audit_log.sql +84918e9b9f382af46704a085ecdd3e3fa38fac195244b4375efd7d3504b27153 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 -8c16772a3f7f23ebdb38fed5414e59799df768e930ec14972d37dfa69c98b11d 0025_pending_registrations.sql -abd34b501669ad72e0b8ce5ca2b2966e354448eb4b064e0b7eca1b465515f458 0026_session_second_factor.sql -524db32bbc3476a82eed8a91d416dd8ed3c617ffb5e6283b10baa349ed837b35 0027_runtime_role.sql -b9e17ad7ce9512f38318f6818b9149d356e9be1058a07a2c201068b6b6bf1173 0028_case_insensitive_usernames.sql +4658e31a2d145019451c6e448f2c2ca2162ae318429ebc73784ba97acadcb499 0012_personal_data.sql +f6434d27992410fe4844cf77d826695d2c064bf9f7cc2533d6ad43982493c011 0013_known_devices.sql +b4d5cc42ee28d7ed7f91888176b18a5d9e3f195fe15f131896f4a128b34a997a 0014_magic_links.sql +84077e0347d23ace25d6cc07e19d83dabd2652fdf33794de759765730b5fa6f0 0015_personal_access_tokens.sql +b55a0e317c8ec1bbc2bba1174096e05729cb67a1116c1663f03c89cfcf89332d 0016_webhooks.sql +5d16ddb1943b82853cafc0c56e2ced5191c63bb0c87f347f81ead2c5d8d09c7c 0017_passkeys.sql +bcaa0d3ba0a0e015dbf9162eb50a7fa1426b3c31a994534b97f6dd78271db0e3 0018_external_identities.sql diff --git a/src/services/auth/register.rs b/src/services/auth/register.rs index 7eeae0a..f03dc21 100644 --- a/src/services/auth/register.rs +++ b/src/services/auth/register.rs @@ -130,7 +130,6 @@ async fn register_account( e, &[ ("users_email_key", "email_taken"), - ("users_username_key", "username_taken"), ("users_username_lower_key", "username_taken"), ], ) { diff --git a/src/services/user.rs b/src/services/user.rs index 616a74f..3e64c89 100644 --- a/src/services/user.rs +++ b/src/services/user.rs @@ -145,13 +145,7 @@ pub async fn change_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"), - ("users_username_lower_key", "username_taken"), - ], - ) + AppError::from_unique_violation(e, &[("users_username_lower_key", "username_taken")]) })?; audit::append( diff --git a/tests/integration/schema/users/constraints.rs b/tests/integration/schema/users/constraints.rs index 1d3f2f8..b1bd323 100644 --- a/tests/integration/schema/users/constraints.rs +++ b/tests/integration/schema/users/constraints.rs @@ -104,7 +104,7 @@ async fn users_enforce_unique_username() { .await .expect_err("duplicate username should fail"); - assert_constraint_error(&err, "users_username_key"); + assert_constraint_error(&err, "users_username_lower_key"); } #[tokio::test] From ee6b951814334b1156715fa976c568aaa4c8a052 Mon Sep 17 00:00:00 2001 From: SIIR3X <125802911+SIIR3X@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:36:18 +0200 Subject: [PATCH 20/55] fix(register): verify an address only with the password of the registration that sent the link --- CHANGELOG.md | 6 ++ docs/dev/api/openapi.yaml | 8 +- docs/dev/api/routes.md | 2 +- docs/dev/security-model.md | 12 ++- src/handlers/auth.rs | 7 +- src/repositories/token.rs | 26 ----- src/services/auth/register.rs | 37 +++++-- templates/emails/en/verification.html | 1 + templates/emails/fr/verification.html | 1 + tests/integration/api/auth/password_reset.rs | 15 +-- tests/security/logs.rs | 2 +- .../security/regressions/account_hardening.rs | 5 +- tests/security/regressions/pre_hijacking.rs | 101 +++++++++++++----- tests/simulation/redis_outage.rs | 2 +- 14 files changed, 148 insertions(+), 77 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 766bfe9..e6e8f4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,12 @@ from scratch (read **Upgrading**). ### Security +- `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 diff --git a/docs/dev/api/openapi.yaml b/docs/dev/api/openapi.yaml index d9aa538..b253001 100644 --- a/docs/dev/api/openapi.yaml +++ b/docs/dev/api/openapi.yaml @@ -2975,7 +2975,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: @@ -7574,7 +7574,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 51ad061..8acaa4c 100644 --- a/docs/dev/api/routes.md +++ b/docs/dev/api/routes.md @@ -48,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 | diff --git a/docs/dev/security-model.md b/docs/dev/security-model.md index cd6aef3..86ce7bd 100644 --- a/docs/dev/security-model.md +++ b/docs/dev/security-model.md @@ -43,10 +43,12 @@ the database together, is out of scope. 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, and the link applies - them when it verifies the account. Whoever registered the address first does - not decide the password its owner activates. Links of a pending account - coexist (a resend repeats the latest registration) until one verifies it. + 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`. @@ -359,7 +361,7 @@ when a cited test no longer exists. | 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`, `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 registration on a pending address carries its own credentials in its link: registering someone's address first does not choose the password they activate | `the_owner_activates_the_account_with_the_password_they_chose`, `a_resend_repeats_the_latest_registration_not_the_first`, `resent_links_coexist_until_one_verifies_the_account`, `email_verification_tokens_carry_complete_credentials_or_none` | +| 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` | diff --git a/src/handlers/auth.rs b/src/handlers/auth.rs index 8ce95d7..3c449a9 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)] @@ -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) } diff --git a/src/repositories/token.rs b/src/repositories/token.rs index a8cf69f..2c6e662 100644 --- a/src/repositories/token.rs +++ b/src/repositories/token.rs @@ -61,32 +61,6 @@ pub async fn create_verification<'e>( .await } -/// Credentials of the most recent live link of a pending account, so a -/// resend repeats what its latest registration chose rather than falling back -/// to the credentials of whoever registered the address first. -pub async fn latest_active_credentials<'e>( - executor: impl PgExecutor<'e>, - user_id: Uuid, -) -> Result, sqlx::Error> { - let row: Option<(String, String, String)> = sqlx::query_as( - "SELECT password_hash, username, preferred_locale FROM email_verification_tokens - WHERE user_id = $1 AND used_at IS NULL AND expires_at > NOW() - AND password_hash IS NOT NULL - ORDER BY created_at DESC - LIMIT 1", - ) - .bind(user_id) - .fetch_optional(executor) - .await?; - Ok(row.map( - |(password_hash, username, preferred_locale)| PendingCredentials { - password_hash, - username, - preferred_locale, - }, - )) -} - pub async fn find_verification_by_hash( pool: &PgPool, token_hash: &[u8], diff --git a/src/services/auth/register.rs b/src/services/auth/register.rs index f03dc21..46bbf3e 100644 --- a/src/services/auth/register.rs +++ b/src/services/auth/register.rs @@ -67,10 +67,10 @@ async fn register_account( if let Some(existing) = user_repo::find_by_email(&state.db, email).await? { // A pending account belongs to nobody yet: this registration gets its - // own link, carrying the credentials it chose. Whoever clicks it - // activates the account with them, so an attacker who registered the - // address first does not pick the owner's password. An active account - // is told of the attempt. + // 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 { let credentials = crate::domain::token::PendingCredentials { password_hash: hash, @@ -245,7 +245,8 @@ pub async fn resend_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 repeats those of the latest live link. Earlier links stay +/// 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. async fn issue_verification( @@ -272,10 +273,6 @@ async fn issue_verification( let hash_bytes = crypto::sha256(raw_token.as_bytes()); let mut tx = state.db.begin().await?; - let credentials = match credentials { - Some(credentials) => Some(credentials), - None => token::latest_active_credentials(&mut *tx, user.id).await?, - }; token::create_verification( &mut *tx, &NewEmailVerificationToken { @@ -333,9 +330,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> { @@ -345,6 +347,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?; diff --git a/templates/emails/en/verification.html b/templates/emails/en/verification.html index 50c2d4f..b8e0155 100644 --- a/templates/emails/en/verification.html +++ b/templates/emails/en/verification.html @@ -3,5 +3,6 @@

Hello {{ username }},

Please verify your email: {{ verification_url }}

+

You will be asked for the password you chose when you registered. If you did not register, ignore this email: the link does nothing without that password.

diff --git a/templates/emails/fr/verification.html b/templates/emails/fr/verification.html index 07da82d..eb0368c 100644 --- a/templates/emails/fr/verification.html +++ b/templates/emails/fr/verification.html @@ -5,5 +5,6 @@

Veuillez vérifier votre adresse email en cliquant sur le lien ci-dessous :

{{ verification_url }}

Ce lien expire dans 24 heures.

+

Le mot de passe choisi à l'inscription vous sera demandé. Si vous n'êtes pas à l'origine de cette inscription, ignorez cet email : le lien ne fait rien sans ce mot de passe.

diff --git a/tests/integration/api/auth/password_reset.rs b/tests/integration/api/auth/password_reset.rs index bfc5ccb..3c04a04 100644 --- a/tests/integration/api/auth/password_reset.rs +++ b/tests/integration/api/auth/password_reset.rs @@ -273,7 +273,7 @@ async fn verify_email_activates_unverified_account() { let res = app .post( "/auth/verify-email", - &serde_json::json!({ "token": token.raw }), + &serde_json::json!({ "token": token.raw, "password": user.password }), ) .await; assert_eq!(res.status().as_u16(), 200); @@ -299,7 +299,7 @@ async fn verify_email_with_invalid_token_rejected() { let res = app .post( "/auth/verify-email", - &serde_json::json!({ "token": uuid::Uuid::new_v4().to_string() }), + &serde_json::json!({ "token": uuid::Uuid::new_v4().to_string(), "password": "Any-Password-1!" }), ) .await; @@ -318,7 +318,7 @@ async fn verify_email_with_already_used_token_rejected() { let first = app .post( "/auth/verify-email", - &serde_json::json!({ "token": token.raw }), + &serde_json::json!({ "token": token.raw, "password": user.password }), ) .await; assert_eq!(first.status().as_u16(), 200); @@ -334,7 +334,7 @@ async fn verify_email_with_already_used_token_rejected() { let second = app .post( "/auth/verify-email", - &serde_json::json!({ "token": token.raw }), + &serde_json::json!({ "token": token.raw, "password": user.password }), ) .await; assert_eq!(second.status().as_u16(), 401); @@ -380,7 +380,10 @@ async fn resent_links_coexist_until_one_verifies_the_account() { // A resend must not revoke the link its owner is about to click: someone // else can ask for one, knowing only the address. let used = app - .post("/auth/verify-email", &serde_json::json!({ "token": first })) + .post( + "/auth/verify-email", + &serde_json::json!({ "token": first, "password": user.password }), + ) .await; assert_eq!(used.status().as_u16(), 200, "the first link still works"); @@ -388,7 +391,7 @@ async fn resent_links_coexist_until_one_verifies_the_account() { let other = app .post( "/auth/verify-email", - &serde_json::json!({ "token": second }), + &serde_json::json!({ "token": second, "password": user.password }), ) .await; assert_eq!( diff --git a/tests/security/logs.rs b/tests/security/logs.rs index 3df3cc9..2840bb1 100644 --- a/tests/security/logs.rs +++ b/tests/security/logs.rs @@ -62,7 +62,7 @@ async fn account_flows_never_log_their_secrets() { let verified = app .post( "/auth/verify-email", - &json!({ "token": verification_token }), + &json!({ "token": verification_token, "password": user.password }), ) .await; assert!(verified.status().is_success(), "{}", verified.status()); diff --git a/tests/security/regressions/account_hardening.rs b/tests/security/regressions/account_hardening.rs index 7d0e4bd..c7d75bd 100644 --- a/tests/security/regressions/account_hardening.rs +++ b/tests/security/regressions/account_hardening.rs @@ -166,7 +166,10 @@ async fn a_one_time_token_submission_can_be_retried() { for _ in 0..2 { let res = app - .post("/auth/verify-email", &json!({ "token": token })) + .post( + "/auth/verify-email", + &json!({ "token": token, "password": "Any-Password-1!" }), + ) .await; assert_eq!(res.status().as_u16(), 401, "a retry is not rate limited"); } diff --git a/tests/security/regressions/pre_hijacking.rs b/tests/security/regressions/pre_hijacking.rs index 8c4351a..5638e18 100644 --- a/tests/security/regressions/pre_hijacking.rs +++ b/tests/security/regressions/pre_hijacking.rs @@ -1,9 +1,12 @@ -//! Account pre-hijacking (SEC-43): registering someone's address first must -//! not let an attacker choose the password the owner will activate. +//! Account pre-hijacking (SEC-43): whoever registers a pending address, first +//! or second, cannot activate the account with a password its owner did not +//! choose. //! -//! Before the control, a registration on a pending address re-sent the link of -//! the existing account and dropped the password the owner had just chosen: -//! clicking it activated the account with the attacker's password. +//! A verification link proves the mailbox; the password proves which +//! registration it activates. Two regressions are pinned: a registration on a +//! pending address re-sending the first registrant's link (the attacker came +//! first), and a later registration's link carrying the attacker's password to +//! the owner's mailbox (the attacker came second). use serde_json::{Value, json}; @@ -25,6 +28,23 @@ async fn register(app: &TestApp, username: &str, password: &str) -> Value { response.json().await.unwrap() } +/// The token of the `n`-th verification e-mail (1-based) sent to the victim. +async fn link(app: &TestApp, n: usize) -> String { + let mails = app.mail.wait_for_count(VICTIM, n).await; + mails[n - 1].value_after("token=").unwrap() +} + +async fn verify(app: &TestApp, token: &str, password: &str) -> (u16, Value) { + let response = app + .post( + "/auth/verify-email", + &json!({ "token": token, "password": password }), + ) + .await; + let status = response.status().as_u16(); + (status, response.json().await.unwrap_or(Value::Null)) +} + async fn login_status(app: &TestApp, password: &str) -> u16 { app.post( "/auth/login", @@ -36,28 +56,30 @@ async fn login_status(app: &TestApp, password: &str) -> u16 { } #[tokio::test] -async fn the_owner_activates_the_account_with_the_password_they_chose() { +async fn an_attacker_registering_first_cannot_pick_the_owners_password() { let app = TestApp::spawn().await; let squatted = register(&app, "squatter", ATTACKER_PASSWORD).await; app.mail.wait_for(VICTIM, SUBJECT).await; - let owned = register(&app, "rightful_owner", OWNER_PASSWORD).await; assert_eq!( squatted, owned, "the second registration looks like a first" ); + // The squatter's link, clicked by the owner, needs the squatter's password. + let (status, body) = verify(&app, &link(&app, 1).await, OWNER_PASSWORD).await; + assert_eq!( + (status, body["code"].as_str()), + (401, Some("invalid_credentials")) + ); + + let owner_link = link(&app, 2).await; let mails = app.mail.wait_for_count(VICTIM, 2).await; - let owner_mail = mails.last().unwrap(); assert!( - owner_mail.html.contains("rightful_owner"), - "the link names the account it activates" + mails[1].html.contains("rightful_owner"), + "the link names its account" ); - let token = owner_mail.value_after("token=").unwrap(); - let verified = app - .post("/auth/verify-email", &json!({ "token": token })) - .await; - assert_eq!(verified.status().as_u16(), 200); + assert_eq!(verify(&app, &owner_link, OWNER_PASSWORD).await.0, 200); assert_eq!(login_status(&app, ATTACKER_PASSWORD).await, 401); assert_eq!(login_status(&app, OWNER_PASSWORD).await, 200); @@ -70,24 +92,55 @@ async fn the_owner_activates_the_account_with_the_password_they_chose() { } #[tokio::test] -async fn a_resend_repeats_the_latest_registration_not_the_first() { +async fn an_attacker_registering_second_cannot_slip_their_password_into_a_link() { + let app = TestApp::spawn().await; + register(&app, "rightful_owner", OWNER_PASSWORD).await; + app.mail.wait_for(VICTIM, SUBJECT).await; + register(&app, "squatter", ATTACKER_PASSWORD).await; + + // The latest link in the owner's mailbox is the attacker's: clicking it + // with the owner's password activates nothing and leaves it unused. + let attacker_link = link(&app, 2).await; + let (status, body) = verify(&app, &attacker_link, OWNER_PASSWORD).await; + assert_eq!( + (status, body["code"].as_str()), + (401, Some("invalid_credentials")) + ); + let pending: String = sqlx::query_scalar("SELECT status::text FROM users WHERE email = $1") + .bind(VICTIM) + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(pending, "pending_verification"); + + assert_eq!( + verify(&app, &link(&app, 1).await, OWNER_PASSWORD).await.0, + 200 + ); + assert_eq!(login_status(&app, ATTACKER_PASSWORD).await, 401); + assert_eq!(login_status(&app, OWNER_PASSWORD).await, 200); +} + +#[tokio::test] +async fn a_resend_never_carries_another_registrations_password() { let app = TestApp::spawn().await; register(&app, "squatter", ATTACKER_PASSWORD).await; register(&app, "rightful_owner", OWNER_PASSWORD).await; app.mail.wait_for_count(VICTIM, 2).await; - // Anyone knowing the address can ask for a resend. + // Anyone knowing the address can ask for a resend: the new link asks for + // the password the account was created with, not the latest one chosen. let resent = app .post("/auth/verify-email/resend", &json!({ "email": VICTIM })) .await; assert_eq!(resent.status().as_u16(), 200); - let mails = app.mail.wait_for_count(VICTIM, 3).await; - let token = mails.last().unwrap().value_after("token=").unwrap(); - let verified = app - .post("/auth/verify-email", &json!({ "token": token })) - .await; - assert_eq!(verified.status().as_u16(), 200); + let resent_link = link(&app, 3).await; + assert_eq!(verify(&app, &resent_link, OWNER_PASSWORD).await.0, 401); - assert_eq!(login_status(&app, ATTACKER_PASSWORD).await, 401); + // The owner's own registration link still works. + assert_eq!( + verify(&app, &link(&app, 2).await, OWNER_PASSWORD).await.0, + 200 + ); assert_eq!(login_status(&app, OWNER_PASSWORD).await, 200); } diff --git a/tests/simulation/redis_outage.rs b/tests/simulation/redis_outage.rs index 166c771..1c8b17f 100644 --- a/tests/simulation/redis_outage.rs +++ b/tests/simulation/redis_outage.rs @@ -73,7 +73,7 @@ async fn email_verification_succeeds_when_redis_is_down() { let res = app .post( "/auth/verify-email", - &serde_json::json!({ "token": token.raw }), + &serde_json::json!({ "token": token.raw, "password": user.password }), ) .await; assert_eq!(res.status().as_u16(), 200); From f888e3971f3def891ee8c33ced5503ea5e2463aa Mon Sep 17 00:00:00 2001 From: SIIR3X <125802911+SIIR3X@users.noreply.github.com> Date: Tue, 22 Sep 2026 01:48:24 +0200 Subject: [PATCH 21/55] fix(auth): bound the lockout to the password and keep recovery out of reach of guessers --- CHANGELOG.md | 17 ++ docs/dev/api/openapi.yaml | 10 +- docs/dev/database/schema.md | 2 +- docs/dev/security-model.md | 23 +- migrations/0008_account_tokens.sql | 7 +- migrations/0009_login_attempts.sql | 3 +- migrations/SHA256SUMS | 4 +- src/domain/login_attempt.rs | 3 + src/handlers/auth.rs | 10 +- src/repositories/login_attempt.rs | 14 +- src/repositories/token.rs | 41 ++-- src/repositories/user.rs | 5 +- src/services/admin/users.rs | 2 +- src/services/auth/guards.rs | 89 +++++-- src/services/auth/login.rs | 65 ++++- src/services/auth/magic_link.rs | 14 +- src/services/auth/mod.rs | 29 ++- src/services/auth/password_reset.rs | 46 ++-- src/services/auth/pre_auth.rs | 11 +- src/services/auth/second_factor.rs | 129 ++++------ src/services/authorize.rs | 2 +- src/services/device.rs | 2 +- src/services/email.rs | 32 +++ src/services/email_2fa.rs | 43 ++-- src/services/external_identity.rs | 2 +- src/services/passkey.rs | 2 +- src/services/personal_access_token.rs | 2 +- templates/emails/en/account_locked.html | 9 + templates/emails/en/account_locked.subject | 1 + templates/emails/fr/account_locked.html | 9 + templates/emails/fr/account_locked.subject | 1 + tests/integration/api/admin/users.rs | 5 +- tests/integration/api/auth/lockout.rs | 9 +- tests/integration/api/auth/magic_link.rs | 26 +- .../schema/tokens/password_reset.rs | 36 ++- .../security/regressions/account_hardening.rs | 4 +- tests/security/regressions/lockout.rs | 226 ++++++++++++++++++ tests/security/regressions/mod.rs | 1 + tests/security/regressions/second_factor.rs | 60 ++++- tests/simulation/clock.rs | 4 +- tests/simulation/redis_outage.rs | 25 ++ 41 files changed, 761 insertions(+), 264 deletions(-) create mode 100644 templates/emails/en/account_locked.html create mode 100644 templates/emails/en/account_locked.subject create mode 100644 templates/emails/fr/account_locked.html create mode 100644 templates/emails/fr/account_locked.subject create mode 100644 tests/security/regressions/lockout.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index e6e8f4e..8979bfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,23 @@ from scratch (read **Upgrading**). ### Security +- 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 diff --git a/docs/dev/api/openapi.yaml b/docs/dev/api/openapi.yaml index b253001..d25cd52 100644 --- a/docs/dev/api/openapi.yaml +++ b/docs/dev/api/openapi.yaml @@ -2171,7 +2171,7 @@ paths: schema: $ref: '#/components/schemas/ErrorBody' '403': - description: Account locked, suspended or not verified + description: Account suspended, inactive or not verified; a locked password answers 401 like a wrong one content: application/json: schema: @@ -2321,7 +2321,7 @@ paths: schema: $ref: '#/components/schemas/ErrorBody' '403': - description: Account locked, suspended or inactive + description: Account suspended or inactive content: application/json: schema: @@ -2731,7 +2731,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: @@ -2797,7 +2797,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: @@ -2919,7 +2919,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: diff --git a/docs/dev/database/schema.md b/docs/dev/database/schema.md index 0772e37..da6ffb6 100644 --- a/docs/dev/database/schema.md +++ b/docs/dev/database/schema.md @@ -19,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 | diff --git a/docs/dev/security-model.md b/docs/dev/security-model.md index 86ce7bd..4a91654 100644 --- a/docs/dev/security-model.md +++ b/docs/dev/security-model.md @@ -28,7 +28,8 @@ the database together, is out of scope. memory. A stored hash weaker than the configured parameters is replaced after the next successful sign-in. - **No account oracle.** An unknown identifier still pays a full hash against a - decoy. A locked account answers the same whatever the password. Registration + 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 @@ -61,9 +62,20 @@ the database together, is out of scope. 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 pending account taken back by a reset loses all of them. -- **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. +- **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 wider ceiling for the account, and a reset restarts + them. - **Brute force** is bounded per identifier and per address (database counters), across identifiers from one address (HyperLogLog), and per submitted token. Budgets are consumed atomically in Redis before the guarded check runs, so @@ -351,7 +363,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` | @@ -376,3 +388,4 @@ when a cited test no longer exists. | 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` | diff --git a/migrations/0008_account_tokens.sql b/migrations/0008_account_tokens.sql index 5d05dea..cc17358 100644 --- a/migrations/0008_account_tokens.sql +++ b/migrations/0008_account_tokens.sql @@ -8,8 +8,8 @@ -- 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. A password --- reset keeps at most one unused token per user. +-- 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, @@ -94,7 +94,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/SHA256SUMS b/migrations/SHA256SUMS index ce7522d..f97d249 100644 --- a/migrations/SHA256SUMS +++ b/migrations/SHA256SUMS @@ -5,8 +5,8 @@ b5c48c512a330e12b1fdc5a648f7b82e079a9e02285c39b41d1a38894fbc1d58 0004_registere f49cf1363738d98e0faab782cf8db3907097da2897ef60aec0969d0ea41bc47f 0005_sessions.sql 18981b163c5fef8eaa8ec9c8df12b11c10464c4794bf4fbcb875e7a0f304005e 0006_authorization_codes.sql 9a77f658341cc377e103c9f95687816d35e4eb8d30d04b1e79f48364ae8296c6 0007_two_factor.sql -3e94ecb956ad6cd865df8f6f719e8d95a4cadcd9d9a11ff1f5b9110d43c81007 0008_account_tokens.sql -6f3bb804844530fdc7e3bcf3dceefb1080c3b033a0f2a2f38c3fbcde2d943a12 0009_login_attempts.sql +c155c5d85738ffc9b08a879001e05875fc4b263afde85b56f32f96f5abf375a5 0008_account_tokens.sql +6c37703c71c8892899764c86bf00b86f09afd362ac17262d4b27045643c316e8 0009_login_attempts.sql 84918e9b9f382af46704a085ecdd3e3fa38fac195244b4375efd7d3504b27153 0010_audit_log.sql 76b27021b2b3e978ee4222edf821fbda8d78f39693fdd446279dc4d4d61377dc 0011_event_outbox.sql 4658e31a2d145019451c6e448f2c2ca2162ae318429ebc73784ba97acadcb499 0012_personal_data.sql diff --git a/src/domain/login_attempt.rs b/src/domain/login_attempt.rs index 93985b4..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)] diff --git a/src/handlers/auth.rs b/src/handlers/auth.rs index 3c449a9..baefb31 100644 --- a/src/handlers/auth.rs +++ b/src/handlers/auth.rs @@ -199,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, inactive or not verified; a locked password answers 401 like a wrong one", body = crate::error::ErrorBody), (status = 422, description = "Invalid input", body = crate::error::ErrorBody), (status = 429, description = "Rate limited; see Retry-After"), ), @@ -391,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"), ), @@ -470,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"), ), )] @@ -506,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"), ), )] @@ -541,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"), ), )] diff --git a/src/repositories/login_attempt.rs b/src/repositories/login_attempt.rs index dcb686b..9e56daa 100644 --- a/src/repositories/login_attempt.rs +++ b/src/repositories/login_attempt.rs @@ -27,10 +27,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 +43,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/token.rs b/src/repositories/token.rs index 2c6e662..070add4 100644 --- a/src/repositories/token.rs +++ b/src/repositories/token.rs @@ -197,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], @@ -206,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) @@ -223,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( @@ -238,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/user.rs b/src/repositories/user.rs index 329a16d..8dbe4ff 100644 --- a/src/repositories/user.rs +++ b/src/repositories/user.rs @@ -100,12 +100,13 @@ pub async fn set_locked_until( Ok(()) } -/// 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?; diff --git a/src/services/admin/users.rs b/src/services/admin/users.rs index 0836a98..cb0f9a0 100644 --- a/src/services/admin/users.rs +++ b/src/services/admin/users.rs @@ -218,7 +218,7 @@ pub async fn force_password_reset( events::wake(); forget_sessions(state, &active).await; - auth_svc::send_reset_link(state, &user, actor.ip, None).await + auth_svc::send_reset_link(state, &user, actor.ip, None, true).await } /// Delete the account like its owner would, after a recent re-authentication of diff --git a/src/services/auth/guards.rs b/src/services/auth/guards.rs index 4d35a7c..acd918a 100644 --- a/src/services/auth/guards.rs +++ b/src/services/auth/guards.rs @@ -6,6 +6,64 @@ 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") +} + +/// 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. 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 +} + +/// The budget of links mailed to `user_id` (`prefix` names the kind): per +/// client address, then for the account as a whole. +pub(super) async fn mailbox_budget_exhausted( + state: &AppState, + prefix: &str, + user_id: Uuid, + ip: Option, +) -> 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, + MAILBOX_LINK_ACCOUNT_WINDOW_SECS, + ) + .await + { + return true; + } + } + budget_exhausted( + state, + &format!("{prefix}:{user_id}"), + MAX_MAILBOX_LINKS_BY_ACCOUNT, + MAILBOX_LINK_ACCOUNT_WINDOW_SECS, + ) + .await +} + /// Consume one attempt of an abuse-control budget. /// /// Fails open: these budgets bound volume (mail floods, token scanning) rather @@ -175,17 +233,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. @@ -256,17 +310,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 9bcd45d..2f0b2ec 100644 --- a/src/services/auth/login.rs +++ b/src/services/auth/login.rs @@ -103,12 +103,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) } @@ -196,6 +209,7 @@ pub async fn login( { lockout_failed("audit", &e); } + notify_locked(state, &u, locked_until); } metrics::counter!("auth_logins_total", "outcome" => "invalid_credentials").increment(1); @@ -283,14 +297,10 @@ 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: @@ -305,8 +315,16 @@ pub(crate) async fn first_factor_proven( .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).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); @@ -339,6 +357,31 @@ pub(crate) async fn first_factor_proven( Ok(LoginResult::Complete(tokens)) } +/// 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..5ffb792 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).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 60b87ac..799e444 100644 --- a/src/services/auth/mod.rs +++ b/src/services/auth/mod.rs @@ -65,7 +65,9 @@ mod session; mod tokens; use guards::*; -pub(crate) use guards::{ensure_account_usable, ensure_status_allows_sign_in}; +pub(crate) use guards::{ + ensure_account_usable, ensure_status_allows_sign_in, second_factor_budget_keys, +}; pub use login::*; pub use magic_link::*; pub use password_reset::*; @@ -137,7 +139,14 @@ const MAX_TOTP_FAILURES: i64 = 5; const MAX_TOTP_FAILURES_BY_USER: i64 = 20; /// 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 = 5; /// Rolling window of the per-account second-factor budgets (1 hour). const SECOND_FACTOR_USER_WINDOW_SECS: u64 = 3600; @@ -189,11 +198,13 @@ 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; +const MAILBOX_LINK_ACCOUNT_WINDOW_SECS: u64 = 3600; /// Verification e-mail requests per client address (IPv6 /64) per window. const MAX_VERIFICATION_RESENDS_BY_IP: i64 = 5; @@ -211,10 +222,6 @@ 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; diff --git a/src/services/auth/password_reset.rs b/src/services/auth/password_reset.rs index 570b800..31aa254 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).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,6 +166,9 @@ 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?; // Invalidate all active sessions to force re-login with the new password session_repo::revoke_all_by_user(&mut *tx, record.user_id).await?; @@ -232,6 +229,19 @@ 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}"), + ], + ) + .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 { diff --git a/src/services/auth/pre_auth.rs b/src/services/auth/pre_auth.rs index 9bb1abd..e22c777 100644 --- a/src/services/auth/pre_auth.rs +++ b/src/services/auth/pre_auth.rs @@ -7,11 +7,7 @@ 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 } @@ -79,10 +75,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) diff --git a/src/services/auth/second_factor.rs b/src/services/auth/second_factor.rs index e55ba3c..199ba29 100644 --- a/src/services/auth/second_factor.rs +++ b/src/services/auth/second_factor.rs @@ -14,11 +14,7 @@ pub async fn complete_two_factor_login( let redis_key = pre_auth_key(pre_auth_token); let fail_key = format!("{TOTP_FAIL_PREFIX}{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,27 +23,27 @@ 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 { return Err(AppError::RateLimitExceeded); } @@ -57,7 +53,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) @@ -113,7 +109,8 @@ 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: of // concurrent completions, only the one that removes it goes on. @@ -154,11 +151,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)?; @@ -171,9 +164,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; } @@ -218,11 +211,7 @@ pub async fn complete_login_with_recovery( let redis_key = pre_auth_key(pre_auth_token); let fail_key = format!("{RC_FAIL_PREFIX}{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); @@ -234,24 +223,24 @@ 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 { return Err(AppError::RateLimitExceeded); } @@ -261,7 +250,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. @@ -286,14 +275,9 @@ pub async fn complete_login_with_recovery( } // Consume the pre-auth token now that recovery succeeded. - take_pre_auth( - state, - &redis_key, - user_id, - pre_auth_token, - &[&fail_key, &user_fail_key], - ) - .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, @@ -348,15 +332,8 @@ async fn take_pre_auth( pre_auth_token: &str, budgets: &[&str], ) -> Result<(), AppError> { - let mut conn = state - .redis - .get() - .await - .map_err(|e| AppError::Internal(e.into()))?; - let removed: i64 = conn - .del(redis_key) - .await - .map_err(|e| AppError::Internal(e.into()))?; + 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); } diff --git a/src/services/authorize.rs b/src/services/authorize.rs index cae6bbb..dd6c59f 100644 --- a/src/services/authorize.rs +++ b/src/services/authorize.rs @@ -279,7 +279,7 @@ async fn ensure_account_usable(state: &AppState, user_id: Uuid) -> Result<(), Ap .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. diff --git a/src/services/device.rs b/src/services/device.rs index 433d162..c46ffce 100644 --- a/src/services/device.rs +++ b/src/services/device.rs @@ -308,7 +308,7 @@ 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. diff --git a/src/services/email.rs b/src/services/email.rs index c621169..576dfa7 100644 --- a/src/services/email.rs +++ b/src/services/email.rs @@ -28,6 +28,7 @@ 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_TWO_FACTOR_ENABLED: &str = "two_factor_enabled"; const TNAME_ACCOUNT_EXISTS: &str = "account_exists"; const TNAME_EMAIL_CHANGED: &str = "email_changed"; @@ -570,6 +571,37 @@ 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 +} + // Tries locale template first, falls back to default_locale. fn render_with_fallback( templates: &Tera, diff --git a/src/services/email_2fa.rs b/src/services/email_2fa.rs index 627c39e..27eddda 100644 --- a/src/services/email_2fa.rs +++ b/src/services/email_2fa.rs @@ -102,6 +102,7 @@ pub async fn verify_setup( user_id, submitted_code, &format!("email2fa_setup_fail:{}", method_id), + None, ) .await?; @@ -231,9 +232,10 @@ 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 + verify_otp(state, user_id, submitted_code, &fail_key, ip).await } /// Separates the digests of these codes from any other flow's. @@ -246,26 +248,27 @@ async fn verify_otp( user_id: Uuid, 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 { return Err(AppError::RateLimitExceeded); } @@ -292,7 +295,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/external_identity.rs b/src/services/external_identity.rs index 999afbc..77bd265 100644 --- a/src/services/external_identity.rs +++ b/src/services/external_identity.rs @@ -493,7 +493,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, diff --git a/src/services/passkey.rs b/src/services/passkey.rs index 309f3b7..7e9cd62 100644 --- a/src/services/passkey.rs +++ b/src/services/passkey.rs @@ -360,7 +360,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, diff --git a/src/services/personal_access_token.rs b/src/services/personal_access_token.rs index e59d419..5dc0d2e 100644 --- a/src/services/personal_access_token.rs +++ b/src/services/personal_access_token.rs @@ -196,7 +196,7 @@ pub async fn exchange(state: &AppState, presented: &str) -> Result + + +

Hello {{ username }},

+

After several wrong passwords, signing in to your {{ app_name }} account with a password is blocked for {{ minutes }} minutes.

+

Passkeys, sign-in links and linked accounts still work, and resetting your password lifts the block.

+

If these attempts were not yours, someone is trying to guess your password: choose a strong, unique one.

+ + diff --git a/templates/emails/en/account_locked.subject b/templates/emails/en/account_locked.subject new file mode 100644 index 0000000..3391564 --- /dev/null +++ b/templates/emails/en/account_locked.subject @@ -0,0 +1 @@ +Sign-in with your password is blocked diff --git a/templates/emails/fr/account_locked.html b/templates/emails/fr/account_locked.html new file mode 100644 index 0000000..f049784 --- /dev/null +++ b/templates/emails/fr/account_locked.html @@ -0,0 +1,9 @@ + + + +

Bonjour {{ username }},

+

Après plusieurs mots de passe erronés, la connexion par mot de passe à votre compte {{ app_name }} est bloquée pendant {{ minutes }} minutes.

+

Les passkeys, les liens de connexion et les comptes liés fonctionnent toujours, et réinitialiser votre mot de passe lève le blocage.

+

Si ces tentatives ne viennent pas de vous, quelqu'un essaie de deviner votre mot de passe : choisissez-en un robuste et unique.

+ + diff --git a/templates/emails/fr/account_locked.subject b/templates/emails/fr/account_locked.subject new file mode 100644 index 0000000..56d5d15 --- /dev/null +++ b/templates/emails/fr/account_locked.subject @@ -0,0 +1 @@ +Connexion par mot de passe bloquée diff --git a/tests/integration/api/admin/users.rs b/tests/integration/api/admin/users.rs index 47f478e..3421ba8 100644 --- a/tests/integration/api/admin/users.rs +++ b/tests/integration/api/admin/users.rs @@ -295,8 +295,9 @@ async fn unlocking_ends_the_lockout_and_forgives_the_failures() { for _ in 0..3 { sign_in(&app, &target.email, "WrongPassword1!").await; } - let (_, locked) = sign_in(&app, &target.email, &target.password).await; - assert_eq!(locked["code"], "account_locked"); + // A locked password answers like a wrong one. + let (status, _) = sign_in(&app, &target.email, &target.password).await; + assert_eq!(status, 401); let (status, _) = body( app.post_auth( diff --git a/tests/integration/api/auth/lockout.rs b/tests/integration/api/auth/lockout.rs index 5f72cd3..703e3e5 100644 --- a/tests/integration/api/auth/lockout.rs +++ b/tests/integration/api/auth/lockout.rs @@ -22,7 +22,8 @@ async fn account_locked_after_threshold_failures() { assert_eq!(res.status().as_u16(), 401); } - // The next attempt - even with the correct password - should be locked. + // The next attempt - even with the correct password - is refused, and + // answers like a wrong password: the lock reveals no account. let res = app .post( "/auth/login", @@ -35,13 +36,13 @@ async fn account_locked_after_threshold_failures() { assert_eq!( res.status().as_u16(), - 403, - "expected 403 account_locked after threshold, got {}", + 401, + "expected 401 after threshold, got {}", res.status() ); let body: serde_json::Value = res.json().await.unwrap(); - assert_eq!(body["code"], "account_locked"); + assert_eq!(body["code"], "invalid_credentials"); } #[tokio::test] diff --git a/tests/integration/api/auth/magic_link.rs b/tests/integration/api/auth/magic_link.rs index fbb61ce..5663a37 100644 --- a/tests/integration/api/auth/magic_link.rs +++ b/tests/integration/api/auth/magic_link.rs @@ -56,8 +56,10 @@ async fn a_link_signs_in_once() { assert_eq!(method["method"], "magic_link"); } +/// A new request leaves the earlier link usable (someone else asking must not +/// revoke the link the owner is about to click); using one ends the others. #[tokio::test] -async fn a_new_link_replaces_the_previous_one_and_an_old_link_expires() { +async fn links_coexist_until_one_is_used_and_an_old_link_expires() { let app = TestApp::spawn().await; let user = fixtures::register_user(&app, 1).await; fixtures::activate_user(&app.db, user.id).await; @@ -72,13 +74,29 @@ async fn a_new_link_replaces_the_previous_one_and_an_old_link_expires() { .filter_map(|m| m.value_after("#token=")) .find(|t| *t != first) .unwrap(); + request(&app, &user.email).await; + let messages = app.mail.wait_for_count(&user.email, 3).await; + let third = messages + .iter() + .filter(|m| m.subject == SUBJECT) + .filter_map(|m| m.value_after("#token=")) + .find(|t| *t != first && *t != second) + .unwrap(); - assert_eq!(complete(&app, &first).await.0, 401); + assert_eq!( + complete(&app, &first).await.0, + 200, + "the earlier link works" + ); + assert_eq!( + complete(&app, &second).await.0, + 401, + "using one ended the others" + ); app.clock.advance(time::Duration::minutes(16)); - let (status, body) = complete(&app, &second).await; + let (status, body) = complete(&app, &third).await; assert_eq!(status, 401, "{body}"); - assert_eq!(body["code"], "token_expired"); } #[tokio::test] diff --git a/tests/integration/schema/tokens/password_reset.rs b/tests/integration/schema/tokens/password_reset.rs index d8064a0..efc3112 100644 --- a/tests/integration/schema/tokens/password_reset.rs +++ b/tests/integration/schema/tokens/password_reset.rs @@ -50,32 +50,24 @@ async fn password_reset_tokens_enforce_unique_hashes() { assert_constraint_error(&err, "password_reset_tokens_token_hash_key"); } +/// Links of a password reset coexist until one is used: someone asking for a +/// reset must not revoke the link its owner is about to click. #[tokio::test] -async fn password_reset_tokens_allow_only_one_unused_token_per_user() { +async fn several_unused_password_reset_tokens_coexist() { let db = TestDb::new().await; let user_id = insert_user(&db.pool, 6211).await; - sqlx::query( - "INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) - VALUES ($1, $2, NOW() + INTERVAL '1 hour')", - ) - .bind(user_id) - .bind(fixed_hash(38)) - .execute(&db.pool) - .await - .expect("failed to insert first unused password reset token"); - - let err = sqlx::query( - "INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) - VALUES ($1, $2, NOW() + INTERVAL '1 hour')", - ) - .bind(user_id) - .bind(fixed_hash(39)) - .execute(&db.pool) - .await - .expect_err("a second unused password reset token should fail"); - - assert_constraint_error(&err, "idx_password_reset_tokens_user_active"); + for seed in [38, 39] { + sqlx::query( + "INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) + VALUES ($1, $2, NOW() + INTERVAL '1 hour')", + ) + .bind(user_id) + .bind(fixed_hash(seed)) + .execute(&db.pool) + .await + .expect("unused reset links of one account coexist"); + } } #[tokio::test] diff --git a/tests/security/regressions/account_hardening.rs b/tests/security/regressions/account_hardening.rs index c7d75bd..aa8efb1 100644 --- a/tests/security/regressions/account_hardening.rs +++ b/tests/security/regressions/account_hardening.rs @@ -35,9 +35,9 @@ async fn locked_account_answers_the_same_whatever_the_password() { &json!({ "identifier": user.email, "password": password }), ) .await; - assert_eq!(res.status().as_u16(), 403); + assert_eq!(res.status().as_u16(), 401); let body: Value = res.json().await.unwrap(); - assert_eq!(body["code"], "account_locked"); + assert_eq!(body["code"], "invalid_credentials"); } } diff --git a/tests/security/regressions/lockout.rs b/tests/security/regressions/lockout.rs new file mode 100644 index 0000000..ae432b7 --- /dev/null +++ b/tests/security/regressions/lockout.rs @@ -0,0 +1,226 @@ +//! Lockout and account recovery (SEC-58): guessing a password cannot keep its +//! owner out. +//! +//! Before the control, the count of wrong passwords had no time bound and was +//! reset only by a password sign-in: one guess every lock period kept an +//! account locked for good, a lock blocked passkeys and sign-in links too, a +//! reset did not lift it, a locked account answered differently from an +//! unknown one, and anyone asking for reset links spent the owner's budget and +//! revoked the link they were about to click. + +use serde_json::{Value, json}; + +use crate::common::{ + app::TestApp, + fixtures::{self, AuthenticatedUser}, +}; + +const LOCKED_SUBJECT: &str = "Sign-in with your password is blocked"; +const RESET_SUBJECT: &str = "Reset your password"; +const LINK_SUBJECT: &str = "Your sign-in link"; +const WRONG: &str = "Wrong-Password-1!"; + +async fn login(app: &TestApp, identifier: &str, password: &str) -> (u16, Value) { + let response = app + .post( + "/auth/login", + &json!({ "identifier": identifier, "password": password }), + ) + .await; + let status = response.status().as_u16(); + (status, response.json().await.unwrap_or(Value::Null)) +} + +/// The test configuration locks after 3 wrong passwords. +async fn lock(app: &TestApp, user: &AuthenticatedUser) { + for _ in 0..3 { + assert_eq!(login(app, &user.email, WRONG).await.0, 401); + } +} + +async fn post_from(app: &TestApp, ip: &str, path: &str, body: &Value) -> reqwest::Response { + app.client + .post(app.url(path)) + .header("x-forwarded-for", ip) + .json(body) + .send() + .await + .unwrap() +} + +async fn sign_in_by_link(app: &TestApp, user: &AuthenticatedUser, nth: usize) -> u16 { + let res = app + .post("/auth/magic-link", &json!({ "email": user.email })) + .await; + assert_eq!(res.status().as_u16(), 200, "{}", res.text().await.unwrap()); + let mails: Vec<_> = app + .mail + .wait_for_count(&user.email, nth) + .await + .into_iter() + .filter(|mail| mail.subject == LINK_SUBJECT) + .collect(); + let token = mails.last().unwrap().value_after("token=").unwrap(); + app.post("/auth/magic-link/complete", &json!({ "token": token })) + .await + .status() + .as_u16() +} + +#[tokio::test] +async fn a_locked_password_answers_like_a_wrong_one_and_tells_the_owner() { + let app = TestApp::spawn().await; + let user = fixtures::authenticated_user(&app, 800).await; + lock(&app, &user).await; + + let (status, body) = login(&app, &user.email, &user.password).await; + assert_eq!( + (status, body["code"].as_str()), + (401, Some("invalid_credentials")) + ); + let (status, unknown) = login(&app, "nobody-800@example.com", WRONG).await; + assert_eq!((status, &unknown["code"]), (401, &body["code"])); + + let mail = app.mail.wait_for(&user.email, LOCKED_SUBJECT).await; + assert!(mail.html.contains("minutes"), "{}", mail.html); +} + +#[tokio::test] +async fn a_lock_does_not_outlive_itself() { + let app = TestApp::spawn().await; + let user = fixtures::authenticated_user(&app, 801).await; + lock(&app, &user).await; + // The lock ends: the guesses before and during it no longer count. + sqlx::query("UPDATE users SET locked_until = NOW() - INTERVAL '1 second' WHERE id = $1") + .bind(user.id) + .execute(&app.db) + .await + .unwrap(); + + assert_eq!(login(&app, &user.email, WRONG).await.0, 401); + assert_eq!( + login(&app, &user.email, &user.password).await.0, + 200, + "one more guess after a lock must not lock again" + ); +} + +#[tokio::test] +async fn any_completed_sign_in_restarts_the_count() { + let app = TestApp::spawn().await; + let user = fixtures::authenticated_user(&app, 802).await; + for _ in 0..2 { + assert_eq!(login(&app, &user.email, WRONG).await.0, 401); + } + assert_eq!(sign_in_by_link(&app, &user, 1).await, 200); + for _ in 0..2 { + assert_eq!(login(&app, &user.email, WRONG).await.0, 401); + } + assert_eq!(login(&app, &user.email, &user.password).await.0, 200); +} + +#[tokio::test] +async fn old_failures_do_not_add_up_with_new_ones() { + let app = TestApp::spawn().await; + let user = fixtures::authenticated_user(&app, 803).await; + for _ in 0..2 { + assert_eq!(login(&app, &user.email, WRONG).await.0, 401); + } + sqlx::query( + "UPDATE login_attempts SET attempted_at = NOW() - INTERVAL '2 days' WHERE user_id = $1", + ) + .bind(user.id) + .execute(&app.db) + .await + .unwrap(); + assert_eq!(login(&app, &user.email, WRONG).await.0, 401); + assert_eq!(login(&app, &user.email, &user.password).await.0, 200); +} + +#[tokio::test] +async fn the_other_ways_in_stay_open_while_the_password_is_locked() { + let app = TestApp::spawn().await; + let user = fixtures::authenticated_user(&app, 804).await; + lock(&app, &user).await; + assert_eq!(sign_in_by_link(&app, &user, 2).await, 200); +} + +#[tokio::test] +async fn a_reset_lifts_the_lock() { + let app = TestApp::spawn().await; + let user = fixtures::authenticated_user(&app, 805).await; + lock(&app, &user).await; + + app.post("/auth/forgot-password", &json!({ "email": user.email })) + .await; + let token = app + .mail + .wait_for(&user.email, RESET_SUBJECT) + .await + .value_after("token=") + .unwrap(); + let res = app + .post( + "/auth/reset-password", + &json!({ "token": token, "new_password": "Brand-New-Pass-805!" }), + ) + .await; + assert_eq!(res.status().as_u16(), 200); + assert_eq!(login(&app, &user.email, "Brand-New-Pass-805!").await.0, 200); +} + +#[tokio::test] +async fn someone_asking_for_links_neither_spends_nor_revokes_the_owners() { + let app = TestApp::spawn().await; + let user = fixtures::authenticated_user(&app, 806).await; + let resets = |app: &TestApp| { + app.mail + .messages_to(&user.email) + .into_iter() + .filter(|mail| mail.subject == RESET_SUBJECT) + .count() + }; + + app.post("/auth/forgot-password", &json!({ "email": user.email })) + .await; + let owners = app + .mail + .wait_for(&user.email, RESET_SUBJECT) + .await + .value_after("token=") + .unwrap(); + + // Someone else asks from their own address until their share is spent. + // Unique per run: address budgets live in the shared Redis. + let b = uuid::Uuid::new_v4().into_bytes(); + let other = format!("10.{}.{}.{}", 200 + b[0] % 50, b[1], 1 + b[2] % 254); + for _ in 0..4 { + let res = post_from( + &app, + &other, + "/auth/forgot-password", + &json!({ "email": user.email }), + ) + .await; + assert_eq!(res.status().as_u16(), 200); + } + app.mail.wait_for_count(&user.email, 4).await; + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + assert_eq!(resets(&app), 4, "3 for the other address, 1 for the owner"); + + // The owner still gets a link from their address, and the first still works. + app.post("/auth/forgot-password", &json!({ "email": user.email })) + .await; + app.mail.wait_for_count(&user.email, 5).await; + let res = app + .post( + "/auth/reset-password", + &json!({ "token": owners, "new_password": "Owner-Choice-806!" }), + ) + .await; + assert_eq!( + res.status().as_u16(), + 200, + "the owner's first link survived" + ); +} diff --git a/tests/security/regressions/mod.rs b/tests/security/regressions/mod.rs index b36f508..003131c 100644 --- a/tests/security/regressions/mod.rs +++ b/tests/security/regressions/mod.rs @@ -1,5 +1,6 @@ mod access_persistence; mod account_hardening; +mod lockout; mod pre_hijacking; mod second_factor; mod session_hardening; diff --git a/tests/security/regressions/second_factor.rs b/tests/security/regressions/second_factor.rs index 8354293..a92be74 100644 --- a/tests/security/regressions/second_factor.rs +++ b/tests/security/regressions/second_factor.rs @@ -154,10 +154,14 @@ async fn account_budget_blocks_fresh_pre_auth_tokens() { let user = fixtures::authenticated_user(&app, 602).await; let (secret, _) = enable_totp(&app, &user).await; - // Simulate an exhausted per-account budget (20 failures this hour). + // Simulate an exhausted budget for this address (20 failures this hour). let mut conn = app.redis.get().await.unwrap(); let _: () = conn - .set_ex(format!("totp_user_fail:{}", user.id), 20, 3600) + .set_ex( + format!("totp_user_fail:{}:{}", user.id, app.client_ip), + 20, + 3600, + ) .await .unwrap(); @@ -550,3 +554,55 @@ async fn concurrent_recovery_code_regenerations_run_once() { "{statuses:?}" ); } + +/// Someone holding the password and guessing codes from their address spends +/// that address's budget, not the owner's (SEC-58). +#[tokio::test] +async fn guessing_codes_from_one_address_does_not_block_the_owner() { + let app = TestApp::spawn().await; + let user = fixtures::authenticated_user(&app, 604).await; + let (secret, _) = enable_totp(&app, &user).await; + + let mut conn = app.redis.get().await.unwrap(); + let _: () = conn + .set_ex(format!("totp_user_fail:{}:10.66.6.6", user.id), 20, 3600) + .await + .unwrap(); + + // The code of the current step was spent enabling TOTP: use the next one. + let challenge = login_challenge(&app, &user).await; + let res = app + .post( + "/auth/two-factor/complete", + &json!({ + "pre_auth_token": challenge["pre_auth_token"], + "code": totp_code(&secret, 1), + }), + ) + .await; + assert_eq!( + res.status().as_u16(), + 200, + "the owner's address is not spent" + ); +} + +/// Signing in again within the minute an e-mail code stays fresh goes on with +/// the code already sent instead of failing after the challenge was stored. +#[tokio::test] +async fn signing_in_again_within_the_email_code_cooldown_still_challenges() { + let app = TestApp::spawn().await; + let user = fixtures::authenticated_user(&app, 605).await; + sqlx::query( + "INSERT INTO two_factor_methods (user_id, method_type, is_primary, is_verified) + VALUES ($1, 'email', TRUE, TRUE)", + ) + .bind(user.id) + .execute(&app.db) + .await + .unwrap(); + + let first = login_challenge(&app, &user).await; + let second = login_challenge(&app, &user).await; + assert_ne!(first["pre_auth_token"], second["pre_auth_token"]); +} diff --git a/tests/simulation/clock.rs b/tests/simulation/clock.rs index f39a757..e210fbf 100644 --- a/tests/simulation/clock.rs +++ b/tests/simulation/clock.rs @@ -53,11 +53,11 @@ async fn a_lockout_lifts_when_its_duration_has_passed() { assert_eq!(res.status().as_u16(), 401); } let locked = app.post("/auth/login", &login(&user.password)).await; - assert_eq!(locked.status().as_u16(), 403); + assert_eq!(locked.status().as_u16(), 401); app.clock.advance(Duration::minutes(29)); let still_locked = app.post("/auth/login", &login(&user.password)).await; - assert_eq!(still_locked.status().as_u16(), 403); + assert_eq!(still_locked.status().as_u16(), 401); // Past the lockout and past the 15-minute brute-force window. app.clock.advance(Duration::minutes(2)); diff --git a/tests/simulation/redis_outage.rs b/tests/simulation/redis_outage.rs index 1c8b17f..87e4069 100644 --- a/tests/simulation/redis_outage.rs +++ b/tests/simulation/redis_outage.rs @@ -61,6 +61,31 @@ async fn login_succeeds_when_redis_is_down() { ); } +/// A second factor needs its challenge stored in Redis: without Redis the +/// sign-in is unavailable (503), not an internal error. +#[tokio::test] +async fn a_second_factor_sign_in_without_redis_is_unavailable_not_broken() { + let app = app_without_redis().await; + let user = fixtures::register_user(&app, 374).await; + fixtures::activate_user(&app.db, user.id).await; + sqlx::query( + "INSERT INTO two_factor_methods (user_id, method_type, is_primary, is_verified) + VALUES ($1, 'email', TRUE, TRUE)", + ) + .bind(user.id) + .execute(&app.db) + .await + .unwrap(); + + let res = app + .post( + "/auth/login", + &serde_json::json!({ "identifier": user.email, "password": user.password }), + ) + .await; + assert_eq!(res.status().as_u16(), 503); +} + // Email verification #[tokio::test] From ea09d8e62ec407feb4883028b44374afc5115d60 Mon Sep 17 00:00:00 2001 From: SIIR3X <125802911+SIIR3X@users.noreply.github.com> Date: Tue, 22 Sep 2026 06:00:30 +0200 Subject: [PATCH 22/55] fix(admin): stop self-granted permissions and require reauthentication for every action that pushes out or reopens --- CHANGELOG.md | 17 ++ docs/dev/api/openapi.yaml | 43 ++-- docs/dev/api/routes.md | 27 ++- docs/dev/security-model.md | 21 +- src/cli.rs | 65 +++++- src/handlers/admin/roles.rs | 4 +- src/handlers/admin/users.rs | 15 +- src/handlers/passkey.rs | 1 + src/handlers/two_factor.rs | 2 + src/main.rs | 6 +- src/repositories/passkey.rs | 8 +- src/repositories/role.rs | 52 ++++- src/repositories/session.rs | 16 ++ src/repositories/two_factor.rs | 5 +- src/repositories/user.rs | 17 +- src/services/admin/clients.rs | 14 +- src/services/admin/mod.rs | 33 +++ src/services/admin/roles.rs | 57 ++++- src/services/admin/users.rs | 43 ++-- src/services/email.rs | 38 +++ src/services/passkey.rs | 8 +- src/services/two_factor.rs | 8 +- src/services/user.rs | 16 ++ src/services/webhooks.rs | 4 + .../emails/en/changed_by_administrator.html | 12 + .../en/changed_by_administrator.subject | 1 + .../emails/fr/changed_by_administrator.html | 12 + .../fr/changed_by_administrator.subject | 1 + tests/integration/api/admin/mod.rs | 1 + tests/integration/api/admin/privileges.rs | 218 ++++++++++++++++++ tests/integration/api/admin/users.rs | 14 +- 31 files changed, 676 insertions(+), 103 deletions(-) create mode 100644 templates/emails/en/changed_by_administrator.html create mode 100644 templates/emails/en/changed_by_administrator.subject create mode 100644 templates/emails/fr/changed_by_administrator.html create mode 100644 templates/emails/fr/changed_by_administrator.subject create mode 100644 tests/integration/api/admin/privileges.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 8979bfd..0213cec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,23 @@ from scratch (read **Upgrading**). ### Security +- 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 diff --git a/docs/dev/api/openapi.yaml b/docs/dev/api/openapi.yaml index d25cd52..4b3b1dc 100644 --- a/docs/dev/api/openapi.yaml +++ b/docs/dev/api/openapi.yaml @@ -720,7 +720,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 +732,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: @@ -921,13 +921,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 +931,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: @@ -961,18 +954,6 @@ paths: 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: @@ -4854,6 +4835,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: @@ -5569,6 +5556,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: @@ -5937,6 +5930,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: diff --git a/docs/dev/api/routes.md b/docs/dev/api/routes.md index 8acaa4c..9a36414 100644 --- a/docs/dev/api/routes.md +++ b/docs/dev/api/routes.md @@ -325,21 +325,21 @@ 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` + reauth | General | -| POST | `/admin/users/{id}/reactivate` | Admin `users:manage` | General | -| POST | `/admin/users/{id}/unlock` | Admin `users:manage` | 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}/sessions` | Admin `users:manage` | 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` + reauth | General | | GET | `/admin/audit` | Admin `audit:read` | General | @@ -349,17 +349,26 @@ at sign-in (`403 two_factor_required` otherwise). `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`. Creating, updating -and re-keying a webhook need a recent re-authentication. 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/security-model.md b/docs/dev/security-model.md index 4a91654..9450770 100644 --- a/docs/dev/security-model.md +++ b/docs/dev/security-model.md @@ -224,18 +224,26 @@ the database together, is out of scope. 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; nobody - grants a role to their own account. + 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 adds to a role they hold a + permission they lack, 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 (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, saving a client or changing its - secret, creating, redirecting or re-keying a webhook, suspending an account - and forcing its reset need a recent re-authentication: a stolen administrator - token alone can neither send account events elsewhere nor shut owners out. +- 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; a webhook's audit keeps the host it points to (never the path or query), and redeliveries are audited. - No change may leave the deployment without an active account holding @@ -389,3 +397,4 @@ when a cited test no longer exists. | 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` | diff --git a/src/cli.rs b/src/cli.rs index 94a4fbb..2d119d1 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, }, }; @@ -169,14 +169,21 @@ pub async fn grant_role(pool: &PgPool, grant: &RoleGrant) -> Result<(), String> .await .map_err(|e| e.to_string())? .ok_or_else(|| format!("no role named {}", grant.role))?; + + // 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(pool, granted.id) + 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(pool, account.id) + && user::has_second_factor(&mut *tx, account.id) .await .map_err(|e| e.to_string())?; if !ready { @@ -188,14 +195,14 @@ pub async fn grant_role(pool: &PgPool, grant: &RoleGrant) -> Result<(), String> } } - match role::assign_to_user(pool, account.id, granted.id, None).await { + 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, @@ -206,9 +213,57 @@ pub async fn grant_role(pool: &PgPool, grant: &RoleGrant) -> Result<(), String> ) .await .map_err(|e| e.to_string())?; + tx.commit().await.map_err(|e| e.to_string())?; Ok(()) } +/// 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 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: json!({ "client_id": saved.client_id, "by": "command_line" }), + }, + ) + .await + .map_err(|e| e.to_string())?; + tx.commit().await.map_err(|e| e.to_string())?; + Ok(saved) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/handlers/admin/roles.rs b/src/handlers/admin/roles.rs index 757e52d..06182dc 100644 --- a/src/handlers/admin/roles.rs +++ b/src/handlers/admin/roles.rs @@ -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" = [])), diff --git a/src/handlers/admin/users.rs b/src/handlers/admin/users.rs index 8e6ec2b..c766726 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, @@ -306,10 +306,9 @@ 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), @@ -321,16 +320,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/passkey.rs b/src/handlers/passkey.rs index 2e6edb9..7e45df6 100644 --- a/src/handlers/passkey.rs +++ b/src/handlers/passkey.rs @@ -175,6 +175,7 @@ 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" = [])), )] diff --git a/src/handlers/two_factor.rs b/src/handlers/two_factor.rs index bfa2f04..ecf1588 100644 --- a/src/handlers/two_factor.rs +++ b/src/handlers/two_factor.rs @@ -154,6 +154,7 @@ 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" = [])), )] @@ -330,6 +331,7 @@ 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" = [])), )] diff --git a/src/main.rs b/src/main.rs index 4005ed6..52a11df 100644 --- a/src/main.rs +++ b/src/main.rs @@ -76,9 +76,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, 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/role.rs b/src/repositories/role.rs index a9ef27c..5ac4266 100644 --- a/src/repositories/role.rs +++ b/src/repositories/role.rs @@ -287,7 +287,10 @@ pub async fn permission_held<'e>( } /// Whether the role grants at least one administrative permission. -pub async fn grants_administration(pool: &PgPool, role_id: Uuid) -> Result { +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 @@ -297,6 +300,51 @@ pub async fn grants_administration(pool: &PgPool, role_id: Uuid) -> Result( + 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 29a0bc7..835b8c6 100644 --- a/src/repositories/session.rs +++ b/src/repositories/session.rs @@ -204,6 +204,22 @@ pub async fn revoke_all_by_user<'e>( Ok(result.rows_affected()) } +/// 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>, diff --git a/src/repositories/two_factor.rs b/src/repositories/two_factor.rs index 02b3210..348595b 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, diff --git a/src/repositories/user.rs b/src/repositories/user.rs index 8dbe4ff..694a760 100644 --- a/src/repositories/user.rs +++ b/src/repositories/user.rs @@ -195,13 +195,16 @@ pub async fn adopt_pending_credentials<'e>( /// Whether the account can prove a second factor: a verified TOTP or email /// method, or a passkey. -pub async fn has_second_factor(pool: &PgPool, id: Uuid) -> Result { +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(pool) + .fetch_one(executor) .await } @@ -361,3 +364,13 @@ pub async fn clear_lockout<'e>( .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/services/admin/clients.rs b/src/services/admin/clients.rs index b69c68d..7dafd8e 100644 --- a/src/services/admin/clients.rs +++ b/src/services/admin/clients.rs @@ -98,8 +98,20 @@ 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?; let revoked = session_repo::revoke_by_client(&mut *tx, client_id).await?; if !client_repo::delete(&mut *tx, client_id).await? { diff --git a/src/services/admin/mod.rs b/src/services/admin/mod.rs index ddbb39e..02e5f88 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,37 @@ impl Actor { } } +/// 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 bbd31b4..a2f9613 100644 --- a/src/services/admin/roles.rs +++ b/src/services/admin/roles.rs @@ -73,6 +73,30 @@ pub async fn set_permissions( ) -> Result<(Role, Vec), AppError> { let role = find(state, name).await?; let permissions = known_permissions(state, permissions).await?; + // Adding to a role one holds a permission one lacks would be granting it + // to oneself (every account holds the default role, so this covers it). + if role_repo::holds_role(&state.db, actor.user_id, role.id).await? { + let current = role_repo::find_all_with_permissions(&state.db) + .await? + .into_iter() + .find(|(r, _)| r.id == role.id) + .map(|(_, granted)| granted) + .unwrap_or_default(); + for added in permissions.iter().filter(|p| !current.contains(p)) { + if !role_repo::user_has_permission(&state.db, actor.user_id, added).await? { + return Err(AppError::Forbidden); + } + } + } + // 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?; @@ -96,8 +120,11 @@ 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?; + // 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 +132,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(()) } @@ -126,9 +163,12 @@ pub async fn assign( .await? .ok_or(AppError::NotFound)?; require_reauth(state, actor, "admin_assign_role").await?; - ensure_can_administer(state, &user, &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?; + 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. @@ -141,6 +181,7 @@ pub async fn assign( ) .await?; tx.commit().await?; + super::notify_owner(state, user_id, "role_granted", Some(&role.name)).await; Ok(()) } @@ -152,6 +193,9 @@ 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?; if !role_repo::unassign(&mut *tx, user_id, role.id).await? { @@ -164,6 +208,9 @@ 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(()) } @@ -183,15 +230,15 @@ fn refuse_own_account(actor: &Actor, user_id: Uuid) -> Result<(), AppError> { /// and an account without one would hold administrative permissions in its /// tokens behind its password alone. pub(crate) async fn ensure_can_administer( - state: &AppState, + tx: &mut sqlx::PgConnection, user: &crate::domain::user::User, role: &Role, ) -> Result<(), AppError> { - if !role_repo::grants_administration(&state.db, role.id).await? { + 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(&state.db, user.id).await? + || !user_repo::has_second_factor(&mut *tx, user.id).await? { return Err(AppError::Conflict("administrator_without_second_factor")); } diff --git a/src/services/admin/users.rs b/src/services/admin/users.rs index cb0f9a0..f544d2a 100644 --- a/src/services/admin/users.rs +++ b/src/services/admin/users.rs @@ -70,7 +70,6 @@ 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 manages_roles = role_repo::user_has_permission(&state.db, user_id, crate::domain::role::ROLES_MANAGE) .await?; @@ -81,7 +80,9 @@ pub async fn suspend(state: &AppState, actor: &Actor, user_id: Uuid) -> Result<( if manages_roles { super::roles::keep_an_administrator(&mut tx).await?; } - session_repo::revoke_all_by_user(&mut *tx, user_id).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( @@ -108,11 +109,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? { @@ -131,11 +135,16 @@ 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?; let mut tx = state.db.begin().await?; user_repo::clear_lockout(&mut *tx, user_id).await?; @@ -157,10 +166,10 @@ pub async fn revoke_sessions( ) -> Result { refuse_own_account(actor, user_id)?; 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( @@ -181,6 +190,7 @@ pub async fn revoke_sessions( events::wake(); forget_sessions(state, &active).await; + super::notify_owner(state, user_id, "sessions_revoked", None).await; Ok(count) } @@ -194,10 +204,10 @@ pub async fn force_password_reset( 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?; 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(); audit::append( &mut *tx, &entry( @@ -222,24 +232,11 @@ pub async fn force_password_reset( } /// Delete the account like its owner would, after a recent re-authentication of -/// the administrator. -pub async fn delete( - state: &AppState, - actor: &Actor, - user_id: Uuid, - current_password: Option<&str>, -) -> Result<(), AppError> { +/// 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)?; - reauth_svc::require_recent_reauth_or_password( - state, - actor.user_id, - actor.session_id, - current_password, - actor.ip, - actor.request_id, - "admin_delete_account", - ) - .await?; + require_reauth(state, actor, "admin_delete_account").await?; find(state, user_id).await?; user_svc::erase_account( state, diff --git a/src/services/email.rs b/src/services/email.rs index 576dfa7..060f2c9 100644 --- a/src/services/email.rs +++ b/src/services/email.rs @@ -29,6 +29,7 @@ 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_TWO_FACTOR_ENABLED: &str = "two_factor_enabled"; const TNAME_ACCOUNT_EXISTS: &str = "account_exists"; const TNAME_EMAIL_CHANGED: &str = "email_changed"; @@ -602,6 +603,43 @@ pub async fn send_account_locked( send(mailer, &mail_cfg.smtp, to_email, username, &subject, body).await } +/// `change` is one of `suspended`, `reactivated`, `role_granted`, +/// `role_revoked` or `sessions_revoked`; `role` names the role for the two +/// 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 +} + // Tries locale template first, falls back to default_locale. fn render_with_fallback( templates: &Tera, diff --git a/src/services/passkey.rs b/src/services/passkey.rs index 7e9cd62..879cb8f 100644 --- a/src/services/passkey.rs +++ b/src/services/passkey.rs @@ -293,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, @@ -307,6 +310,7 @@ pub async fn remove( }, ) .await?; + tx.commit().await?; Ok(()) } diff --git a/src/services/two_factor.rs b/src/services/two_factor.rs index b4683cf..25d02e1 100644 --- a/src/services/two_factor.rs +++ b/src/services/two_factor.rs @@ -473,13 +473,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, @@ -494,6 +497,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 3e64c89..38ba31a 100644 --- a/src/services/user.rs +++ b/src/services/user.rs @@ -525,3 +525,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 921e195..ff8b7e9 100644 --- a/src/services/webhooks.rs +++ b/src/services/webhooks.rs @@ -391,7 +391,10 @@ pub async fn rotate_secret(state: &AppState, actor: &Actor, id: Uuid) -> Result< 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> { + require_reauth(state, actor, "admin_delete_webhook").await?; let previous = webhook_repo::find_endpoint(&state.db, id) .await? .ok_or(AppError::NotFound)?; @@ -419,6 +422,7 @@ pub async fn redeliver( endpoint_id: Uuid, delivery_id: Uuid, ) -> Result<(), AppError> { + 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); diff --git a/templates/emails/en/changed_by_administrator.html b/templates/emails/en/changed_by_administrator.html new file mode 100644 index 0000000..7f9c84e --- /dev/null +++ b/templates/emails/en/changed_by_administrator.html @@ -0,0 +1,12 @@ + + + +

Hello {{ username }},

+{% if change == "suspended" %}

An administrator suspended your {{ app_name }} account and signed it out everywhere.

+{% elif change == "reactivated" %}

An administrator reactivated your {{ app_name }} account: you can sign in again.

+{% elif change == "role_granted" %}

An administrator gave your {{ app_name }} account the role {{ role }}.

+{% elif change == "role_revoked" %}

Your {{ app_name }} account no longer holds the role {{ role }}.

+{% else %}

An administrator signed your {{ app_name }} account out of every device.

+{% endif %}

Your account's history lists this change. If you did not expect it, contact support.

+ + diff --git a/templates/emails/en/changed_by_administrator.subject b/templates/emails/en/changed_by_administrator.subject new file mode 100644 index 0000000..80a715a --- /dev/null +++ b/templates/emails/en/changed_by_administrator.subject @@ -0,0 +1 @@ +An administrator changed your account diff --git a/templates/emails/fr/changed_by_administrator.html b/templates/emails/fr/changed_by_administrator.html new file mode 100644 index 0000000..0b1a854 --- /dev/null +++ b/templates/emails/fr/changed_by_administrator.html @@ -0,0 +1,12 @@ + + + +

Bonjour {{ username }},

+{% if change == "suspended" %}

Un administrateur a suspendu votre compte {{ app_name }} et l'a déconnecté partout.

+{% elif change == "reactivated" %}

Un administrateur a réactivé votre compte {{ app_name }} : vous pouvez de nouveau vous connecter.

+{% elif change == "role_granted" %}

Un administrateur a attribué le rôle {{ role }} à votre compte {{ app_name }}.

+{% elif change == "role_revoked" %}

Votre compte {{ app_name }} ne détient plus le rôle {{ role }}.

+{% else %}

Un administrateur a déconnecté votre compte {{ app_name }} de tous vos appareils.

+{% endif %}

L'historique de votre compte mentionne ce changement. Si vous ne vous y attendiez pas, contactez le support.

+ + diff --git a/templates/emails/fr/changed_by_administrator.subject b/templates/emails/fr/changed_by_administrator.subject new file mode 100644 index 0000000..8aaa463 --- /dev/null +++ b/templates/emails/fr/changed_by_administrator.subject @@ -0,0 +1 @@ +Un administrateur a modifié votre compte diff --git a/tests/integration/api/admin/mod.rs b/tests/integration/api/admin/mod.rs index 6a48fee..4012123 100644 --- a/tests/integration/api/admin/mod.rs +++ b/tests/integration/api/admin/mod.rs @@ -2,6 +2,7 @@ mod audit; mod clients; +mod privileges; mod roles; mod users; mod webhooks; diff --git a/tests/integration/api/admin/privileges.rs b/tests/integration/api/admin/privileges.rs new file mode 100644 index 0000000..bb5f6c8 --- /dev/null +++ b/tests/integration/api/admin/privileges.rs @@ -0,0 +1,218 @@ +//! Administrative privileges (SEC-59): no administrator grants themselves +//! permissions, pushes the others out, or acts unnoticed with a stolen token. + +use reqwest::Method; +use serde_json::{Value, json}; +use uuid::Uuid; + +use super::{Admin, admin, body, enroll_second_factor, prove_second_factor, token_with}; +use crate::common::{app::TestApp, fixtures}; + +async fn send(app: &TestApp, method: Method, path: &str, token: &str, json: Value) -> (u16, Value) { + body( + app.client + .request(method, app.url(path)) + .bearer_auth(token) + .json(&json) + .send() + .await + .unwrap(), + ) + .await +} + +/// An administrator holding only `roles:manage`, through a role of its own. +async fn role_manager(app: &TestApp, by: &Admin) -> (fixtures::AuthenticatedUser, String) { + let (status, created) = send( + app, + Method::POST, + "/admin/roles", + &by.token, + json!({ "name": "support", "permissions": ["roles:manage"] }), + ) + .await; + assert_eq!(status, 201, "{created}"); + let manager = fixtures::authenticated_user(app, 30).await; + enroll_second_factor(app, manager.id).await; + let (status, granted) = send( + app, + Method::POST, + &format!("/admin/users/{}/roles", manager.id), + &by.token, + json!({ "role": "support" }), + ) + .await; + assert_eq!(status, 204, "{granted}"); + prove_second_factor(app, &manager).await; + let token = token_with(app, &manager, &["roles:manage"]); + (manager, token) +} + +#[tokio::test] +async fn nobody_adds_to_a_role_they_hold_a_permission_they_lack() { + let app = TestApp::spawn().await; + let admin = admin(&app, 1).await; + let (_, token) = role_manager(&app, &admin).await; + + let (status, _) = send( + &app, + Method::PUT, + "/admin/roles/support/permissions", + &token, + json!({ "permissions": ["roles:manage", "users:manage", "audit:read"] }), + ) + .await; + assert_eq!( + status, 403, + "a role manager cannot make themselves a full administrator" + ); +} + +#[tokio::test] +async fn the_default_role_never_grants_administration() { + let app = TestApp::spawn().await; + let admin = admin(&app, 1).await; + let (status, response) = send( + &app, + Method::PUT, + "/admin/roles/user/permissions", + &admin.token, + json!({ "permissions": ["users:read"] }), + ) + .await; + assert_eq!( + (status, response["code"].as_str()), + (409, Some("default_role_administration")) + ); +} + +#[tokio::test] +async fn actions_that_push_out_or_reopen_need_a_recent_reauthentication() { + let app = TestApp::spawn().await; + let admin = admin(&app, 1).await; + let (status, _) = send( + &app, + Method::POST, + "/admin/roles", + &admin.token, + json!({ "name": "doomed", "permissions": [] }), + ) + .await; + assert_eq!(status, 201); + let other = fixtures::authenticated_user(&app, 2).await; + app.clear_recent_reauth(&admin.token).await; + + let id = Uuid::new_v4(); + for (method, path) in [ + ( + Method::DELETE, + format!("/admin/users/{}/roles/admin", other.id), + ), + (Method::DELETE, "/admin/roles/doomed".to_owned()), + (Method::POST, format!("/admin/users/{}/unlock", other.id)), + ( + Method::POST, + format!("/admin/users/{}/reactivate", other.id), + ), + (Method::DELETE, "/admin/clients/some-client".to_owned()), + (Method::DELETE, format!("/admin/webhooks/{id}")), + ( + Method::POST, + format!("/admin/webhooks/{id}/deliveries/{id}/retry"), + ), + ] { + let (status, response) = send(&app, method.clone(), &path, &admin.token, json!({})).await; + assert_eq!( + (status, response["code"].as_str()), + (403, Some("reauthentication_required")), + "{method} {path}" + ); + } +} + +#[tokio::test] +async fn an_administrator_cannot_unlock_their_own_account() { + let app = TestApp::spawn().await; + let admin = admin(&app, 1).await; + let (status, _) = send( + &app, + Method::POST, + &format!("/admin/users/{}/unlock", admin.user.id), + &admin.token, + json!({}), + ) + .await; + assert_eq!(status, 403); +} + +#[tokio::test] +async fn the_owner_hears_of_what_an_administrator_changed() { + let app = TestApp::spawn().await; + let admin = admin(&app, 1).await; + let target = fixtures::authenticated_user(&app, 2).await; + + let (status, _) = send( + &app, + Method::POST, + &format!("/admin/users/{}/suspend", target.id), + &admin.token, + json!({}), + ) + .await; + assert_eq!(status, 204); + let mail = app + .mail + .wait_for(&target.email, "An administrator changed your account") + .await; + assert!(mail.html.contains("suspended"), "{}", mail.html); +} + +#[tokio::test] +async fn deleting_a_role_leaves_a_trace_in_each_holders_history() { + let app = TestApp::spawn().await; + let admin = admin(&app, 1).await; + let (manager, _) = role_manager(&app, &admin).await; + + let (status, _) = send( + &app, + Method::DELETE, + "/admin/roles/support", + &admin.token, + json!({}), + ) + .await; + assert_eq!(status, 204); + let revoked: i64 = sqlx::query_scalar( + "SELECT count(*) FROM audit_log WHERE user_id = $1 AND action = 'role_revoked' + AND metadata->>'role' = 'support'", + ) + .bind(manager.id) + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(revoked, 1); +} + +#[tokio::test] +async fn an_administrator_keeps_a_second_factor() { + let app = TestApp::spawn().await; + let admin = admin(&app, 1).await; + let method: Uuid = sqlx::query_scalar("SELECT id FROM two_factor_methods WHERE user_id = $1") + .bind(admin.user.id) + .fetch_one(&app.db) + .await + .unwrap(); + + let (status, response) = send( + &app, + Method::DELETE, + &format!("/users/me/two-factor/email/{method}"), + &admin.user.access_token, + json!({ "current_password": admin.user.password }), + ) + .await; + assert_eq!( + (status, response["code"].as_str()), + (409, Some("administrator_needs_second_factor")) + ); +} diff --git a/tests/integration/api/admin/users.rs b/tests/integration/api/admin/users.rs index 3421ba8..0c01eab 100644 --- a/tests/integration/api/admin/users.rs +++ b/tests/integration/api/admin/users.rs @@ -400,7 +400,9 @@ async fn deleting_an_account_needs_a_recent_reauthentication_and_announces_it() assert_eq!(status, 403); assert_eq!(response["code"], "reauthentication_required"); - let (status, response) = body( + // A password in the body is not enough: the re-authentication goes + // through its own, strictly budgeted route. + let (status, _) = body( app.delete_auth_json( &path, &admin.token, @@ -409,6 +411,16 @@ async fn deleting_an_account_needs_a_recent_reauthentication_and_announces_it() .await, ) .await; + assert_eq!(status, 403); + let reauth = app + .post_auth( + "/users/me/reauth", + &admin.token, + &json!({ "current_password": admin.user.password }), + ) + .await; + assert_eq!(reauth.status().as_u16(), 204); + let (status, response) = body(app.delete_auth(&path, &admin.token).await).await; assert_eq!(status, 204, "{response}"); assert_eq!(app.get_auth(&path, &admin.token).await.status(), 404); From 640d91975fdf50c0993f240adf5af2862416b23e Mon Sep 17 00:00:00 2001 From: SIIR3X <125802911+SIIR3X@users.noreply.github.com> Date: Tue, 22 Sep 2026 10:12:36 +0200 Subject: [PATCH 23/55] fix(privacy): hide administrators from the history, keep addresses out of audit metadata and announce every deletion --- CHANGELOG.md | 9 ++ docs/dev/privacy.md | 2 +- docs/dev/security-model.md | 17 ++- migrations/0012_personal_data.sql | 16 ++- migrations/SHA256SUMS | 2 +- src/domain/audit.rs | 15 +++ src/handlers/audit.rs | 24 ++-- src/repositories/export.rs | 56 +++++++++- src/services/auth/session.rs | 41 ++++++- src/services/email_change.rs | 8 +- tests/security/regressions/data_privacy.rs | 122 +++++++++++++++++++++ tests/security/regressions/mod.rs | 1 + 12 files changed, 288 insertions(+), 25 deletions(-) create mode 100644 tests/security/regressions/data_privacy.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0213cec..925a654 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,15 @@ from scratch (read **Upgrading**). ### Security +- `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 diff --git a/docs/dev/privacy.md b/docs/dev/privacy.md index bf8c37f..612c88d 100644 --- a/docs/dev/privacy.md +++ b/docs/dev/privacy.md @@ -58,7 +58,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. 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`) | diff --git a/docs/dev/security-model.md b/docs/dev/security-model.md index 9450770..5169698 100644 --- a/docs/dev/security-model.md +++ b/docs/dev/security-model.md @@ -268,14 +268,18 @@ 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`, @@ -398,3 +402,4 @@ when a cited test no longer exists. | 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` | diff --git a/migrations/0012_personal_data.sql b/migrations/0012_personal_data.sql index 61ee33b..689a65c 100644 --- a/migrations/0012_personal_data.sql +++ b/migrations/0012_personal_data.sql @@ -64,9 +64,19 @@ BEGIN 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; diff --git a/migrations/SHA256SUMS b/migrations/SHA256SUMS index f97d249..1f70bf5 100644 --- a/migrations/SHA256SUMS +++ b/migrations/SHA256SUMS @@ -9,7 +9,7 @@ c155c5d85738ffc9b08a879001e05875fc4b263afde85b56f32f96f5abf375a5 0008_account_t 6c37703c71c8892899764c86bf00b86f09afd362ac17262d4b27045643c316e8 0009_login_attempts.sql 84918e9b9f382af46704a085ecdd3e3fa38fac195244b4375efd7d3504b27153 0010_audit_log.sql 76b27021b2b3e978ee4222edf821fbda8d78f39693fdd446279dc4d4d61377dc 0011_event_outbox.sql -4658e31a2d145019451c6e448f2c2ca2162ae318429ebc73784ba97acadcb499 0012_personal_data.sql +a75ca9d75ea094cf37fff95f700256371b1dabe71840c2d94ed7407198354cae 0012_personal_data.sql f6434d27992410fe4844cf77d826695d2c064bf9f7cc2533d6ad43982493c011 0013_known_devices.sql b4d5cc42ee28d7ed7f91888176b18a5d9e3f195fe15f131896f4a128b34a997a 0014_magic_links.sql 84077e0347d23ace25d6cc07e19d83dabd2652fdf33794de759765730b5fa6f0 0015_personal_access_tokens.sql diff --git a/src/domain/audit.rs b/src/domain/audit.rs index 5142b18..e75e05a 100644 --- a/src/domain/audit.rs +++ b/src/domain/audit.rs @@ -74,3 +74,18 @@ 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 +/// keeps what was done, not who did it nor from where. +pub fn owner_view( + mut metadata: serde_json::Value, + ip_address: Option, +) -> (serde_json::Value, Option) { + if metadata.get("by").and_then(|by| by.as_str()) != Some("administrator") { + return (metadata, ip_address); + } + if let Some(fields) = metadata.as_object_mut() { + fields.remove("administrator_id"); + } + (metadata, None) +} diff --git a/src/handlers/audit.rs b/src/handlers/audit.rs index 0a94df1..80b13dc 100644 --- a/src/handlers/audit.rs +++ b/src/handlers/audit.rs @@ -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, diff --git a/src/repositories/export.rs b/src/repositories/export.rs index 6e01736..aac9a5d 100644 --- a/src/repositories/export.rs +++ b/src/repositories/export.rs @@ -1,8 +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, and so is what identifies an administrator who -//! changed the account. 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; @@ -80,6 +80,58 @@ 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, + 'ip_address', host(r.request_ip), + '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, diff --git a/src/services/auth/session.rs b/src/services/auth/session.rs index 9daefc1..2bbf5c5 100644 --- a/src/services/auth/session.rs +++ b/src/services/auth/session.rs @@ -111,8 +111,9 @@ 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), }), }, ) @@ -274,3 +275,39 @@ pub async fn logout( Ok(()) } + +/// 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/email_change.rs b/src/services/email_change.rs index 6946cb5..4cafea6 100644 --- a/src/services/email_change.rs +++ b/src/services/email_change.rs @@ -365,7 +365,13 @@ pub async fn confirm_new( 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?; diff --git a/tests/security/regressions/data_privacy.rs b/tests/security/regressions/data_privacy.rs new file mode 100644 index 0000000..9c15115 --- /dev/null +++ b/tests/security/regressions/data_privacy.rs @@ -0,0 +1,122 @@ +//! Personal data (SEC-60): what the account's owner sees, what the audit log +//! keeps, and what downstream services hear. + +use serde_json::{Value, json}; + +use crate::common::{app::TestApp, fixtures}; + +#[tokio::test] +async fn the_history_names_no_administrator_nor_their_address() { + let app = TestApp::spawn().await; + let user = fixtures::authenticated_user(&app, 1).await; + let administrator = uuid::Uuid::new_v4(); + sqlx::query( + "INSERT INTO audit_log (user_id, action, ip_address, metadata) + VALUES ($1, 'account_reactivated', '203.0.113.78', + jsonb_build_object('by', 'administrator', 'administrator_id', $2::text))", + ) + .bind(user.id) + .bind(administrator) + .execute(&app.db) + .await + .unwrap(); + + let history: Value = app + .get_auth("/users/me/audit", &user.access_token) + .await + .json() + .await + .unwrap(); + let entry = history["entries"] + .as_array() + .unwrap() + .iter() + .find(|entry| entry["action"] == "account_reactivated") + .expect("the change stays in the history"); + assert_eq!(entry["metadata"], json!({ "by": "administrator" })); + assert_eq!(entry["ip_address"], Value::Null); +} + +#[tokio::test] +async fn a_replay_is_audited_without_addresses_in_its_metadata() { + let app = TestApp::spawn_with_config(|config| config.jwt.strict_session_binding = true).await; + let user = fixtures::authenticated_user(&app, 2).await; + sqlx::query("UPDATE sessions SET ip_address = '198.51.100.9' WHERE user_id = $1") + .bind(user.id) + .execute(&app.db) + .await + .unwrap(); + + let res = app + .post( + "/auth/refresh", + &json!({ "refresh_token": user.refresh_token }), + ) + .await; + assert_eq!(res.status().as_u16(), 401); + let metadata: Value = sqlx::query_scalar( + "SELECT metadata FROM audit_log WHERE user_id = $1 AND action = 'session_replay_detected'", + ) + .bind(user.id) + .fetch_one(&app.db) + .await + .unwrap(); + assert!(!metadata.to_string().contains("198.51.100.9"), "{metadata}"); + assert_eq!(metadata["same_network"], false); +} + +#[tokio::test] +async fn purging_a_never_verified_account_reaches_the_webhooks() { + let app = TestApp::spawn().await; + sqlx::query( + "INSERT INTO webhook_endpoints (url, events, secret) + VALUES ('https://hooks.example.com/auth', ARRAY['user.deleted'], 'v2:unused')", + ) + .execute(&app.db) + .await + .unwrap(); + let pending = fixtures::register_user(&app, 3).await; + sqlx::query("UPDATE users SET created_at = NOW() - INTERVAL '30 days' WHERE id = $1") + .bind(pending.id) + .execute(&app.db) + .await + .unwrap(); + + let purged: i32 = + sqlx::query_scalar("SELECT purge_unverified_accounts('7 days'::interval, 10)") + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(purged, 1); + let deliveries: Vec = sqlx::query_scalar( + "SELECT payload FROM webhook_deliveries WHERE event_name = 'user.deleted'", + ) + .fetch_all(&app.db) + .await + .unwrap(); + assert_eq!(deliveries, vec![json!({ "user_id": pending.id })]); +} + +#[tokio::test] +async fn the_export_holds_every_way_in_and_where_links_were_asked_from() { + let app = TestApp::spawn().await; + let user = fixtures::authenticated_user(&app, 4).await; + sqlx::query( + "INSERT INTO external_identities (user_id, provider, subject) VALUES ($1, 'github', '4242')", + ) + .bind(user.id) + .execute(&app.db) + .await + .unwrap(); + + let document: Value = app + .get_auth("/users/me/export", &user.access_token) + .await + .json() + .await + .unwrap(); + assert_eq!(document["external_identities"][0]["provider"], "github"); + assert!(document["passkeys"].is_array()); + assert!(document["personal_access_tokens"].is_array()); + assert_eq!(document["mailed_link_requests"][0]["kind"], "verification"); +} diff --git a/tests/security/regressions/mod.rs b/tests/security/regressions/mod.rs index 003131c..1c974ce 100644 --- a/tests/security/regressions/mod.rs +++ b/tests/security/regressions/mod.rs @@ -1,5 +1,6 @@ mod access_persistence; mod account_hardening; +mod data_privacy; mod lockout; mod pre_hijacking; mod second_factor; From 21d2c43fc1400e0fdaabc40cdbea02ff6c647f52 Mon Sep 17 00:00:00 2001 From: SIIR3X <125802911+SIIR3X@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:24:42 +0200 Subject: [PATCH 24/55] fix(session): forgive a rotated token only to its client and budget registrations per address --- CHANGELOG.md | 8 +++ crates/testkit/src/app.rs | 1 + docs/dev/guides/configuration.md | 3 +- docs/dev/security-model.md | 9 +-- docs/dev/threat-model.md | 4 +- src/bin/bench_support.rs | 1 + src/config/mod.rs | 9 ++- src/config/tests.rs | 1 + src/services/auth/login.rs | 4 +- src/services/auth/register.rs | 16 ++++++ src/services/auth/session.rs | 55 +++++++++++++++++- .../security/regressions/session_hardening.rs | 57 +++++++++++++++++++ 12 files changed, 157 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 925a654..e87f85c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,14 @@ from scratch (read **Upgrading**). ### Security +- 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 diff --git a/crates/testkit/src/app.rs b/crates/testkit/src/app.rs index 80c1b03..bd88966 100644 --- a/crates/testkit/src/app.rs +++ b/crates/testkit/src/app.rs @@ -525,6 +525,7 @@ 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, }, captcha: CaptchaConfig { secret: None, diff --git a/docs/dev/guides/configuration.md b/docs/dev/guides/configuration.md index 4367166..274ea66 100644 --- a/docs/dev/guides/configuration.md +++ b/docs/dev/guides/configuration.md @@ -128,6 +128,7 @@ 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` | +| `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 | @@ -220,7 +221,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 | diff --git a/docs/dev/security-model.md b/docs/dev/security-model.md index 5169698..75016a4 100644 --- a/docs/dev/security-model.md +++ b/docs/dev/security-model.md @@ -95,10 +95,10 @@ the database together, is out of scope. 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 and - ends the cached validity of its access tokens at once; within 1 second of - the rotation it is treated as a concurrent refresh from the same client (two - tabs), refused without revocation and counted - (`auth_refresh_concurrent_total`). Refused refreshes (unknown token, another + 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 @@ -403,3 +403,4 @@ when a cited test no longer exists. | 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` | diff --git a/docs/dev/threat-model.md b/docs/dev/threat-model.md index 72ebf80..999b95e 100644 --- a/docs/dev/threat-model.md +++ b/docs/dev/threat-model.md @@ -133,7 +133,9 @@ at least once a year. - 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. Budgets on - registration bound how fast usernames can be tried; a deployment that treats + 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. ## 6. Verification diff --git a/src/bin/bench_support.rs b/src/bin/bench_support.rs index 9a3a11c..f0606e5 100644 --- a/src/bin/bench_support.rs +++ b/src/bin/bench_support.rs @@ -382,6 +382,7 @@ 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, }, captcha: CaptchaConfig { secret: None, diff --git a/src/config/mod.rs b/src/config/mod.rs index f5e70be..ae4b677 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -189,6 +189,10 @@ 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, } #[derive(Debug, Clone, PartialEq)] @@ -549,6 +553,9 @@ 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), }, mail: MailConfig { smtp: SmtpConfig { @@ -654,7 +661,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), diff --git a/src/config/tests.rs b/src/config/tests.rs index 543cfe8..5fe453b 100644 --- a/src/config/tests.rs +++ b/src/config/tests.rs @@ -70,6 +70,7 @@ fn valid_config() -> Config { sensitive_action_reauth_secs: 600, new_device_alerts: true, magic_links: false, + registrations_per_ip_per_hour: 20, }, mail: MailConfig { smtp: SmtpConfig { diff --git a/src/services/auth/login.rs b/src/services/auth/login.rs index 2f0b2ec..212af8c 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, ) diff --git a/src/services/auth/register.rs b/src/services/auth/register.rs index 46bbf3e..7557c40 100644 --- a/src/services/auth/register.rs +++ b/src/services/auth/register.rs @@ -16,6 +16,22 @@ pub async fn register( 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, diff --git a/src/services/auth/session.rs b/src/services/auth/session.rs index 2bbf5c5..82cc6dd 100644 --- a/src/services/auth/session.rs +++ b/src/services/auth/session.rs @@ -71,13 +71,38 @@ pub async fn refresh_token( }; match session.refresh_verdict(state.clock.now(), ip.map(|n| n.ip()), &policy) { RefreshVerdict::Rotate => {} - RefreshVerdict::ConcurrentRefresh => { - // Two tabs, or a retried request - or a thief refreshing first, - // which leaves the owner signed out: measured, so a spike shows. + 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 => { note_refresh_failure(state, ip).await; @@ -168,6 +193,7 @@ 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); } @@ -276,6 +302,29 @@ 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 { diff --git a/tests/security/regressions/session_hardening.rs b/tests/security/regressions/session_hardening.rs index 3338159..ed5ed5f 100644 --- a/tests/security/regressions/session_hardening.rs +++ b/tests/security/regressions/session_hardening.rs @@ -369,3 +369,60 @@ async fn replayed_refresh_tokens_count_against_the_address() { .unwrap(); assert_eq!(failures, Some(1)); } + +/// Within the grace window, only the client that rotated the session may +/// present the old token again; anyone else is a replay (SEC-61). +#[tokio::test] +async fn a_rotated_token_reused_from_another_client_revokes_the_family() { + let app = TestApp::spawn().await; + let user = fixtures::authenticated_user(&app, 722).await; + let rotated: Value = refresh(&app, &user.refresh_token) + .await + .json() + .await + .unwrap(); + + let b = uuid::Uuid::new_v4().into_bytes(); + let elsewhere = format!("10.{}.{}.{}", 150 + b[0] % 50, b[1], 1 + b[2] % 254); + let res = app + .client + .post(app.url("/auth/refresh")) + .header("x-forwarded-for", &elsewhere) + .header("user-agent", "another-client/1.0") + .json(&json!({ "refresh_token": user.refresh_token })) + .send() + .await + .unwrap(); + assert_eq!(res.status().as_u16(), 401); + + let next = refresh(&app, rotated["refresh_token"].as_str().unwrap()).await; + assert_eq!(next.status().as_u16(), 401, "the family must be revoked"); +} + +/// Registrations from one address are budgeted per hour (SEC-61). +#[tokio::test] +async fn registrations_from_one_address_are_budgeted() { + let app = TestApp::spawn_with_config(|config| { + config.security.registrations_per_ip_per_hour = 2; + }) + .await; + let b = uuid::Uuid::new_v4().into_bytes(); + let from = format!("10.{}.{}.{}", 100 + b[0] % 50, b[1], 1 + b[2] % 254); + let mut statuses = Vec::new(); + for n in 0..3 { + let res = app + .client + .post(app.url("/auth/register")) + .header("x-forwarded-for", &from) + .json(&json!({ + "username": format!("squat{n}_{}", b[3]), + "email": format!("squat{n}_{}@example.com", uuid::Uuid::new_v4().simple()), + "password": "Squatting-Pass-1!", + })) + .send() + .await + .unwrap(); + statuses.push(res.status().as_u16()); + } + assert_eq!(statuses, [202, 202, 429]); +} From 86ed76a4b99f269160abfcf631738ca6d1af090d Mon Sep 17 00:00:00 2001 From: SIIR3X <125802911+SIIR3X@users.noreply.github.com> Date: Tue, 22 Sep 2026 18:36:49 +0200 Subject: [PATCH 25/55] fix(oauth): show and reauthenticate every device approval and keep delegation bound to the client as registered --- CHANGELOG.md | 18 +++ docs/dev/api/openapi.yaml | 33 +++- docs/dev/api/routes.md | 28 ++-- docs/dev/guides/integration.md | 12 +- docs/dev/security-model.md | 24 ++- src/domain/oauth.rs | 52 +++++++ src/handlers/oauth.rs | 22 +-- src/repositories/registered_client.rs | 10 ++ src/services/auth/session.rs | 1 + src/services/auth/tokens.rs | 30 +++- src/services/authorize.rs | 4 + src/services/device.rs | 144 +++++++++++------- src/services/external_identity.rs | 43 ++++-- src/services/oauth.rs | 63 ++++---- src/services/personal_access_token.rs | 1 + src/state.rs | 5 + src/utils/jwt.rs | 3 + .../api/clients/authorization_code.rs | 61 +++++++- .../integration/api/clients/introspection.rs | 54 ++++++- tests/integration/api/clients/metadata.rs | 20 ++- .../integration/api/clients/openid_connect.rs | 6 +- tests/security/delegation.rs | 77 +++++++++- 22 files changed, 567 insertions(+), 144 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e87f85c..4afaeb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,24 @@ from scratch (read **Upgrading**). ### Security +- 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 diff --git a/docs/dev/api/openapi.yaml b/docs/dev/api/openapi.yaml index 4b3b1dc..ead525d 100644 --- a/docs/dev/api/openapi.yaml +++ b/docs/dev/api/openapi.yaml @@ -6642,6 +6642,10 @@ components: - user_code - created_at - reauthentication_required + - scopes + - unrestricted + - unavailable_scopes + - sessions_used properties: client_id: type: @@ -6657,12 +6661,37 @@ components: reauthentication_required: type: boolean description: |- - Approving needs `current_password` (or a recent `POST /users/me/reauth`): - the client is not the instance's own application. + 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 diff --git a/docs/dev/api/routes.md b/docs/dev/api/routes.md index 9a36414..d7207df 100644 --- a/docs/dev/api/routes.md +++ b/docs/dev/api/routes.md @@ -118,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 @@ -158,9 +161,10 @@ included, and `GET /oauth/userinfo` for their access tokens. `profile` releases **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 }`. +(`access_token`, `refresh_token`), `scope`, `client_id`, `sub`, `exp`, `iat` +and, for access tokens, `iss`, `aud` and `jti`. 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 @@ -173,9 +177,11 @@ 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`; approving a client other than the instance's own -application needs `current_password` or a recent re-authentication, which +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). diff --git a/docs/dev/guides/integration.md b/docs/dev/guides/integration.md index 8d3cb9d..38978f5 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 @@ -129,7 +132,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. @@ -161,7 +165,7 @@ A token's claims: | `iss`, `aud`, `iat`, `nbf`, `exp` | Standard | | `roles` | Role names; absent from scoped and client credentials tokens | | `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 | ## 4. Following account changes diff --git a/docs/dev/security-model.md b/docs/dev/security-model.md index 75016a4..5349e3b 100644 --- a/docs/dev/security-model.md +++ b/docs/dev/security-model.md @@ -193,16 +193,24 @@ the database together, is out of scope. - **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. Approving a device of a client other than the instance's - own application requires a re-authentication, like consenting to it. + 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. The redemption + 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. + the session to revoke. 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). - **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 @@ -212,7 +220,10 @@ 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`. A refresh token is + introspected only by its own client, and personal access tokens never are. + 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. ## Administration @@ -404,3 +415,4 @@ when a cited test no longer exists. | 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` | diff --git a/src/domain/oauth.rs b/src/domain/oauth.rs index 86e4390..6971809 100644 --- a/src/domain/oauth.rs +++ b/src/domain/oauth.rs @@ -258,3 +258,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/handlers/oauth.rs b/src/handlers/oauth.rs index 2ea3236..168b8fb 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}, @@ -196,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!({ @@ -224,7 +221,7 @@ pub async fn metadata(State(state): State) -> Result Result, AppError> { validate_user_code(&user_code)?; Ok(Json( - device_svc::describe(&state, auth.session_id, &user_code, ip).await?, + device_svc::describe(&state, auth.user_id, auth.session_id, &user_code, ip).await?, )) } @@ -624,7 +616,7 @@ pub async fn verify_device( ) .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/repositories/registered_client.rs b/src/repositories/registered_client.rs index 2227c17..f96de72 100644 --- a/src/repositories/registered_client.rs +++ b/src/repositories/registered_client.rs @@ -134,3 +134,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/services/auth/session.rs b/src/services/auth/session.rs index 82cc6dd..8c7d35b 100644 --- a/src/services/auth/session.rs +++ b/src/services/auth/session.rs @@ -223,6 +223,7 @@ pub async fn refresh_token( user.id, new_session.id, new_session.scopes.as_deref(), + new_session.client_id.as_deref(), state, ) .await?; diff --git a/src/services/auth/tokens.rs b/src/services/auth/tokens.rs index bafd20d..1010c29 100644 --- a/src/services/auth/tokens.rs +++ b/src/services/auth/tokens.rs @@ -144,7 +144,14 @@ 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(), + state, + ) + .await?; Ok(AuthTokens { access_token, @@ -197,6 +204,7 @@ pub(crate) async fn build_access_token( user_id: Uuid, session_id: uuid::Uuid, scopes: Option<&[String]>, + client_id: Option<&str>, state: &AppState, ) -> Result { let issued_at = state.clock.now(); @@ -214,11 +222,27 @@ 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 current = 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()))? + .map(|client| client.scopes), + None => None, + }; + 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(), + ); 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); // 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. diff --git a/src/services/authorize.rs b/src/services/authorize.rs index dd6c59f..8ca12b8 100644 --- a/src/services/authorize.rs +++ b/src/services/authorize.rs @@ -217,6 +217,10 @@ pub async fn redeem( 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?; diff --git a/src/services/device.rs b/src/services/device.rs index c46ffce..a6c320d 100644 --- a/src/services/device.rs +++ b/src/services/device.rs @@ -106,9 +106,20 @@ 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`): - /// the client is not the instance's own application. + /// 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. @@ -354,26 +365,42 @@ 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 { +async fn note_unknown_code(state: &AppState, ip: Option, user_id: Uuid) { + 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(); + if let Err(e) = redis_counter::consume(&state.redis, &budgets).await { tracing::warn!(error = %e, "could not record an unknown device code lookup"); } } @@ -404,17 +431,19 @@ 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() { @@ -427,9 +456,13 @@ pub async fn describe( .map_err(|e| AppError::Internal(e.into()))?, None => None, }; - let reauthentication_required = match &client { - Some(client) => authorize_svc::requires_reauthentication(state, session_id, client).await, - None => true, + 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 { @@ -440,44 +473,47 @@ pub async fn describe( 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 a signed-in user. /// -/// The approval mints a long-lived session for the client: for a client other -/// than the instance's own application it needs a fresh proof of the password, -/// like consenting to an authorization request. +/// 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).await?; - let Some((_, _, entry)) = load_entry(state, approval.user_code).await? else { - note_unknown_code(state, approval.ip).await; + guard_code_scan(state, approval.ip, approval.user_id).await?; + if load_entry(state, approval.user_code).await?.is_none() { + note_unknown_code(state, approval.ip, approval.user_id).await; return Err(AppError::NotFound); - }; - let primary = match entry.client_id.as_deref() { - Some(cid) => client_repo::find_by_id(&state.db, cid) - .await - .map_err(|e| AppError::Internal(e.into()))? - .is_some_and(|client| client.is_primary), - None => false, - }; - if !primary { - crate::services::reauth::require_recent_reauth_or_password( - state, - approval.user_id, - approval.session_id, - approval.current_password, - approval.ip, - approval.request_id, - "approve_device", - ) - .await?; } + crate::services::reauth::require_recent_reauth_or_password( + state, + approval.user_id, + approval.session_id, + approval.current_password, + approval.ip, + approval.request_id, + "approve_device", + ) + .await?; update_status( state, approval.user_code, DeviceAuthStatus::Authorized, - Some(approval.user_id), + approval.user_id, approval.ip, ) .await @@ -489,31 +525,33 @@ pub async fn verify(state: &AppState, approval: &Approval<'_>) -> Result<(), App /// 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 + update_status(state, user_code, DeviceAuthStatus::Denied, user_id, ip).await } +/// 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?; + 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); entry.status = new_status; - entry.user_id = user_id; let updated = serde_json::to_string(&entry).map_err(|e| AppError::Internal(e.into()))?; let mut conn = state diff --git a/src/services/external_identity.rs b/src/services/external_identity.rs index 77bd265..2ba60ba 100644 --- a/src/services/external_identity.rs +++ b/src/services/external_identity.rs @@ -345,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, false).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, false).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; @@ -368,6 +376,23 @@ 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, diff --git a/src/services/oauth.rs b/src/services/oauth.rs index b8a11de..98e6763 100644 --- a/src/services/oauth.rs +++ b/src/services/oauth.rs @@ -163,7 +163,18 @@ pub async fn authenticate_client( } } Ok(client) => { - let key = format!("oauth_client_rpm:{}", client.client_id); + // 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 { @@ -227,10 +238,12 @@ async fn identify_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)) => { @@ -240,10 +253,10 @@ async fn identify_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) => {} } @@ -299,11 +312,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(|| { @@ -479,6 +495,9 @@ pub async fn approve_request( request_id: Option, ) -> Result { let (request, client) = load_request(state, 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)?; // 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 { @@ -513,6 +532,7 @@ 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"))) } @@ -528,6 +548,7 @@ pub async fn deny_request(state: &AppState, id: &str) -> Result { 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 { @@ -872,7 +893,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 { @@ -886,27 +910,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) } @@ -990,7 +995,7 @@ pub async fn revoke( } } // Personal access tokens belong to accounts, not clients. - Presented::Personal(_) => {} + Presented::Personal => {} } Ok(()) } diff --git a/src/services/personal_access_token.rs b/src/services/personal_access_token.rs index 5dc0d2e..fcfb24e 100644 --- a/src/services/personal_access_token.rs +++ b/src/services/personal_access_token.rs @@ -203,6 +203,7 @@ pub async fn exchange(state: &AppState, presented: &str) -> Result 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/jwt.rs b/src/utils/jwt.rs index 62ee29d..3bd5d85 100644 --- a/src/utils/jwt.rs +++ b/src/utils/jwt.rs @@ -112,6 +112,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())) } diff --git a/tests/integration/api/clients/authorization_code.rs b/tests/integration/api/clients/authorization_code.rs index b718ca3..96381f9 100644 --- a/tests/integration/api/clients/authorization_code.rs +++ b/tests/integration/api/clients/authorization_code.rs @@ -644,6 +644,65 @@ async fn a_registered_redirect_keeps_its_query() { let (_, body) = approve_raw(&app, &user, &request_id, json!({})).await; let url = reqwest::Url::parse(body["redirect_to"].as_str().unwrap()).unwrap(); let names: Vec = url.query_pairs().map(|(k, _)| k.into_owned()).collect(); - assert_eq!(names, ["tenant", "code", "state"]); + assert_eq!(names, ["tenant", "code", "state", "iss"]); assert_eq!(query_param(url.as_str(), "tenant").as_deref(), Some("acme")); + // RFC 9207: the client can check which server answered. + assert_eq!( + query_param(url.as_str(), "iss").as_deref(), + Some(app.state.config.server.public_url.trim_end_matches('/')) + ); +} + +/// A redirect URI removed from the client since the request was made receives +/// no code, and a code already issued for it no longer redeems (SEC-62). +#[tokio::test] +async fn a_redirect_removed_from_the_client_receives_nothing() { + let app = TestApp::spawn().await; + register_client(&app, PARTNER, false, &[], 5).await; + let user = fixtures::authenticated_user(&app, 60).await; + let pkce = super::pkce(); + + let issued = code_for(&app, &user, PARTNER, CALLBACK, &pkce).await; + let request_id = request(&app, PARTNER, CALLBACK, &pkce, &[]).await; + sqlx::query("UPDATE registered_clients SET redirect_uris = ARRAY[$2] WHERE client_id = $1") + .bind(PARTNER) + .bind(LOOPBACK) + .execute(&app.db) + .await + .unwrap(); + + let (status, body) = approve_raw( + &app, + &user, + &request_id, + json!({ "current_password": user.password }), + ) + .await; + assert_ne!(status, 200, "{body}"); + let (status, body) = redeem(&app, &issued, &pkce.verifier, PARTNER, CALLBACK).await; + assert_eq!( + (status, body["error"].as_str()), + (400, Some("invalid_grant")), + "{body}" + ); +} + +/// Access tokens are typed `at+jwt` and name their client (RFC 9068). +#[tokio::test] +async fn a_client_access_token_is_typed_and_names_its_client() { + let app = TestApp::spawn().await; + register_client(&app, PARTNER, false, &[], 5).await; + let user = fixtures::authenticated_user(&app, 61).await; + let pkce = super::pkce(); + let code = code_for(&app, &user, PARTNER, CALLBACK, &pkce).await; + let (status, tokens) = redeem(&app, &code, &pkce.verifier, PARTNER, CALLBACK).await; + assert_eq!(status, 200, "{tokens}"); + + let access = tokens["access_token"].as_str().unwrap(); + let header = jsonwebtoken::decode_header(access).unwrap(); + assert_eq!(header.typ.as_deref(), Some("at+jwt")); + assert_eq!( + app.decode_access_token(access).client_id.as_deref(), + Some(PARTNER) + ); } diff --git a/tests/integration/api/clients/introspection.rs b/tests/integration/api/clients/introspection.rs index ad64c68..e5e2a22 100644 --- a/tests/integration/api/clients/introspection.rs +++ b/tests/integration/api/clients/introspection.rs @@ -64,6 +64,15 @@ async fn client_tokens(app: &TestApp, user: &AuthenticatedUser) -> Value { tokens } +/// Sessions of `user_id` not revoked. +async fn live_sessions(app: &TestApp, user_id: uuid::Uuid) -> i64 { + sqlx::query_scalar("SELECT count(*) FROM sessions WHERE user_id = $1 AND revoked_at IS NULL") + .bind(user_id) + .fetch_one(&app.db) + .await + .unwrap() +} + async fn introspect(app: &TestApp, token: &str) -> Value { let (status, body) = form( app, @@ -101,9 +110,9 @@ async fn a_resource_server_learns_what_a_token_is_worth() { assert_eq!(access["sub"], user.id.to_string()); assert!(access["exp"].is_number() && access["jti"].is_string()); + // A refresh token is its client's secret: another client learns nothing. let refresh = introspect(&app, tokens["refresh_token"].as_str().unwrap()).await; - assert_eq!(refresh["active"], true); - assert_eq!(refresh["token_type"], "refresh_token"); + assert_eq!(refresh, json!({ "active": false })); // A first-party session has no client. let own = introspect(&app, &user.access_token).await; @@ -182,8 +191,9 @@ async fn revoking_an_access_token_ends_that_token_only() { assert_eq!(introspect(&app, access).await["active"], false); assert_eq!(app.get_auth("/users/me", access).await.status(), 401); assert_eq!( - introspect(&app, tokens["refresh_token"].as_str().unwrap()).await["active"], - true + live_sessions(&app, user.id).await, + 2, + "the session lives on" ); } @@ -201,6 +211,42 @@ async fn a_client_cannot_revoke_the_tokens_of_another() { user.refresh_token.as_str(), ] { assert_eq!(revoke(&app, token, "other-app").await, 200); + } + for token in [ + tokens["access_token"].as_str().unwrap(), + user.access_token.as_str(), + ] { assert_eq!(introspect(&app, token).await["active"], true, "{token}"); } + assert_eq!( + live_sessions(&app, user.id).await, + 2, + "no session was revoked" + ); +} + +/// Personal access tokens belong to accounts, and refresh tokens to their +/// client: introspection by another client says nothing of either (SEC-62). +#[tokio::test] +async fn introspection_reveals_no_personal_or_foreign_refresh_token() { + let app = TestApp::spawn().await; + setup(&app).await; + let user = fixtures::authenticated_user(&app, 2).await; + let created: Value = app + .post_auth( + "/users/me/tokens", + &user.access_token, + &json!({ "name": "ci" }), + ) + .await + .json() + .await + .unwrap(); + + for token in [ + created["secret"].as_str().unwrap(), + user.refresh_token.as_str(), + ] { + assert_eq!(introspect(&app, token).await, json!({ "active": false })); + } } diff --git a/tests/integration/api/clients/metadata.rs b/tests/integration/api/clients/metadata.rs index 09a5fdd..b240bb7 100644 --- a/tests/integration/api/clients/metadata.rs +++ b/tests/integration/api/clients/metadata.rs @@ -7,6 +7,13 @@ use crate::common::app::TestApp; #[tokio::test] async fn the_metadata_describes_the_endpoints_and_capabilities() { let app = TestApp::spawn().await; + sqlx::query( + "INSERT INTO registered_clients (client_id, display_name, scopes) + VALUES ('reporting', 'Reporting', ARRAY['users:read'])", + ) + .execute(&app.db) + .await + .unwrap(); let response = app.get("/.well-known/oauth-authorization-server").await; assert_eq!(response.status(), 200); assert_eq!(response.headers()["cache-control"], "public, max-age=300"); @@ -29,10 +36,13 @@ async fn the_metadata_describes_the_endpoints_and_capabilities() { .unwrap() .contains(&"urn:ietf:params:oauth:grant-type:device_code".into()) ); - assert!( - metadata["scopes_supported"] - .as_array() - .unwrap() - .contains(&"users:read".into()) + // Only what some client may ask for: not the whole permission catalog. + assert_eq!( + metadata["scopes_supported"], + serde_json::json!(["users:read"]) + ); + assert_eq!( + metadata["authorization_response_iss_parameter_supported"], + true ); } diff --git a/tests/integration/api/clients/openid_connect.rs b/tests/integration/api/clients/openid_connect.rs index 688f0e0..b4d4c87 100644 --- a/tests/integration/api/clients/openid_connect.rs +++ b/tests/integration/api/clients/openid_connect.rs @@ -164,5 +164,9 @@ async fn the_provider_publishes_its_configuration() { ); assert_eq!(configuration["subject_types_supported"], json!(["public"])); let scopes = configuration["scopes_supported"].as_array().unwrap(); - assert!(scopes.contains(&json!("openid")) && scopes.contains(&json!("users:read"))); + assert!(scopes.contains(&json!("openid"))); + assert!( + !scopes.contains(&json!("roles:manage")), + "no client may ask for it: unlisted" + ); } diff --git a/tests/security/delegation.rs b/tests/security/delegation.rs index d401116..8382226 100644 --- a/tests/security/delegation.rs +++ b/tests/security/delegation.rs @@ -250,9 +250,84 @@ async fn approving_another_client_needs_a_recent_reauthentication() { .await; assert_eq!(status, StatusCode::OK, "{body}"); - // The instance's own application needs none. + // The instance's own application too: a code handed over by someone + // else is how a device flow is phished (SEC-62). app.clear_recent_reauth(&user.access_token).await; let (_, user_code) = start_device_flow(&app, "primary-app").await; let (status, body) = approve(&app, &user.access_token, json!({ "user_code": user_code })).await; + assert_eq!(status, StatusCode::FORBIDDEN, "{body}"); + let (status, body) = approve( + &app, + &user.access_token, + json!({ "user_code": user_code, "current_password": user.password }), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); +} + +/// The approval screen of a device says how much of the account it would get, +/// like the consent of the authorization code flow (SEC-62). +#[tokio::test] +async fn the_device_approval_screen_shows_what_it_grants() { + let app = TestApp::spawn().await; + register_client(&app, "primary-app", true).await; + sqlx::query( + "INSERT INTO registered_clients (client_id, display_name, is_primary, default_max_sessions, scopes) + VALUES ('reporting', 'reporting', FALSE, 5, ARRAY['users:read'])", + ) + .execute(&app.db) + .await + .unwrap(); + let user = fixtures::authenticated_user(&app, 5).await; + + let (_, user_code) = start_device_flow(&app, "primary-app").await; + let preview: Value = app + .get_auth(&format!("/oauth/device/{user_code}"), &user.access_token) + .await + .json() + .await + .unwrap(); + assert_eq!(preview["unrestricted"], true, "{preview}"); + + let (status, body) = form( + &app, + "/oauth/device_authorization", + &[("client_id", "reporting"), ("scope", "users:read")], + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let preview: Value = app + .get_auth( + &format!("/oauth/device/{}", body["user_code"].as_str().unwrap()), + &user.access_token, + ) + .await + .json() + .await + .unwrap(); + assert_eq!(preview["unrestricted"], false, "{preview}"); + assert_eq!(preview["unavailable_scopes"], json!(["users:read"])); +} + +/// A public client is only named: a flood naming it from other addresses does +/// not spend the budget of its users (SEC-62). +#[tokio::test] +async fn a_public_clients_budget_is_split_by_address() { + use deadpool_redis::redis::AsyncCommands; + + let app = TestApp::spawn().await; + register_client(&app, "public-app", false).await; + let mut conn = app.redis.get().await.unwrap(); + let _: () = conn + .set_ex("oauth_client_rpm:public-app", 1_000_000, 60) + .await + .unwrap(); + + let (status, body) = form( + &app, + "/oauth/device_authorization", + &[("client_id", "public-app")], + ) + .await; assert_eq!(status, StatusCode::OK, "{body}"); } From c506cd90bf20ca62d1c50ad72c99ff9a77de4204 Mon Sep 17 00:00:00 2001 From: SIIR3X <125802911+SIIR3X@users.noreply.github.com> Date: Tue, 22 Sep 2026 22:48:55 +0200 Subject: [PATCH 26/55] fix(db): keep owner-only floors in maintenance functions, read only row-bound secrets and require connection passwords --- CHANGELOG.md | 12 +++- deploy/db/auth-api-grants.sql | 3 + deploy/db/pg_hba.auth-api.conf | 12 ++++ deploy/db/users.acl.template | 7 +++ docs/deploy/database/deployment.md | 10 ++- docs/deploy/guides/operations.md | 11 ++-- docs/dev/guides/commands.md | 2 +- docs/dev/guides/configuration.md | 3 +- docs/dev/security-model.md | 15 +++-- migrations/0010_audit_log.sql | 30 +++++++-- migrations/0012_personal_data.sql | 5 +- migrations/SHA256SUMS | 4 +- src/config/env_vars.rs | 9 +++ src/config/mod.rs | 2 +- src/config/tests.rs | 31 +++++++++- src/config/validate.rs | 26 ++++++++ src/services/key_rotation.rs | 10 +-- src/utils/crypto.rs | 65 ++++++++------------ src/utils/totp.rs | 5 +- tests/integration/migrations/runtime_role.rs | 49 +++++++++++++++ tests/integration/services/key_rotation.rs | 50 +++------------ tests/security/headers.rs | 9 ++- 22 files changed, 255 insertions(+), 115 deletions(-) create mode 100644 deploy/db/pg_hba.auth-api.conf create mode 100644 deploy/db/users.acl.template diff --git a/CHANGELOG.md b/CHANGELOG.md index 4afaeb1..d807232 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,16 @@ from scratch (read **Upgrading**). ### Security +- 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 @@ -173,8 +183,6 @@ from scratch (read **Upgrading**). after `deploy/db/auth-api-grants.sql`. - Check the settings refused at start-up (listed under Security) against your environment before upgrading. -- Run `auth-api --rotate-totp-keys` once, without `PREVIOUS_ENCRYPTION_KEY`, to - bind the secrets written before the upgrade to their rows. - Monitoring that reads the dependencies from the public `/ready` must query the internal listener instead (`http://10.0.0.1:9465/ready`). - Copy the new `log_parameter_max_length` lines of diff --git a/deploy/db/auth-api-grants.sql b/deploy/db/auth-api-grants.sql index 8ba0a5b..7771158 100644 --- a/deploy/db/auth-api-grants.sql +++ b/deploy/db/auth-api-grants.sql @@ -44,3 +44,6 @@ $$; -- 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/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/users.acl.template b/deploy/db/users.acl.template new file mode 100644 index 0000000..862e969 --- /dev/null +++ b/deploy/db/users.acl.template @@ -0,0 +1,7 @@ +# 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. +user default off +user auth_api on #REDIS_PASSWORD_SHA256 ~* &* +@all -@dangerous -@admin diff --git a/docs/deploy/database/deployment.md b/docs/deploy/database/deployment.md index 2f53a1c..47a192b 100644 --- a/docs/deploy/database/deployment.md +++ b/docs/deploy/database/deployment.md @@ -160,7 +160,13 @@ 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 @@ -307,7 +313,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 diff --git a/docs/deploy/guides/operations.md b/docs/deploy/guides/operations.md index 9989e9b..0310984 100644 --- a/docs/deploy/guides/operations.md +++ b/docs/deploy/guides/operations.md @@ -63,9 +63,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:** @@ -93,10 +94,6 @@ and resumed. Run it until it reports `rotated=0 failed=0`: secrets already under the new key are skipped, and a secret changed during the run is left as the service wrote it. - - Without `PREVIOUS_ENCRYPTION_KEY`, the same command rewrites secrets written - before 2.1.0 in the current format, which binds each one to its account or - endpoint; run it once after upgrading. 4. Remove the previous key and redeploy: `pass rm prod/auth-api/previous-encryption-key`, then the update guide's exports again (the previous key is now unset). diff --git a/docs/dev/guides/commands.md b/docs/dev/guides/commands.md index b2db987..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`, bound to their rows; without `PREVIOUS_ENCRYPTION_KEY` it only upgrades secrets written in an older format (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 274ea66..a8e3e5e 100644 --- a/docs/dev/guides/configuration.md +++ b/docs/dev/guides/configuration.md @@ -257,7 +257,8 @@ 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`; -- `ARGON2_MEMORY_KIB` is under `19456` or `ARGON2_ITERATIONS` under `2`. +- `ARGON2_MEMORY_KIB` is under `19456` or `ARGON2_ITERATIONS` under `2`; +- `DATABASE_URL`, `DATABASE_READ_URL` or `REDIS_URL` carries no password. In every environment, the service also refuses to start when: diff --git a/docs/dev/security-model.md b/docs/dev/security-model.md index 5349e3b..004d805 100644 --- a/docs/dev/security-model.md +++ b/docs/dev/security-model.md @@ -140,8 +140,9 @@ the database together, is out of scope. - 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, 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 - service refuses to start while a secret names a key it no longer holds. + associated data: a ciphertext copied onto another row does not decrypt. 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, @@ -299,8 +300,13 @@ the database together, is out of scope. 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. PostgreSQL logs slow statements without their bound - values. + 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. 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 @@ -416,3 +422,4 @@ when a cited test no longer exists. | 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` | diff --git a/migrations/0010_audit_log.sql b/migrations/0010_audit_log.sql index db8f68e..c7448bb 100644 --- a/migrations/0010_audit_log.sql +++ b/migrations/0010_audit_log.sql @@ -84,10 +84,30 @@ 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', + + 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 @@ -96,7 +116,9 @@ 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; + 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; diff --git a/migrations/0012_personal_data.sql b/migrations/0012_personal_data.sql index 689a65c..d4329c0 100644 --- a/migrations/0012_personal_data.sql +++ b/migrations/0012_personal_data.sql @@ -47,7 +47,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 @@ -114,7 +115,7 @@ 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; diff --git a/migrations/SHA256SUMS b/migrations/SHA256SUMS index 1f70bf5..d5574fc 100644 --- a/migrations/SHA256SUMS +++ b/migrations/SHA256SUMS @@ -7,9 +7,9 @@ f49cf1363738d98e0faab782cf8db3907097da2897ef60aec0969d0ea41bc47f 0005_sessions. 9a77f658341cc377e103c9f95687816d35e4eb8d30d04b1e79f48364ae8296c6 0007_two_factor.sql c155c5d85738ffc9b08a879001e05875fc4b263afde85b56f32f96f5abf375a5 0008_account_tokens.sql 6c37703c71c8892899764c86bf00b86f09afd362ac17262d4b27045643c316e8 0009_login_attempts.sql -84918e9b9f382af46704a085ecdd3e3fa38fac195244b4375efd7d3504b27153 0010_audit_log.sql +0a44b3868aa9e4ea6c1d11eb561756034d77a0cab3197e2e03bae374be15433c 0010_audit_log.sql 76b27021b2b3e978ee4222edf821fbda8d78f39693fdd446279dc4d4d61377dc 0011_event_outbox.sql -a75ca9d75ea094cf37fff95f700256371b1dabe71840c2d94ed7407198354cae 0012_personal_data.sql +c5192b0923eca493d1c194d74d6db96d1c1804e7dffb7f5445be6761c7e26144 0012_personal_data.sql f6434d27992410fe4844cf77d826695d2c064bf9f7cc2533d6ad43982493c011 0013_known_devices.sql b4d5cc42ee28d7ed7f91888176b18a5d9e3f195fe15f131896f4a128b34a997a 0014_magic_links.sql 84077e0347d23ace25d6cc07e19d83dabd2652fdf33794de759765730b5fa6f0 0015_personal_access_tokens.sql diff --git a/src/config/env_vars.rs b/src/config/env_vars.rs index dd6de89..dfbad7e 100644 --- a/src/config/env_vars.rs +++ b/src/config/env_vars.rs @@ -116,6 +116,15 @@ pub(super) fn values_from_files( 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 ae4b677..acf1f19 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -440,7 +440,7 @@ impl Config { pub fn from_env() -> Result { dotenvy::dotenv().ok(); let files = values_from_files(std::env::vars(), |path| std::fs::read_to_string(path))?; - Self::from_lookup(|key| std::env::var(key).ok().or_else(|| files.get(key).cloned())) + 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: diff --git a/src/config/tests.rs b/src/config/tests.rs index 5fe453b..bf028aa 100644 --- a/src/config/tests.rs +++ b/src/config/tests.rs @@ -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, }, @@ -1216,3 +1216,32 @@ fn an_unreadable_secret_file_stops_the_start() { 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") + ); +} diff --git a/src/config/validate.rs b/src/config/validate.rs index 579209a..f19e5e3 100644 --- a/src/config/validate.rs +++ b/src/config/validate.rs @@ -147,6 +147,24 @@ impl Config { }); } + // 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. @@ -565,3 +583,11 @@ pub(super) fn validate_cors(cors: &CorsConfig, is_production: bool) -> Result<() 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/services/key_rotation.rs b/src/services/key_rotation.rs index 3aabd22..54957c9 100644 --- a/src/services/key_rotation.rs +++ b/src/services/key_rotation.rs @@ -148,8 +148,9 @@ pub async fn rotate_totp_encryption_key(state: &AppState) -> Result Result { let kids: Vec = state .keyring @@ -160,9 +161,10 @@ pub async fn secrets_under_unknown_keys(state: &AppState) -> Result ALL($1)) + 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 ~ '^v[12]:' AND split_part(secret, ':', 2) <> ALL($1))", + WHERE secret !~ '^v2:' OR split_part(secret, ':', 2) <> ALL($1))", ) .bind(&kids) .fetch_one(&state.db) diff --git a/src/utils/crypto.rs b/src/utils/crypto.rs index ed224b3..c13474b 100644 --- a/src/utils/crypto.rs +++ b/src/utils/crypto.rs @@ -119,9 +119,6 @@ pub fn decode_encryption_key(b64: &str) -> Result<[u8; 32], CryptoError> { // Keyring -/// Prefix of versioned ciphertexts without associated data: -/// `v1:{kid}:{base64(nonce || ciphertext)}`. Still read, never written. -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:"; @@ -226,25 +223,16 @@ impl Keyring { )) } - /// Decrypt a value written by [`Keyring::encrypt`] for `context`, or an - /// older value (`v1`, or unversioned) that carries no context. + /// 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 { - if let Some(rest) = stored.strip_prefix(V2_PREFIX) { - let (kid, body) = rest.split_once(':').ok_or(CryptoError::InvalidInput)?; - let key = self.key_for(kid).ok_or(CryptoError::UnknownKey)?; - return decrypt_with_aad(body, key, &v2_aad(context)); - } - 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), - }), - } + 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 another key, or in a @@ -256,15 +244,13 @@ impl Keyring { .is_none_or(|(kid, _)| kid != self.current.kid) } - /// Whether `stored` names a key this keyring holds. Unversioned values name - /// none and are not judged. + /// 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 { - let named = stored + stored .strip_prefix(V2_PREFIX) - .or_else(|| stored.strip_prefix(V1_PREFIX)) .and_then(|rest| rest.split_once(':')) - .map(|(kid, _)| kid); - named.is_none_or(|kid| self.key_for(kid).is_some()) + .is_some_and(|(kid, _)| self.key_for(kid).is_some()) } /// Identifiers of the keys this keyring holds, current first. @@ -502,23 +488,27 @@ mod tests { } #[test] - fn keyring_reads_the_previous_key_and_the_older_formats() { + 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 v1_old = format!( + let rotating = Keyring::new([2u8; 32], Some([1u8; 32])); + 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 legacy_old = encrypt("secret", &[1u8; 32]).unwrap(); - - let rotating = Keyring::new([2u8; 32], Some([1u8; 32])); - for stored in [&v2_old, &v1_old, &legacy_old] { - assert_eq!(rotating.decrypt(stored, b"row").unwrap(), "secret"); - assert!(rotating.needs_rotation(stored)); + 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)); } - // A v1 value under the current key is rewritten too, to gain its context. - assert!(old.needs_rotation(&v1_old)); } #[test] @@ -532,7 +522,6 @@ mod tests { Err(CryptoError::UnknownKey) )); assert!(!other.knows_key_of(&stored)); - assert!(other.knows_key_of(&encrypt("legacy", &[1u8; 32]).unwrap())); } #[test] diff --git a/src/utils/totp.rs b/src/utils/totp.rs index 1cb7772..811d20c 100644 --- a/src/utils/totp.rs +++ b/src/utils/totp.rs @@ -109,7 +109,6 @@ fn percent_encode(input: &str) -> String { #[cfg(test)] mod tests { use super::*; - use crate::utils::crypto; fn keyring() -> Keyring { Keyring::new(*KEY, None) @@ -157,7 +156,9 @@ mod tests { } fn encrypted_rfc_secret() -> String { - crypto::encrypt(RFC_SECRET, KEY).unwrap() + Keyring::new(*KEY, None) + .encrypt(RFC_SECRET, uuid::Uuid::nil().as_bytes()) + .unwrap() } #[test] diff --git a/tests/integration/migrations/runtime_role.rs b/tests/integration/migrations/runtime_role.rs index ed65bb2..9147167 100644 --- a/tests/integration/migrations/runtime_role.rs +++ b/tests/integration/migrations/runtime_role.rs @@ -68,6 +68,7 @@ async fn the_runtime_role_cannot_erase_the_audit_trail_or_alter_the_schema() { "DELETE FROM permissions", "UPDATE permissions SET description = 'planted'", "DELETE FROM _sqlx_migrations", + "UPDATE maintenance_floors SET audit_retention_months = 1", "CREATE TABLE planted (id INT)", "ALTER TABLE users ADD COLUMN planted TEXT", ] { @@ -134,3 +135,51 @@ async fn the_runtime_role_does_everything_the_service_needs() { .unwrap(); assert_eq!(orphaned, 1); } + +/// The maintenance functions run with the owner's privileges but keep the +/// owner's floors: the runtime role cannot use them to drop recent audit +/// partitions, coarsen fresh addresses or purge new pending accounts (SEC-63). +#[tokio::test] +async fn the_maintenance_functions_keep_the_owners_floors() { + let db = TestDb::new().await; + let three_months_ago: String = sqlx::query_scalar( + "SELECT to_char(date_trunc('month', NOW()) - INTERVAL '3 months', 'YYYY_MM')", + ) + .fetch_one(&db.pool) + .await + .unwrap(); + sqlx::raw_sql(&format!( + "CREATE TABLE IF NOT EXISTS audit_log_{three_months_ago} PARTITION OF audit_log + FOR VALUES FROM (date_trunc('month', NOW()) - INTERVAL '3 months') + TO (date_trunc('month', NOW()) - INTERVAL '2 months')" + )) + .execute(&db.pool) + .await + .unwrap(); + sqlx::query( + "INSERT INTO users (username, email, password_hash) + VALUES ('fresh_pending', 'fresh.pending@example.com', repeat('h', 60))", + ) + .execute(&db.pool) + .await + .unwrap(); + let mut runtime = runtime_connection(&db).await; + + sqlx::raw_sql("SELECT rotate_audit_log_partitions(1)") + .execute(&mut runtime) + .await + .unwrap(); + let kept: bool = sqlx::query_scalar("SELECT to_regclass($1) IS NOT NULL") + .bind(format!("audit_log_{three_months_ago}")) + .fetch_one(&db.pool) + .await + .unwrap(); + assert!(kept, "a partition within the floor was dropped"); + + let purged: i32 = + sqlx::query_scalar("SELECT purge_unverified_accounts('0 seconds'::interval, 10)") + .fetch_one(&mut runtime) + .await + .unwrap(); + assert_eq!(purged, 0, "an account pending for minutes was purged"); +} diff --git a/tests/integration/services/key_rotation.rs b/tests/integration/services/key_rotation.rs index 7344853..eec5897 100644 --- a/tests/integration/services/key_rotation.rs +++ b/tests/integration/services/key_rotation.rs @@ -198,13 +198,14 @@ async fn rotate_is_idempotent_when_run_twice() { ); } +/// A secret in a format without its row bound in (unversioned or `v1`) is +/// never read: it could be moved to another account's row. It is found before +/// the service starts serving, like a secret under a removed key (SEC-63). #[tokio::test] -async fn rotate_upgrades_secrets_written_before_ciphertexts_were_versioned() { +async fn secrets_in_an_unbound_format_stop_the_start() { use auth_api::utils::crypto; let key_a = crypto::decode_encryption_key(KEY_A).unwrap(); - let key_b = crypto::decode_encryption_key(KEY_B).unwrap(); - let app = TestApp::spawn_with_config(|c| { c.crypto.encryption_key = KEY_A.into(); }) @@ -219,54 +220,19 @@ async fn rotate_upgrades_secrets_written_before_ciphertexts_were_versioned() { .await; assert_eq!(setup_res.status().as_u16(), 200); - // Put the secret back in the pre-versioning format: bare base64, no key id. - let stored: String = sqlx::query_scalar( - "SELECT totp_secret FROM two_factor_methods WHERE user_id = $1 AND method_type = 'totp'", - ) - .bind(user.id) - .fetch_one(&app.db) - .await - .unwrap(); - let plaintext = crypto::Keyring::new(key_a, None) - .decrypt(&stored, user.id.as_bytes()) - .unwrap(); - let legacy = crypto::encrypt(&plaintext, &key_a).unwrap(); + let legacy = crypto::encrypt("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ", &key_a).unwrap(); sqlx::query("UPDATE two_factor_methods SET totp_secret = $1 WHERE user_id = $2") .bind(&legacy) .bind(user.id) .execute(&app.db) .await .unwrap(); - - let rot_state = rotation_state(&app, KEY_B, KEY_A).await; - let result = rotate_totp_encryption_key(&rot_state).await.unwrap(); - assert_eq!((result.rotated, result.failed), (1, 0)); - - let after: String = sqlx::query_scalar( - "SELECT totp_secret FROM two_factor_methods WHERE user_id = $1 AND method_type = 'totp'", - ) - .bind(user.id) - .fetch_one(&app.db) - .await - .unwrap(); - assert!( - after.starts_with("v2:"), - "rotated secrets are bound to their row" - ); assert_eq!( - crypto::Keyring::new(key_b, None) - .decrypt(&after, user.id.as_bytes()) + auth_api::services::key_rotation::secrets_under_unknown_keys(&app.state) + .await .unwrap(), - plaintext + 1 ); - - let audited: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM audit_log WHERE action = 'encryption_key_rotated'", - ) - .fetch_one(&app.db) - .await - .unwrap(); - assert_eq!(audited, 1, "a rotation is audited under its own action"); } /// A secret written under a key the configuration no longer holds is found diff --git a/tests/security/headers.rs b/tests/security/headers.rs index c0eb5a7..4f4e296 100644 --- a/tests/security/headers.rs +++ b/tests/security/headers.rs @@ -216,17 +216,22 @@ async fn security_headers_enable_hsts_for_https_production() { config.crypto.argon2_iterations = 2; // The committed development AES key is refused in production. config.crypto.encryption_key = "6M+xtK7VzYMoz/3mc3vJf2e6h9b9yLyx3Eabo/236YE=".into(); + // Production requires a password in REDIS_URL; the test Redis has + // none, so this app only answers what needs no Redis (the probes). + let mut redis = reqwest::Url::parse(&config.redis.url).unwrap(); + redis.set_password(Some("unused")).unwrap(); + config.redis.url = redis.to_string(); }) .await; let res = app .client - .get(format!("{}/users/me", app.base_url)) + .get(format!("{}/live", app.base_url)) .send() .await .unwrap(); - assert_eq!(res.status().as_u16(), 401); + assert_eq!(res.status().as_u16(), 200); assert_eq!( res.headers() .get("strict-transport-security") From d6c6453774d1c3d91b31176f7af735aa80259ba9 Mon Sep 17 00:00:00 2001 From: SIIR3X <125802911+SIIR3X@users.noreply.github.com> Date: Wed, 23 Sep 2026 03:01:01 +0200 Subject: [PATCH 27/55] fix(config): refuse settings that undo the lockout, the device code lifetime or the trusted proxy boundary --- CHANGELOG.md | 7 +++++ docs/dev/guides/configuration.md | 17 +++++++++--- docs/dev/security-model.md | 1 + src/config/env_vars.rs | 5 +++- src/config/tests.rs | 35 ++++++++++++++++++++++++ src/config/validate.rs | 46 ++++++++++++++++++++++++++++++-- tests/security/headers.rs | 1 + 7 files changed, 106 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d807232..e336a5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,13 @@ from scratch (read **Upgrading**). ### Security +- 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 diff --git a/docs/dev/guides/configuration.md b/docs/dev/guides/configuration.md index a8e3e5e..dcbab99 100644 --- a/docs/dev/guides/configuration.md +++ b/docs/dev/guides/configuration.md @@ -244,8 +244,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 wider than `/8` (IPv4) or `/32` (IPv6), from + which any peer 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; @@ -268,4 +271,12 @@ In every environment, the service also refuses to start when: - `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`. +- `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/security-model.md b/docs/dev/security-model.md index 004d805..179e1bf 100644 --- a/docs/dev/security-model.md +++ b/docs/dev/security-model.md @@ -423,3 +423,4 @@ when a cited test no longer exists. | 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` | diff --git a/src/config/env_vars.rs b/src/config/env_vars.rs index dfbad7e..cfd4b1e 100644 --- a/src/config/env_vars.rs +++ b/src/config/env_vars.rs @@ -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 diff --git a/src/config/tests.rs b/src/config/tests.rs index bf028aa..9b2ec09 100644 --- a/src/config/tests.rs +++ b/src/config/tests.rs @@ -1245,3 +1245,38 @@ fn validate_rejects_production_connections_without_a_password() { 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/7"] { + 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:?}" + ); +} diff --git a/src/config/validate.rs b/src/config/validate.rs index f19e5e3..386e60b 100644 --- a/src/config/validate.rs +++ b/src/config/validate.rs @@ -54,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 @@ -64,6 +68,21 @@ 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 too wide -- name the reverse proxies, or any peer can forge X-Forwarded-For" + ), + }); + } validate_production_argon2(&self.crypto)?; validate_production_encryption_key("ENCRYPTION_KEY", &self.crypto.encryption_key)?; @@ -116,6 +135,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)?; } @@ -460,6 +483,13 @@ 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 || security.sensitive_action_reauth_secs > MAX_REAUTH_WINDOW_SECS @@ -475,10 +505,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: @@ -498,6 +529,17 @@ pub(super) fn validate_device_auth(device: &DeviceAuthConfig) -> Result<(), Conf Ok(()) } +/// 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; + +/// Widest trusted proxy network: anything wider lets arbitrary peers forge +/// `X-Forwarded-For` and pick their own address. +const MIN_TRUSTED_PROXY_PREFIX_V4: u8 = 8; +const MIN_TRUSTED_PROXY_PREFIX_V6: u8 = 32; + /// 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; diff --git a/tests/security/headers.rs b/tests/security/headers.rs index 4f4e296..1c9fa74 100644 --- a/tests/security/headers.rs +++ b/tests/security/headers.rs @@ -208,6 +208,7 @@ async fn security_headers_enable_hsts_for_https_production() { config.jwt.strict_session_binding = true; config.webhooks.allow_http = false; config.device_auth.consent_uri = "https://app.example.com/authorize".into(); + config.device_auth.verification_uri = "https://app.example.com/device".into(); config.webauthn.rp_id = "app.example.com".into(); config.external_login_uri = "https://app.example.com/external-login".into(); config.webauthn.origins = vec!["https://app.example.com".into()]; From d92cbe30fb9988f035eca0ccbf026064060b56b7 Mon Sep 17 00:00:00 2001 From: SIIR3X <125802911+SIIR3X@users.noreply.github.com> Date: Wed, 23 Sep 2026 07:13:07 +0200 Subject: [PATCH 28/55] fix(deploy): align nginx limits with the API, protect the internal listener and mount secrets as files --- CHANGELOG.md | 24 +++++- Cargo.lock | 1 + Cargo.toml | 1 + Makefile | 2 +- crates/testkit/src/app.rs | 1 + deploy/db/pgbackrest.conf | 20 ++++- .../monitoring/docker-compose.monitoring.yml | 2 + deploy/monitoring/prometheus.yml | 5 ++ docker-compose.api.yml | 78 +++++++++++++---- docs/deploy/api/nginx.md | 11 +-- docs/deploy/api/secrets.md | 29 +++++-- docs/deploy/database/deployment.md | 10 ++- docs/deploy/guides/monitoring.md | 11 ++- docs/deploy/guides/operations.md | 12 +-- docs/deploy/guides/update.md | 11 ++- docs/dev/security-model.md | 10 ++- nginx/nginx.conf | 61 +++++++------- scripts/backup-drill.sh | 4 +- scripts/infra-check.sh | 6 +- scripts/restore-db.sh | 30 +++++-- scripts/stack-smoke.sh | 32 +++++-- scripts/write-secrets.sh | 40 +++++++++ src/bin/bench_support.rs | 1 + src/config/mod.rs | 17 +++- src/config/tests.rs | 1 + src/config/validate.rs | 19 +++++ src/handlers/mod.rs | 84 +++++++++++++++++-- src/openapi.rs | 81 +++++++++++++++++- src/state.rs | 4 + tests/simulation/dependencies.rs | 4 +- 30 files changed, 502 insertions(+), 110 deletions(-) create mode 100755 scripts/write-secrets.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index e336a5d..8a19bde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,24 @@ from scratch (read **Upgrading**). ### Security +- 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 @@ -191,7 +209,11 @@ from scratch (read **Upgrading**). - 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`). + 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. +- 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. diff --git a/Cargo.lock b/Cargo.lock index 56da1fb..d379e93 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -218,6 +218,7 @@ dependencies = [ "proptest", "rand 0.10.2", "rand_core 0.6.4", + "regex", "reqwest", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 0f9adac..1f742e5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -71,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/Makefile b/Makefile index e10af04..eaf6664 100644 --- a/Makefile +++ b/Makefile @@ -227,7 +227,7 @@ release: infra-check stack-test ## Build a signed release bundle in dist/ (VERSI 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 \ + 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/crates/testkit/src/app.rs b/crates/testkit/src/app.rs index bd88966..437bc1f 100644 --- a/crates/testkit/src/app.rs +++ b/crates/testkit/src/app.rs @@ -603,6 +603,7 @@ pub fn test_config(db_url: &str, redis_url: &str, nats_url: &str) -> Config { metrics: MetricsConfig { enabled: false, port: 9464, + token: None, }, } } 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/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/docker-compose.api.yml b/docker-compose.api.yml index 43d1fa9..239b988 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. @@ -163,6 +182,33 @@ services: - 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). diff --git a/docs/deploy/api/nginx.md b/docs/deploy/api/nginx.md index f549ae7..201b8b4 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,7 +114,7 @@ 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 diff --git a/docs/deploy/api/secrets.md b/docs/deploy/api/secrets.md index 0f6cba3..a0d7427 100644 --- a/docs/deploy/api/secrets.md +++ b/docs/deploy/api/secrets.md @@ -2,15 +2,16 @@ [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 they are +exported, then `scripts/write-secrets.sh` writes them 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. -An orchestrator that mounts secrets as files (Docker Swarm, Kubernetes, -systemd credentials) can pass any of them as `X_FILE`, the path of the file, -instead of `X`: the value then appears neither in `docker inspect` nor in the -process environment. The image runs as UID 65532, which must be able to read -the file. The reference `docker-compose.api.yml` keeps environment variables: -compose mounts file secrets into a read-only container only from host files, -which would put the secrets on the API VPS's disk. +Any other orchestrator that mounts secrets as files (Docker Swarm, Kubernetes, +systemd credentials) can pass them as `X_FILE` the same way. ## Setup @@ -156,3 +157,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 47a192b..0a6ba47 100644 --- a/docs/deploy/database/deployment.md +++ b/docs/deploy/database/deployment.md @@ -485,11 +485,13 @@ 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_owner" -scripts/restore-db.sh -i backup.key -f auth_api_YYYYMMDD_HHMMSS.sql.gz.age \ - -d "postgres://auth_api_owner:@10.0.0.2/auth_api_restore" +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. @@ -511,7 +513,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 da534c3..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 internal listener (`/metrics`, and `/ready` with the state of each dependency), 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 0310984..f4a7996 100644 --- a/docs/deploy/guides/operations.md +++ b/docs/deploy/guides/operations.md @@ -117,9 +117,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 @@ -154,8 +153,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 10.0.0.1:9465/ready` -and `curl -f 10.0.0.1:9466/ready` on the API VPS (each dependency listed) 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 @@ -206,7 +206,7 @@ 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=...}` diff --git a/docs/deploy/guides/update.md b/docs/deploy/guides/update.md index 006205f..7740c33 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 . ``` @@ -75,10 +75,19 @@ 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) +export METRICS_TOKEN=$(pass prod/auth-api/metrics-token) +./write-secrets.sh ./rolling-update.sh ``` +`write-secrets.sh` 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/security-model.md b/docs/dev/security-model.md index 179e1bf..be6aed5 100644 --- a/docs/dev/security-model.md +++ b/docs/dev/security-model.md @@ -318,8 +318,13 @@ the database together, is out of scope. HSTS, CSP `default-src 'none'`, `nosniff`, `DENY` framing and `no-store`. - 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; the public `/ready` says only whether - the instance is ready. + 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. The production compose + file hands the secrets to the instances as files, out of `docker inspect` + and the process environment. ## Configuration @@ -424,3 +429,4 @@ when a cited test no longer exists. | 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` | diff --git a/nginx/nginx.conf b/nginx/nginx.conf index 2b7eed9..f816fd8 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -7,7 +7,25 @@ # 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 requests the API holds to its strict bucket (credentials, one-time codes, +# password guesses, OAuth flows): keyed by client address, 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/" $binary_remote_addr; + "~^[A-Z]+ /oauth/(token|introspect|revoke)$" ""; + "~^[A-Z]+ /oauth/" $binary_remote_addr; + "~^[A-Z]+ /users/me/(reauth|export|username|password)$" $binary_remote_addr; + "~^[A-Z]+ /users/me/email/(start|verify-current|submit|confirm)$" $binary_remote_addr; + "~^[A-Z]+ /users/me/two-factor/(totp/setup|email/setup|recovery-codes)$" $binary_remote_addr; + "~^DELETE /users/me(/sessions(/[^/]+)?|/passkeys/[^/]+|/external-identities/[^/]+|/two-factor/(totp|email)/[^/]+)?$" $binary_remote_addr; +} +limit_req_zone $auth_limit_key zone=api_auth:10m rate=40r/m; # One line per client request, correlated with the API's logs by request_id. log_format auth_api_json escape=json @@ -93,8 +111,9 @@ server { client_body_timeout 10s; 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)$) { + # 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 +142,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 +175,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..0829bb6 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 diff --git a/scripts/restore-db.sh b/scripts/restore-db.sh index c3661ea..fc7ef98 100755 --- a/scripts/restore-db.sh +++ b/scripts/restore-db.sh @@ -2,7 +2,12 @@ # 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] +# +# 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 @@ -32,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; } @@ -47,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 @@ -59,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 @@ -69,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/stack-smoke.sh b/scripts/stack-smoke.sh index ca2e630..ded3a41 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") @@ -114,15 +125,18 @@ 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 "public ready names no dependency" '{"status":"ready"}' "$(curl -s http://127.0.0.1:3001/ready)" -check "internal ready details dependencies" up "$(curl -s 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 -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}'; } +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" 536870912 "$(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-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 diff --git a/scripts/write-secrets.sh b/scripts/write-secrets.sh new file mode 100755 index 0000000..1780b9b --- /dev/null +++ b/scripts/write-secrets.sh @@ -0,0 +1,40 @@ +#!/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 left unexported is written empty and counts as unset. +# +# Usage (as the operator, with sudo): +# export DATABASE_URL=$(pass prod/auth-api/database-url) ... +# 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} + +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) + +missing=() +for name in "${REQUIRED[@]}"; do + [[ -n "${!name:-}" ]] || missing+=("$name") +done +if ((${#missing[@]})); then + echo "write-secrets: export ${missing[*]} first (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")" + printf '%s' "${!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_support.rs b/src/bin/bench_support.rs index f0606e5..799fc58 100644 --- a/src/bin/bench_support.rs +++ b/src/bin/bench_support.rs @@ -458,6 +458,7 @@ fn fallback_config(db_url: &str, redis_url: &str) -> Config { metrics: MetricsConfig { enabled: false, port: 9464, + token: None, }, } } diff --git a/src/config/mod.rs b/src/config/mod.rs index acf1f19..d711bab 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -376,7 +376,7 @@ 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, @@ -384,6 +384,20 @@ pub struct MetricsConfig { /// (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("port", &self.port) + .field("token", &self.token.as_ref().map(|_| "")) + .finish() + } } #[derive(Debug, Clone)] @@ -706,6 +720,7 @@ impl Config { metrics: MetricsConfig { enabled: vars.parse("METRICS_ENABLED")?.unwrap_or(true), 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 9b2ec09..716bcb9 100644 --- a/src/config/tests.rs +++ b/src/config/tests.rs @@ -146,6 +146,7 @@ fn valid_config() -> Config { metrics: MetricsConfig { enabled: true, port: 9464, + token: Some("metrics-token-0123456789abcdef0123456789".into()), }, } } diff --git a/src/config/validate.rs b/src/config/validate.rs index 386e60b..24e9b87 100644 --- a/src/config/validate.rs +++ b/src/config/validate.rs @@ -170,6 +170,22 @@ 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. @@ -529,6 +545,9 @@ pub(super) fn validate_device_auth(device: &DeviceAuthConfig) -> Result<(), Conf Ok(()) } +/// 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; diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 8857107..a05e228 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -155,15 +155,30 @@ fn ready_status(readiness: &Readiness) -> axum::http::StatusCode { pub async fn ready( axum::extract::State(state): axum::extract::State, ) -> (axum::http::StatusCode, axum::Json) { - let readiness = readiness(&state).await; - ( - ready_status(&readiness), - axum::Json(ReadyResponse { - status: readiness.status, - }), - ) + // 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, @@ -172,6 +187,29 @@ async fn ready_detail( (ready_status(&readiness), axum::Json(readiness)) } +/// 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 { @@ -235,6 +273,10 @@ pub fn router_with_metrics(state: AppState) -> (Router, Router) { }), ) .route("/ready", get(ready_detail)) + .layer(middleware::from_fn_with_state( + state.config.metrics.token.clone(), + internal_bearer, + )) .with_state(state); (app, internal) @@ -655,6 +697,34 @@ fn me_router() -> Router { mod tests { use tower::ServiceExt; + /// 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_and_folds_unmatched_paths() { // The builder installs the process-global Prometheus recorder (and diff --git a/src/openapi.rs b/src/openapi.rs index 9c96f1e..4e5e475 100644 --- a/src/openapi.rs +++ b/src/openapi.rs @@ -287,6 +287,14 @@ 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"); @@ -301,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; @@ -331,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/state.rs b/src/state.rs index 53388c6..3879568 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), }) } diff --git a/tests/simulation/dependencies.rs b/tests/simulation/dependencies.rs index 8d22761..7d10df8 100644 --- a/tests/simulation/dependencies.rs +++ b/tests/simulation/dependencies.rs @@ -236,7 +236,9 @@ async fn readiness_follows_each_dependency() { _ => &dependencies(&app).nats, }; proxy.set(Fault::Refuse); - let (status, body) = ready_until(&app, |_, body| body[name] == "down").await; + // The public answer is reused for a second: wait for both to agree. + let (status, body) = + ready_until(&app, |status, body| status == 503 && body[name] == "down").await; assert_eq!( (status, body[name].as_str()), (503, Some("down")), From 567cd15166ffdc8683cc2210b2a197c164a895f4 Mon Sep 17 00:00:00 2001 From: SIIR3X <125802911+SIIR3X@users.noreply.github.com> Date: Wed, 23 Sep 2026 11:25:13 +0200 Subject: [PATCH 29/55] docs: record the re-audit fixes in the threat model and the release notes --- CHANGELOG.md | 3 ++- docs/dev/guides/release.md | 1 + docs/dev/threat-model.md | 9 +++++---- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a19bde..b56fdce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,8 @@ as described in the [versioning policy](docs/dev/guides/versioning.md). ## [2.1.0] - 2026-09-26 -Security release: fixes every finding of the security audit of 2026-09-26. +Security release: fixes every finding of the security audit of 2026-09-26 +and of the independent re-audit 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 diff --git a/docs/dev/guides/release.md b/docs/dev/guides/release.md index afc7a41..cac2ecf 100644 --- a/docs/dev/guides/release.md +++ b/docs/dev/guides/release.md @@ -59,6 +59,7 @@ 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) | | `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/threat-model.md b/docs/dev/threat-model.md index 999b95e..cdc90c2 100644 --- a/docs/dev/threat-model.md +++ b/docs/dev/threat-model.md @@ -52,14 +52,15 @@ at least once a year. | 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. 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,7 +107,7 @@ 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; account, approval and administration routes refuse delegated tokens (SEC-34, SEC-42) | - | From 3180e056a60d5fcf099f4f87e5f1d40835d5f188 Mon Sep 17 00:00:00 2001 From: SIIR3X <125802911+SIIR3X@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:37:19 +0200 Subject: [PATCH 30/55] fix(oauth): require reauthentication for every consent and for signing accounts out from the administration --- CHANGELOG.md | 6 ++++ docs/dev/api/routes.md | 2 +- docs/dev/security-model.md | 6 ++-- src/services/admin/users.rs | 7 +++- src/services/authorize.rs | 36 ++++++++----------- src/services/oauth.rs | 33 +++++++++-------- .../api/clients/authorization_code.rs | 19 ++++++++-- 7 files changed, 64 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b56fdce..c1ee519 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,12 @@ from scratch (read **Upgrading**). ### Security +- 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 diff --git a/docs/dev/api/routes.md b/docs/dev/api/routes.md index d7207df..8211298 100644 --- a/docs/dev/api/routes.md +++ b/docs/dev/api/routes.md @@ -333,7 +333,7 @@ are regenerated. | 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}/sessions` | Admin `users:manage` | 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 | diff --git a/docs/dev/security-model.md b/docs/dev/security-model.md index be6aed5..0c99184 100644 --- a/docs/dev/security-model.md +++ b/docs/dev/security-model.md @@ -189,8 +189,9 @@ 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 @@ -430,3 +431,4 @@ when a cited test no longer exists. | 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` | diff --git a/src/services/admin/users.rs b/src/services/admin/users.rs index f544d2a..a7a3908 100644 --- a/src/services/admin/users.rs +++ b/src/services/admin/users.rs @@ -165,6 +165,9 @@ 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 mut tx = state.db.begin().await?; @@ -190,7 +193,9 @@ pub async fn revoke_sessions( events::wake(); forget_sessions(state, &active).await; - super::notify_owner(state, user_id, "sessions_revoked", None).await; + if count > 0 { + super::notify_owner(state, user_id, "sessions_revoked", None).await; + } Ok(count) } diff --git a/src/services/authorize.rs b/src/services/authorize.rs index 8ca12b8..f0cb832 100644 --- a/src/services/authorize.rs +++ b/src/services/authorize.rs @@ -102,12 +102,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,21 +111,19 @@ 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?; diff --git a/src/services/oauth.rs b/src/services/oauth.rs index 98e6763..21b016d 100644 --- a/src/services/oauth.rs +++ b/src/services/oauth.rs @@ -477,10 +477,8 @@ pub async fn describe_request( Ok(RequestDescription { request: description, redirect_uri: request.redirect_uri, - reauthentication_required: authorize_svc::requires_reauthentication( - state, session_id, &client, - ) - .await, + reauthentication_required: authorize_svc::requires_reauthentication(state, session_id) + .await, }) } @@ -499,19 +497,20 @@ pub async fn approve_request( // was made no longer receives a code. authorize_svc::validate_redirect(&client, &request.redirect_uri)?; // 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?; - } + // 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 approval = authorize_svc::Approval { user_id, diff --git a/tests/integration/api/clients/authorization_code.rs b/tests/integration/api/clients/authorization_code.rs index 96381f9..866dc83 100644 --- a/tests/integration/api/clients/authorization_code.rs +++ b/tests/integration/api/clients/authorization_code.rs @@ -170,6 +170,7 @@ async fn a_primary_client_signs_in_end_to_end() { let user = fixtures::authenticated_user(&app, 720).await; let p = pkce(); let request_id = request(&app, PRIMARY, CALLBACK, &p, &[]).await; + app.clear_recent_reauth(&user.access_token).await; let described: Value = app .get_auth( @@ -183,12 +184,24 @@ async fn a_primary_client_signs_in_end_to_end() { assert_eq!(described["client_name"], PRIMARY); assert_eq!(described["redirect_uri"], CALLBACK); assert_eq!(described["unrestricted"], true); - assert_eq!(described["reauthentication_required"], false); + assert_eq!(described["reauthentication_required"], true); assert!(described.get("sessions_allowed").is_none()); - // The primary client needs no fresh re-authentication. - app.clear_recent_reauth(&user.access_token).await; + // The primary client too needs a fresh re-authentication: an access token + // alone must not mint a new, long-lived session (SEC-66). A refusal leaves + // the request to approve. let (status, body) = approve_raw(&app, &user, &request_id, json!({})).await; + assert_eq!( + (status, body["code"].as_str()), + (403, Some("reauthentication_required")) + ); + let (status, body) = approve_raw( + &app, + &user, + &request_id, + json!({ "current_password": user.password }), + ) + .await; assert_eq!(status, 200, "{body}"); let code = query_param(body["redirect_to"].as_str().unwrap(), "code").unwrap(); From a88d3bbe2effe4798deb78d31f58049ab4628c46 Mon Sep 17 00:00:00 2001 From: SIIR3X <125802911+SIIR3X@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:49:26 +0200 Subject: [PATCH 31/55] fix(auth): reserve password attempts before hashing and let the captcha replace the identifier budget --- CHANGELOG.md | 8 ++ docs/dev/security-model.md | 10 +- src/services/auth/login.rs | 94 ++++++++++++++++++- src/services/auth/mod.rs | 3 + src/utils/redis_counter.rs | 12 +++ tests/integration/api/auth/captcha.rs | 64 +++++++++++++ .../security/regressions/account_hardening.rs | 9 +- tests/security/regressions/lockout.rs | 24 +++++ tests/security/regressions/second_factor.rs | 18 ++++ 9 files changed, 238 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c1ee519..92ec999 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,14 @@ from scratch (read **Upgrading**). ### Security +- 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 diff --git a/docs/dev/security-model.md b/docs/dev/security-model.md index 0c99184..afe2ef5 100644 --- a/docs/dev/security-model.md +++ b/docs/dev/security-model.md @@ -77,7 +77,14 @@ the database together, is out of scope. address too, with a wider ceiling for the account, and a reset restarts them. - **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. @@ -432,3 +439,4 @@ when a cited test no longer exists. | 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` | diff --git a/src/services/auth/login.rs b/src/services/auth/login.rs index 212af8c..9e44886 100644 --- a/src/services/auth/login.rs +++ b/src/services/auth/login.rs @@ -85,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); @@ -97,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) => { @@ -152,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); @@ -219,6 +255,8 @@ pub async fn login( return Err(AppError::InvalidCredentials); } (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 } @@ -307,8 +345,17 @@ pub(crate) async fn first_factor_proven( // 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(&dropped)[..]).await; + } let _: Result<(), _> = conn .sadd::<_, _, ()>(&user_index_key, &pre_auth_token) .await; @@ -359,6 +406,49 @@ 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. diff --git a/src/services/auth/mod.rs b/src/services/auth/mod.rs index 799e444..dea7bf1 100644 --- a/src/services/auth/mod.rs +++ b/src/services/auth/mod.rs @@ -97,6 +97,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:"; diff --git a/src/utils/redis_counter.rs b/src/utils/redis_counter.rs index 24ce002..99c79c3 100644 --- a/src/utils/redis_counter.rs +++ b/src/utils/redis_counter.rs @@ -149,6 +149,18 @@ pub async fn claim_cooldown(redis: &RedisPool, key: &str, ttl_secs: u64) -> bool claimed.map(|reply| reply.is_some()).unwrap_or(true) } +/// Give back one attempt on each budget, after the guarded action turned out +/// legitimate. Best-effort, like `reset`. +pub async fn release(redis: &RedisPool, keys: &[&str]) { + use deadpool_redis::redis::AsyncCommands; + + if let Ok(mut conn) = redis.get().await { + for key in keys { + let _: Result = conn.decr(*key, 1).await; + } + } +} + pub async fn reset(redis: &RedisPool, keys: &[&str]) { use deadpool_redis::redis::AsyncCommands; diff --git a/tests/integration/api/auth/captcha.rs b/tests/integration/api/auth/captcha.rs index d94b492..8ab2ee3 100644 --- a/tests/integration/api/auth/captcha.rs +++ b/tests/integration/api/auth/captcha.rs @@ -375,3 +375,67 @@ async fn a_token_solved_on_another_site_is_refused() { "solved on the host of FRONTEND_URL" ); } + +/// With a CAPTCHA required, the budget of an identifier stops refusing: every +/// attempt already costs a solved challenge, and guessing someone's password +/// must not also keep them from signing in (SEC-67). The lockout still bounds +/// the guesses. +#[tokio::test] +async fn the_captcha_replaces_the_identifier_budget() { + let verify_url = spawn_captcha_mock(true).await; + let app = TestApp::spawn_with_config(|c| { + c.captcha.secret = Some("test_secret".into()); + c.captcha.verify_url = verify_url.clone(); + c.captcha.fail_open_on_error = false; + c.captcha.request_timeout_secs = 5; + c.security.lockout_threshold = 50; + }) + .await; + let email = "captcha.budget70@example.com"; + let password = "Password70!okay"; + let registered = app + .post( + "/auth/register", + &serde_json::json!({ + "username": "captcha_budget70", + "email": email, + "password": password, + "captcha_token": "valid-token-xyz", + }), + ) + .await; + assert_eq!(registered.status().as_u16(), 202); + let id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM users WHERE email = $1") + .bind(email) + .fetch_one(&app.db) + .await + .unwrap(); + fixtures::activate_user(&app.db, id).await; + let login = |password: &str| { + serde_json::json!({ + "identifier": email, + "password": password, + "captcha_token": "valid-token-xyz", + }) + }; + + // The identifier's budget is spent (10 failures this quarter of an hour). + for _ in 0..10 { + sqlx::query( + "INSERT INTO login_attempts (user_id, attempted_identifier, was_successful, failure_reason) + VALUES ($1, $2, FALSE, 'invalid_password')", + ) + .bind(id) + .bind(email) + .execute(&app.db) + .await + .unwrap(); + } + let status = app + .post("/auth/login", &login("Wrong-Password-70!")) + .await + .status(); + assert_eq!(status.as_u16(), 401, "a solved challenge is not refused"); + let status = app.post("/auth/login", &login(password)).await.status(); + assert_eq!(status.as_u16(), 200, "the owner still signs in"); +} diff --git a/tests/security/regressions/account_hardening.rs b/tests/security/regressions/account_hardening.rs index aa8efb1..a1f1057 100644 --- a/tests/security/regressions/account_hardening.rs +++ b/tests/security/regressions/account_hardening.rs @@ -254,11 +254,18 @@ async fn a_wrong_reauthentication_password_has_its_own_code() { #[tokio::test] async fn rotating_ipv6_addresses_within_a_64_does_not_reset_the_failure_budget() { let app = TestApp::spawn().await; + // A /64 of its own per run: address budgets live in the shared Redis. + let b = uuid::Uuid::new_v4().into_bytes(); + let prefix = format!( + "2001:db8:{:x}:{:x}", + u16::from_be_bytes([b[0], b[1]]), + u16::from_be_bytes([b[2], b[3]]) + ); let attempt = |n: u32| { let request = app .client .post(app.url("/auth/login")) - .header("x-forwarded-for", format!("2001:db8:77:1::{n:x}")) + .header("x-forwarded-for", format!("{prefix}::{n:x}")) .json(&json!({ "identifier": format!("nobody{n}@example.com"), "password": "Wrong-password1!", diff --git a/tests/security/regressions/lockout.rs b/tests/security/regressions/lockout.rs index ae432b7..7e998d9 100644 --- a/tests/security/regressions/lockout.rs +++ b/tests/security/regressions/lockout.rs @@ -224,3 +224,27 @@ async fn someone_asking_for_links_neither_spends_nor_revokes_the_owners() { "the owner's first link survived" ); } + +/// Attempts sent at once are reserved one by one before the hash is computed: +/// a burst cannot slip past the budget of an identifier (SEC-67). +#[tokio::test] +async fn a_burst_of_guesses_cannot_outrun_the_budget() { + let app = TestApp::spawn_with_config(|c| c.security.lockout_threshold = 50).await; + let user = fixtures::authenticated_user(&app, 807).await; + + let attempts = (0..16).map(|_| login(&app, &user.email, WRONG)); + let statuses: Vec = futures::future::join_all(attempts) + .await + .into_iter() + .map(|(status, _)| status) + .collect(); + let evaluated = statuses.iter().filter(|s| **s == 401).count(); + assert!( + evaluated <= 10, + "{evaluated} guesses evaluated: {statuses:?}" + ); + assert!( + statuses.iter().filter(|s| **s == 429).count() >= 6, + "{statuses:?}" + ); +} diff --git a/tests/security/regressions/second_factor.rs b/tests/security/regressions/second_factor.rs index a92be74..600e6bb 100644 --- a/tests/security/regressions/second_factor.rs +++ b/tests/security/regressions/second_factor.rs @@ -606,3 +606,21 @@ async fn signing_in_again_within_the_email_code_cooldown_still_challenges() { let second = login_challenge(&app, &user).await; assert_ne!(first["pre_auth_token"], second["pre_auth_token"]); } + +/// At most five second-factor challenges stay open per account: someone +/// holding the password cannot open them without end (SEC-67). +#[tokio::test] +async fn open_challenges_are_capped_per_account() { + let app = TestApp::spawn().await; + let user = fixtures::authenticated_user(&app, 606).await; + let (_secret, _) = enable_totp(&app, &user).await; + for _ in 0..8 { + login_challenge(&app, &user).await; + } + let mut conn = app.redis.get().await.unwrap(); + let open: i64 = conn + .scard(format!("user_pre_auth:{}", user.id)) + .await + .unwrap(); + assert!(open <= 5, "{open} challenges open"); +} From e84a1275efc33e2a887a35e1c33461f1bcd7fce8 Mon Sep 17 00:00:00 2001 From: SIIR3X <125802911+SIIR3X@users.noreply.github.com> Date: Thu, 24 Sep 2026 00:01:32 +0200 Subject: [PATCH 32/55] fix(two-factor): tighten account budgets, warn the owner and keep resends from killing the live code --- CHANGELOG.md | 8 +++ docs/dev/security-model.md | 9 ++- src/handlers/auth.rs | 3 + src/repositories/email_2fa.rs | 43 +++++++++--- src/services/auth/guards.rs | 69 ++++++++++++++++++- src/services/auth/mod.rs | 18 +++-- src/services/auth/second_factor.rs | 6 ++ src/services/email.rs | 30 ++++++++ src/services/email_2fa.rs | 3 + src/services/two_factor.rs | 4 +- src/services/user.rs | 3 + src/utils/redis_counter.rs | 40 +++++++++++ .../emails/en/second_factor_attempts.html | 8 +++ .../emails/en/second_factor_attempts.subject | 1 + .../emails/fr/second_factor_attempts.html | 8 +++ .../emails/fr/second_factor_attempts.subject | 1 + tests/integration/api/two_factor/email.rs | 62 +++++++++++++++++ .../repositories/email_2fa_codes.rs | 28 +++----- tests/security/regressions/second_factor.rs | 58 ++++++++++++++++ 19 files changed, 366 insertions(+), 36 deletions(-) create mode 100644 templates/emails/en/second_factor_attempts.html create mode 100644 templates/emails/en/second_factor_attempts.subject create mode 100644 templates/emails/fr/second_factor_attempts.html create mode 100644 templates/emails/fr/second_factor_attempts.subject diff --git a/CHANGELOG.md b/CHANGELOG.md index 92ec999..bb6165d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,14 @@ from scratch (read **Upgrading**). ### Security +- 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 diff --git a/docs/dev/security-model.md b/docs/dev/security-model.md index afe2ef5..b257539 100644 --- a/docs/dev/security-model.md +++ b/docs/dev/security-model.md @@ -74,8 +74,12 @@ the database together, is out of scope. 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 wider ceiling for the account, and a reset restarts - them. + 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. A password attempt is also reserved atomically in Redis, against the account @@ -440,3 +444,4 @@ when a cited test no longer exists. | 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` | diff --git a/src/handlers/auth.rs b/src/handlers/auth.rs index baefb31..c3dc157 100644 --- a/src/handlers/auth.rs +++ b/src/handlers/auth.rs @@ -589,6 +589,9 @@ 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; diff --git a/src/repositories/email_2fa.rs b/src/repositories/email_2fa.rs index b577f6e..fdc1570 100644 --- a/src/repositories/email_2fa.rs +++ b/src/repositories/email_2fa.rs @@ -62,16 +62,29 @@ pub async fn find_active_by_user_and_hash( /// 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)] @@ -98,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/services/auth/guards.rs b/src/services/auth/guards.rs index acd918a..4a0258d 100644 --- a/src/services/auth/guards.rs +++ b/src/services/auth/guards.rs @@ -13,10 +13,71 @@ pub(super) fn redis_unavailable(error: impl std::fmt::Display) -> AppError { 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:{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. Someone holding the password and guessing from their own address +/// 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, @@ -34,6 +95,12 @@ pub(crate) fn second_factor_budget_keys( 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. pub(super) async fn mailbox_budget_exhausted( diff --git a/src/services/auth/mod.rs b/src/services/auth/mod.rs index dea7bf1..8375b37 100644 --- a/src/services/auth/mod.rs +++ b/src/services/auth/mod.rs @@ -66,7 +66,8 @@ mod tokens; use guards::*; pub(crate) use guards::{ - ensure_account_usable, ensure_status_allows_sign_in, second_factor_budget_keys, + 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::*; @@ -139,7 +140,7 @@ 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. pub(crate) const TOTP_USER_FAIL_PREFIX: &str = "totp_user_fail:"; @@ -149,7 +150,11 @@ 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 = 5; +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; @@ -161,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. diff --git a/src/services/auth/second_factor.rs b/src/services/auth/second_factor.rs index 199ba29..b2015f2 100644 --- a/src/services/auth/second_factor.rs +++ b/src/services/auth/second_factor.rs @@ -45,6 +45,9 @@ pub async fn complete_two_factor_login( })); 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); } @@ -242,6 +245,9 @@ pub async fn complete_login_with_recovery( })); 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); } diff --git a/src/services/email.rs b/src/services/email.rs index 060f2c9..402ee31 100644 --- a/src/services/email.rs +++ b/src/services/email.rs @@ -30,6 +30,7 @@ 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"; @@ -640,6 +641,35 @@ pub async fn send_changed_by_administrator( 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, diff --git a/src/services/email_2fa.rs b/src/services/email_2fa.rs index 27eddda..de2218b 100644 --- a/src/services/email_2fa.rs +++ b/src/services/email_2fa.rs @@ -270,6 +270,9 @@ async fn verify_otp( })); 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); } diff --git a/src/services/two_factor.rs b/src/services/two_factor.rs index 25d02e1..e105537 100644 --- a/src/services/two_factor.rs +++ b/src/services/two_factor.rs @@ -297,7 +297,9 @@ pub async fn generate_recovery_codes( // Claimed before regenerating: concurrent requests cannot each replace // the codes the previous one just showed. let cooldown_key = format!("rc_regen:{}", user_id); - if !redis_counter::claim_cooldown(&state.redis, &cooldown_key, RC_REGEN_COOLDOWN_SECS).await { + if !redis_counter::claim_cooldown_strict(&state.redis, &cooldown_key, RC_REGEN_COOLDOWN_SECS) + .await? + { return Err(AppError::RateLimitExceeded); } diff --git a/src/services/user.rs b/src/services/user.rs index 38ba31a..90f5886 100644 --- a/src/services/user.rs +++ b/src/services/user.rs @@ -257,6 +257,9 @@ pub async fn change_password( events::wake(); auth_svc::invalidate_session_caches(state, &revoked_session_ids).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).await; diff --git a/src/utils/redis_counter.rs b/src/utils/redis_counter.rs index 99c79c3..ede9cad 100644 --- a/src/utils/redis_counter.rs +++ b/src/utils/redis_counter.rs @@ -134,6 +134,29 @@ pub async fn peek(redis: &RedisPool, key: &str) -> Result { /// before the guarded action: concurrent requests cannot all see the key free /// and all act. Fails open (a Redis outage claims nothing and allows the /// action), like the volume budgets. +/// [`claim_cooldown`] for actions whose repetition does harm (regenerating +/// recovery codes replaces the owner's): a Redis outage refuses the action. +pub async fn claim_cooldown_strict( + redis: &RedisPool, + key: &str, + ttl_secs: u64, +) -> Result { + let mut conn = redis + .get() + .await + .map_err(|_| redis_failure("redis_unavailable"))?; + let claimed: Option = deadpool_redis::redis::cmd("SET") + .arg(key) + .arg(1) + .arg("NX") + .arg("EX") + .arg(ttl_secs) + .query_async(&mut *conn) + .await + .map_err(|_| redis_failure("redis_query_failed"))?; + Ok(claimed.is_some()) +} + pub async fn claim_cooldown(redis: &RedisPool, key: &str, ttl_secs: u64) -> bool { let Ok(mut conn) = redis.get().await else { return true; @@ -171,3 +194,20 @@ pub async fn reset(redis: &RedisPool, keys: &[&str]) { let _: Result<(), _> = conn.del(keys).await; } } + +#[cfg(test)] +mod cooldown_tests { + /// A cooldown guarding a harmful repetition refuses when Redis is down; + /// a volume cooldown lets the action through (SEC-68). + #[tokio::test] + async fn a_strict_cooldown_fails_closed() { + let pool = crate::utils::redis_pool::build(&crate::config::RedisConfig { + url: "redis://127.0.0.1:1".into(), + pool_size: 1, + wait_timeout_ms: 100, + }) + .unwrap(); + assert!(super::claim_cooldown_strict(&pool, "k", 60).await.is_err()); + assert!(super::claim_cooldown(&pool, "k", 60).await); + } +} diff --git a/templates/emails/en/second_factor_attempts.html b/templates/emails/en/second_factor_attempts.html new file mode 100644 index 0000000..d26f52d --- /dev/null +++ b/templates/emails/en/second_factor_attempts.html @@ -0,0 +1,8 @@ + + + +

Hello {{ username }},

+

Someone signed in to your {{ app_name }} account with your password and then entered many wrong second-factor codes. Second-factor sign-ins are paused for up to an hour.

+

If it was not you, your password is known to someone else: reset it now, and review your sessions and security keys.

+ + diff --git a/templates/emails/en/second_factor_attempts.subject b/templates/emails/en/second_factor_attempts.subject new file mode 100644 index 0000000..482b8b2 --- /dev/null +++ b/templates/emails/en/second_factor_attempts.subject @@ -0,0 +1 @@ +Someone is guessing your second factor diff --git a/templates/emails/fr/second_factor_attempts.html b/templates/emails/fr/second_factor_attempts.html new file mode 100644 index 0000000..360a871 --- /dev/null +++ b/templates/emails/fr/second_factor_attempts.html @@ -0,0 +1,8 @@ + + + +

Bonjour {{ username }},

+

Quelqu'un s'est connecté à votre compte {{ app_name }} avec votre mot de passe, puis a saisi de nombreux codes de second facteur erronés. Les connexions par second facteur sont suspendues pendant une heure au plus.

+

Si ce n'était pas vous, votre mot de passe est connu d'un tiers : réinitialisez-le maintenant et vérifiez vos sessions et vos clés de sécurité.

+ + diff --git a/templates/emails/fr/second_factor_attempts.subject b/templates/emails/fr/second_factor_attempts.subject new file mode 100644 index 0000000..1be9016 --- /dev/null +++ b/templates/emails/fr/second_factor_attempts.subject @@ -0,0 +1 @@ +Quelqu'un tente de deviner votre second facteur diff --git a/tests/integration/api/two_factor/email.rs b/tests/integration/api/two_factor/email.rs index 7624d1a..255fcbb 100644 --- a/tests/integration/api/two_factor/email.rs +++ b/tests/integration/api/two_factor/email.rs @@ -604,3 +604,65 @@ async fn email_2fa_setup_verify_expired_code_rejected() { .await; assert_eq!(res.status().as_u16(), 401, "expired code must return 401"); } + +async fn email_challenge(app: &TestApp, user: &fixtures::AuthenticatedUser) -> String { + let body: Value = app + .post( + "/auth/login", + &serde_json::json!({ "identifier": user.email, "password": user.password }), + ) + .await + .json() + .await + .unwrap(); + assert_eq!(body["two_factor_method"], "email", "{body}"); + body["pre_auth_token"].as_str().unwrap().to_owned() +} + +/// A resend keeps the previous code usable (someone holding the challenge +/// must not kill the code its owner is typing), and a challenge resends at +/// most twice (SEC-68). +#[tokio::test] +async fn a_resend_keeps_the_previous_code_and_is_budgeted() { + let app = TestApp::spawn().await; + let user = fixtures::authenticated_user(&app, 880).await; + setup_email_2fa(&app, &user.access_token, user.id).await; + let pre_auth = email_challenge(&app, &user).await; + let mut statuses = Vec::new(); + for _ in 0..3 { + app.clear_email_2fa_cooldown(user.id).await; + let res = app + .post( + "/auth/two-factor/email/resend", + &serde_json::json!({ "pre_auth_token": pre_auth }), + ) + .await; + statuses.push(res.status().as_u16()); + } + assert_eq!(statuses, [204, 204, 429]); + + // A new challenge: its code, then a resend, and the first code still works. + app.clear_email_2fa_cooldown(user.id).await; + let pre_auth = email_challenge(&app, &user).await; + let first = read_otp_from_db(&app, user.id).await; + app.clear_email_2fa_cooldown(user.id).await; + let res = app + .post( + "/auth/two-factor/email/resend", + &serde_json::json!({ "pre_auth_token": pre_auth }), + ) + .await; + assert_eq!(res.status().as_u16(), 204); + + let res = app + .post( + "/auth/two-factor/email/complete", + &serde_json::json!({ "pre_auth_token": pre_auth, "code": first }), + ) + .await; + assert_eq!(res.status().as_u16(), 200, "the earlier code still works"); + assert!( + active_email_code_hashes(&app, user.id).await.is_empty(), + "using a code ends the other one" + ); +} diff --git a/tests/integration/repositories/email_2fa_codes.rs b/tests/integration/repositories/email_2fa_codes.rs index 7cb3b18..c9f892b 100644 --- a/tests/integration/repositories/email_2fa_codes.rs +++ b/tests/integration/repositories/email_2fa_codes.rs @@ -18,34 +18,28 @@ async fn issue(db: &TestDb, user_id: Uuid, hash: &[u8; 32]) -> Uuid { .unwrap() } +/// A new code keeps the previous one alive (a resend must not kill the code +/// its owner is typing) and ends any older one: two codes at most are live. #[tokio::test] -async fn a_new_code_replaces_the_live_one() { +async fn a_new_code_keeps_only_the_previous_one() { let db = TestDb::new().await; let user = insert_active_user(&db.pool, 1).await; issue(&db, user, &[1u8; 32]).await; - let second = issue(&db, user, &[2u8; 32]).await; + issue(&db, user, &[2u8; 32]).await; + let third = issue(&db, user, &[3u8; 32]).await; let live = email_2fa::find_active_by_user(&db.pool, user) .await .unwrap() .expect("a live code"); - assert_eq!(live.id, second); - assert!( - email_2fa::find_active_by_user_and_hash(&db.pool, user, &[vec![1u8; 32]]) + assert_eq!(live.id, third); + for (hash, alive) in [([1u8; 32], false), ([2u8; 32], true), ([3u8; 32], true)] { + let found = email_2fa::find_active_by_user_and_hash(&db.pool, user, &[hash.to_vec()]) .await - .unwrap() - .is_none(), - "the replaced code still verifies" - ); - let unused: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM email_2fa_codes WHERE user_id = $1 AND used_at IS NULL", - ) - .bind(user) - .fetch_one(&db.pool) - .await - .unwrap(); - assert_eq!(unused, 1); + .unwrap(); + assert_eq!(found.is_some(), alive, "{hash:?}"); + } } #[tokio::test] diff --git a/tests/security/regressions/second_factor.rs b/tests/security/regressions/second_factor.rs index 600e6bb..c93e682 100644 --- a/tests/security/regressions/second_factor.rs +++ b/tests/security/regressions/second_factor.rs @@ -624,3 +624,61 @@ async fn open_challenges_are_capped_per_account() { .unwrap(); assert!(open <= 5, "{open} challenges open"); } + +/// An account whose second-factor budget is spent is told by mail: someone +/// holds the password (SEC-68). +#[tokio::test] +async fn a_spent_second_factor_budget_warns_the_owner() { + let app = TestApp::spawn().await; + let user = fixtures::authenticated_user(&app, 607).await; + let (secret, _) = enable_totp(&app, &user).await; + let mut conn = app.redis.get().await.unwrap(); + let _: () = conn + .set_ex(format!("totp_user_fail:{}", user.id), 30, 3600) + .await + .unwrap(); + + let challenge = login_challenge(&app, &user).await; + let res = app + .post( + "/auth/two-factor/complete", + &json!({ + "pre_auth_token": challenge["pre_auth_token"], + "code": totp_code(&secret, 1), + }), + ) + .await; + assert_eq!(res.status().as_u16(), 429); + app.mail + .wait_for(&user.email, "Someone is guessing your second factor") + .await; +} + +/// A challenge opened with the old password dies when the password changes +/// (SEC-68). +#[tokio::test] +async fn a_password_change_ends_open_challenges() { + let app = TestApp::spawn().await; + let user = fixtures::authenticated_user(&app, 608).await; + let (secret, _) = enable_totp(&app, &user).await; + let challenge = login_challenge(&app, &user).await; + + let res = app + .patch_auth( + "/users/me/password", + &user.access_token, + &json!({ "current_password": user.password, "new_password": "Changed-Pass-608!" }), + ) + .await; + assert_eq!(res.status().as_u16(), 204, "{}", res.text().await.unwrap()); + let res = app + .post( + "/auth/two-factor/complete", + &json!({ + "pre_auth_token": challenge["pre_auth_token"], + "code": totp_code(&secret, 1), + }), + ) + .await; + assert_eq!(res.status().as_u16(), 401); +} From bb61d83780caecd627d753768aa9bfafbb692fe9 Mon Sep 17 00:00:00 2001 From: SIIR3X <125802911+SIIR3X@users.noreply.github.com> Date: Thu, 24 Sep 2026 04:13:38 +0200 Subject: [PATCH 33/55] fix(account): remove ways in planted before a reset and let administrators clear a compromised account --- CHANGELOG.md | 8 ++ crates/testkit/src/app.rs | 1 + docs/dev/api/openapi.yaml | 100 ++++++++++++++++++ docs/dev/api/routes.md | 1 + docs/dev/guides/configuration.md | 1 + docs/dev/security-model.md | 10 +- migrations/0010_audit_log.sql | 1 + migrations/SHA256SUMS | 2 +- src/bin/bench_support.rs | 1 + src/config/mod.rs | 7 ++ src/config/tests.rs | 1 + src/domain/audit.rs | 1 + src/handlers/admin/users.rs | 40 ++++++- src/handlers/audit.rs | 1 + src/handlers/mod.rs | 4 + src/openapi.rs | 1 + src/repositories/session.rs | 15 +++ src/repositories/user.rs | 57 ++++++++++ src/services/admin/users.rs | 46 ++++++++ src/services/auth/password_reset.rs | 24 ++++- src/services/email.rs | 2 + src/services/user.rs | 12 ++- .../emails/en/changed_by_administrator.html | 2 + templates/emails/en/password_changed.html | 6 ++ .../emails/fr/changed_by_administrator.html | 2 + templates/emails/fr/password_changed.html | 6 ++ tests/integration/api/admin/privileges.rs | 49 +++++++++ tests/security/regressions/lockout.rs | 60 +++++++++++ 28 files changed, 453 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb6165d..f2394c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,14 @@ from scratch (read **Upgrading**). ### Security +- 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 diff --git a/crates/testkit/src/app.rs b/crates/testkit/src/app.rs index 437bc1f..6d24f7f 100644 --- a/crates/testkit/src/app.rs +++ b/crates/testkit/src/app.rs @@ -526,6 +526,7 @@ pub fn test_config(db_url: &str, redis_url: &str, nats_url: &str) -> Config { new_device_alerts: true, magic_links: true, registrations_per_ip_per_hour: 10_000, + reset_revokes_factors_added_hours: 72, }, captcha: CaptchaConfig { secret: None, diff --git a/docs/dev/api/openapi.yaml b/docs/dev/api/openapi.yaml index ead525d..e26c003 100644 --- a/docs/dev/api/openapi.yaml +++ b/docs/dev/api/openapi.yaml @@ -974,6 +974,72 @@ paths: $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: + $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: [] /admin/users/{id}/password-reset: post: tags: @@ -987,6 +1053,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 @@ -1014,6 +1087,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: @@ -6807,6 +6898,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: diff --git a/docs/dev/api/routes.md b/docs/dev/api/routes.md index 8211298..09b6708 100644 --- a/docs/dev/api/routes.md +++ b/docs/dev/api/routes.md @@ -333,6 +333,7 @@ are regenerated. | 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 | diff --git a/docs/dev/guides/configuration.md b/docs/dev/guides/configuration.md index dcbab99..ca8bf4c 100644 --- a/docs/dev/guides/configuration.md +++ b/docs/dev/guides/configuration.md @@ -128,6 +128,7 @@ 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 | diff --git a/docs/dev/security-model.md b/docs/dev/security-model.md index b257539..7225409 100644 --- a/docs/dev/security-model.md +++ b/docs/dev/security-model.md @@ -60,8 +60,13 @@ the database together, is out of scope. - **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 pending - account taken back by a reset loses all of them. + 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 @@ -445,3 +450,4 @@ when a cited test no longer exists. | 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` | diff --git a/migrations/0010_audit_log.sql b/migrations/0010_audit_log.sql index c7448bb..529a1a8 100644 --- a/migrations/0010_audit_log.sql +++ b/migrations/0010_audit_log.sql @@ -50,6 +50,7 @@ CREATE TYPE audit_action AS ENUM ( -- Administrative changes. 'account_unlocked', 'password_reset_forced', + 'access_factors_removed', 'role_created', 'role_deleted', 'role_permissions_changed', diff --git a/migrations/SHA256SUMS b/migrations/SHA256SUMS index d5574fc..9702773 100644 --- a/migrations/SHA256SUMS +++ b/migrations/SHA256SUMS @@ -7,7 +7,7 @@ f49cf1363738d98e0faab782cf8db3907097da2897ef60aec0969d0ea41bc47f 0005_sessions. 9a77f658341cc377e103c9f95687816d35e4eb8d30d04b1e79f48364ae8296c6 0007_two_factor.sql c155c5d85738ffc9b08a879001e05875fc4b263afde85b56f32f96f5abf375a5 0008_account_tokens.sql 6c37703c71c8892899764c86bf00b86f09afd362ac17262d4b27045643c316e8 0009_login_attempts.sql -0a44b3868aa9e4ea6c1d11eb561756034d77a0cab3197e2e03bae374be15433c 0010_audit_log.sql +54950d7f925c6d2d8eb4878e7e5b1905b11510a08e054cb313de91d9dd709047 0010_audit_log.sql 76b27021b2b3e978ee4222edf821fbda8d78f39693fdd446279dc4d4d61377dc 0011_event_outbox.sql c5192b0923eca493d1c194d74d6db96d1c1804e7dffb7f5445be6761c7e26144 0012_personal_data.sql f6434d27992410fe4844cf77d826695d2c064bf9f7cc2533d6ad43982493c011 0013_known_devices.sql diff --git a/src/bin/bench_support.rs b/src/bin/bench_support.rs index 799fc58..f23f267 100644 --- a/src/bin/bench_support.rs +++ b/src/bin/bench_support.rs @@ -383,6 +383,7 @@ fn fallback_config(db_url: &str, redis_url: &str) -> Config { new_device_alerts: true, magic_links: false, registrations_per_ip_per_hour: 10_000, + reset_revokes_factors_added_hours: 72, }, captcha: CaptchaConfig { secret: None, diff --git a/src/config/mod.rs b/src/config/mod.rs index d711bab..2ad8052 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -193,6 +193,10 @@ pub struct SecurityConfig { /// 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)] @@ -570,6 +574,9 @@ impl Config { 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 { diff --git a/src/config/tests.rs b/src/config/tests.rs index 716bcb9..04317f8 100644 --- a/src/config/tests.rs +++ b/src/config/tests.rs @@ -71,6 +71,7 @@ fn valid_config() -> Config { new_device_alerts: true, magic_links: false, registrations_per_ip_per_hour: 20, + reset_revokes_factors_added_hours: 72, }, mail: MailConfig { smtp: SmtpConfig { diff --git a/src/domain/audit.rs b/src/domain/audit.rs index e75e05a..14977f0 100644 --- a/src/domain/audit.rs +++ b/src/domain/audit.rs @@ -43,6 +43,7 @@ pub enum AuditAction { EncryptionKeyRotated, AccountUnlocked, PasswordResetForced, + AccessFactorsRemoved, RoleCreated, RoleDeleted, RolePermissionsChanged, diff --git a/src/handlers/admin/users.rs b/src/handlers/admin/users.rs index c766726..a426d71 100644 --- a/src/handlers/admin/users.rs +++ b/src/handlers/admin/users.rs @@ -282,11 +282,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 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" = [])), )] @@ -295,9 +297,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?; - admin_users::force_password_reset(&state, &actor(&admin, ip), user_id).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::remove_access_factors(&state, &actor(&admin, ip), user_id).await?; Ok(StatusCode::NO_CONTENT) } diff --git a/src/handlers/audit.rs b/src/handlers/audit.rs index 80b13dc..12b628b 100644 --- a/src/handlers/audit.rs +++ b/src/handlers/audit.rs @@ -182,6 +182,7 @@ 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::RoleCreated => "role_created", A::RoleDeleted => "role_deleted", A::RolePermissionsChanged => "role_permissions_changed", diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index a05e228..95d3674 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -521,6 +521,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), diff --git a/src/openapi.rs b/src/openapi.rs index 4e5e475..019f6bf 100644 --- a/src/openapi.rs +++ b/src/openapi.rs @@ -102,6 +102,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, diff --git a/src/repositories/session.rs b/src/repositories/session.rs index 835b8c6..7da1cc6 100644 --- a/src/repositories/session.rs +++ b/src/repositories/session.rs @@ -204,6 +204,21 @@ 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>( diff --git a/src/repositories/user.rs b/src/repositories/user.rs index 694a760..30befde 100644 --- a/src/repositories/user.rs +++ b/src/repositories/user.rs @@ -228,6 +228,63 @@ pub async fn drop_access_factors<'e>( 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. +pub async fn drop_access_factors_since( + tx: &mut sqlx::PgConnection, + id: Uuid, + since: OffsetDateTime, +) -> Result, sqlx::Error> { + let mut removed: Vec<(String, String)> = sqlx::query_as( + "DELETE FROM two_factor_methods WHERE user_id = $1 AND created_at > $2 + RETURNING method_type::text, ''", + ) + .bind(id) + .bind(since) + .fetch_all(&mut *tx) + .await?; + removed.extend( + sqlx::query_as::<_, (String, String)>( + "DELETE FROM passkeys WHERE user_id = $1 AND created_at > $2 + RETURNING 'passkey', name::text", + ) + .bind(id) + .bind(since) + .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, diff --git a/src/services/admin/users.rs b/src/services/admin/users.rs index a7a3908..68711e9 100644 --- a/src/services/admin/users.rs +++ b/src/services/admin/users.rs @@ -155,6 +155,7 @@ pub async fn unlock(state: &AppState, actor: &Actor, user_id: Uuid) -> Result<() .await?; tx.commit().await?; redis_counter::reset(&state.redis, &[&user_svc::reauth_fail_key(user_id)]).await; + super::notify_owner(state, user_id, "unlocked", None).await; Ok(()) } @@ -205,6 +206,7 @@ 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?; @@ -233,9 +235,53 @@ pub async fn force_password_reset( events::wake(); forget_sessions(state, &active).await; + // Whoever knew the password may have added their own ways in: on request, + // they go with it. + if revoke_access_factors { + let revoked = drop_access_factors_in(state, actor, user_id).await?; + forget_sessions(state, &revoked).await; + } auth_svc::send_reset_link(state, &user, actor.ip, None, true).await } +/// 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, +) -> Result<(), AppError> { + refuse_own_account(actor, user_id)?; + require_reauth(state, actor, "admin_remove_access_factors").await?; + find(state, user_id).await?; + let revoked = drop_access_factors_in(state, actor, user_id).await?; + forget_sessions(state, &revoked).await; + super::notify_owner(state, user_id, "access_removed", None).await; + Ok(()) +} + +async fn drop_access_factors_in( + state: &AppState, + actor: &Actor, + user_id: Uuid, +) -> Result, AppError> { + let mut tx = state.db.begin().await?; + user_repo::lock_row(&mut *tx, user_id).await?; + let revoked = session_repo::revoke_personal_access_sessions(&mut *tx, user_id).await?; + 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?; + tx.commit().await?; + Ok(revoked) +} + /// 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). diff --git a/src/services/auth/password_reset.rs b/src/services/auth/password_reset.rs index 31aa254..c3e734b 100644 --- a/src/services/auth/password_reset.rs +++ b/src/services/auth/password_reset.rs @@ -170,6 +170,16 @@ pub async fn reset_password( // 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)); + user_repo::drop_access_factors_since(&mut tx, record.user_id, since).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?; @@ -245,7 +255,19 @@ pub async fn reset_password( // 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 { - crate::services::user::notify_password_changed(state, &user).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/email.rs b/src/services/email.rs index 402ee31..42aafd1 100644 --- a/src/services/email.rs +++ b/src/services/email.rs @@ -277,11 +277,13 @@ pub async fn send_password_changed( 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, diff --git a/src/services/user.rs b/src/services/user.rs index 90f5886..5cb125e 100644 --- a/src/services/user.rs +++ b/src/services/user.rs @@ -261,7 +261,7 @@ pub async fn change_password( // 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).await; + notify_password_changed(state, &user, Vec::new()).await; Ok(()) } @@ -321,8 +321,13 @@ pub(crate) async fn access_summary(state: &AppState, user_id: Uuid) -> Vec, +) { let access = access_summary(state, user.id).await; let mailer = state.mailer.clone(); let templates = state.templates.clone(); @@ -339,6 +344,7 @@ pub(crate) async fn notify_password_changed(state: &AppState, user: &User) { &username, &locale, &access, + &removed, ) .await }); diff --git a/templates/emails/en/changed_by_administrator.html b/templates/emails/en/changed_by_administrator.html index 7f9c84e..af3ea01 100644 --- a/templates/emails/en/changed_by_administrator.html +++ b/templates/emails/en/changed_by_administrator.html @@ -6,6 +6,8 @@ {% elif change == "reactivated" %}

An administrator reactivated your {{ app_name }} account: you can sign in again.

{% elif change == "role_granted" %}

An administrator gave your {{ app_name }} account the role {{ role }}.

{% elif change == "role_revoked" %}

Your {{ app_name }} account no longer holds the role {{ role }}.

+{% elif change == "unlocked" %}

An administrator unlocked sign-in with your password on your {{ app_name }} account.

+{% elif change == "access_removed" %}

An administrator removed the second factors, passkeys, linked accounts and personal access tokens of your {{ app_name }} account: add again those that were yours.

{% else %}

An administrator signed your {{ app_name }} account out of every device.

{% endif %}

Your account's history lists this change. If you did not expect it, contact support.

diff --git a/templates/emails/en/password_changed.html b/templates/emails/en/password_changed.html index 15680da..a04ef4a 100644 --- a/templates/emails/en/password_changed.html +++ b/templates/emails/en/password_changed.html @@ -10,5 +10,11 @@ {% for item in access %}
  • {% if item.kind == "passkey" %}Passkey "{{ item.name }}"{% elif item.kind == "totp" %}Authenticator app{% elif item.kind == "email" %}Codes sent by email{% elif item.kind == "personal_access_token" %}Personal access token "{{ item.name }}"{% else %}Sign-in with {{ item.name }}{% endif %}
  • {% endfor %} {% endif %} +{% if removed %} +

    These were added shortly before the reset was asked for and have been removed: add them again if they were yours.

    +
      +{% for item in removed %}
    • {% if item.kind == "passkey" %}Passkey "{{ item.name }}"{% elif item.kind == "totp" %}Authenticator app{% elif item.kind == "email" %}Codes sent by email{% else %}Sign-in with {{ item.name }}{% endif %}
    • +{% endfor %}
    +{% endif %} diff --git a/templates/emails/fr/changed_by_administrator.html b/templates/emails/fr/changed_by_administrator.html index 0b1a854..8f929a8 100644 --- a/templates/emails/fr/changed_by_administrator.html +++ b/templates/emails/fr/changed_by_administrator.html @@ -6,6 +6,8 @@ {% elif change == "reactivated" %}

    Un administrateur a réactivé votre compte {{ app_name }} : vous pouvez de nouveau vous connecter.

    {% elif change == "role_granted" %}

    Un administrateur a attribué le rôle {{ role }} à votre compte {{ app_name }}.

    {% elif change == "role_revoked" %}

    Votre compte {{ app_name }} ne détient plus le rôle {{ role }}.

    +{% elif change == "unlocked" %}

    Un administrateur a débloqué la connexion par mot de passe à votre compte {{ app_name }}.

    +{% elif change == "access_removed" %}

    Un administrateur a retiré les seconds facteurs, passkeys, comptes liés et jetons personnels de votre compte {{ app_name }} : ajoutez de nouveau ceux qui étaient les vôtres.

    {% else %}

    Un administrateur a déconnecté votre compte {{ app_name }} de tous vos appareils.

    {% endif %}

    L'historique de votre compte mentionne ce changement. Si vous ne vous y attendiez pas, contactez le support.

    diff --git a/templates/emails/fr/password_changed.html b/templates/emails/fr/password_changed.html index 9cdefcc..2dd125f 100644 --- a/templates/emails/fr/password_changed.html +++ b/templates/emails/fr/password_changed.html @@ -10,5 +10,11 @@ {% for item in access %}
  • {% if item.kind == "passkey" %}Cle d'acces "{{ item.name }}"{% elif item.kind == "totp" %}Application d'authentification{% elif item.kind == "email" %}Codes envoyes par e-mail{% elif item.kind == "personal_access_token" %}Jeton d'acces personnel "{{ item.name }}"{% else %}Connexion avec {{ item.name }}{% endif %}
  • {% endfor %} {% endif %} +{% if removed %} +

    Ces accès, ajoutés dans les heures qui ont précédé la demande de réinitialisation, ont été retirés : ajoutez-les de nouveau s'ils étaient les vôtres.

    +
      +{% for item in removed %}
    • {% if item.kind == "passkey" %}Passkey « {{ item.name }} »{% elif item.kind == "totp" %}Application d'authentification{% elif item.kind == "email" %}Codes envoyés par email{% else %}Connexion avec {{ item.name }}{% endif %}
    • +{% endfor %}
    +{% endif %} diff --git a/tests/integration/api/admin/privileges.rs b/tests/integration/api/admin/privileges.rs index bb5f6c8..c0df090 100644 --- a/tests/integration/api/admin/privileges.rs +++ b/tests/integration/api/admin/privileges.rs @@ -216,3 +216,52 @@ async fn an_administrator_keeps_a_second_factor() { (409, Some("administrator_needs_second_factor")) ); } + +/// An administrator can remove what someone who knew the password may have +/// added; the owner is told (SEC-69). +#[tokio::test] +async fn an_administrator_removes_the_ways_in_of_a_compromised_account() { + let app = TestApp::spawn().await; + let admin = admin(&app, 1).await; + let target = fixtures::authenticated_user(&app, 2).await; + enroll_second_factor(&app, target.id).await; + + let (status, response) = send( + &app, + Method::DELETE, + &format!("/admin/users/{}/access-factors", target.id), + &admin.token, + json!({}), + ) + .await; + assert_eq!(status, 204, "{response}"); + let methods: i64 = + sqlx::query_scalar("SELECT count(*) FROM two_factor_methods WHERE user_id = $1") + .bind(target.id) + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(methods, 0); + app.mail + .wait_for(&target.email, "An administrator changed your account") + .await; + + // An administrator's own second factor stays. + let other = admin_with_index(&app, 3).await; + let (status, response) = send( + &app, + Method::DELETE, + &format!("/admin/users/{}/access-factors", other.user.id), + &admin.token, + json!({}), + ) + .await; + assert_eq!( + (status, response["code"].as_str()), + (409, Some("administrator_needs_second_factor")) + ); +} + +async fn admin_with_index(app: &TestApp, index: usize) -> Admin { + admin(app, index).await +} diff --git a/tests/security/regressions/lockout.rs b/tests/security/regressions/lockout.rs index 7e998d9..b73327e 100644 --- a/tests/security/regressions/lockout.rs +++ b/tests/security/regressions/lockout.rs @@ -248,3 +248,63 @@ async fn a_burst_of_guesses_cannot_outrun_the_budget() { "{statuses:?}" ); } + +/// A reset removes the second factors and passkeys added shortly before it +/// was asked for (whoever held the password may have planted them), keeps the +/// older ones, and says so in the mail (SEC-69). +#[tokio::test] +async fn a_reset_removes_the_ways_in_added_just_before_it() { + let app = TestApp::spawn().await; + let user = fixtures::authenticated_user(&app, 808).await; + sqlx::query( + "INSERT INTO two_factor_methods (user_id, method_type, is_primary, is_verified, created_at) + VALUES ($1, 'email', TRUE, TRUE, NOW() - INTERVAL '10 days')", + ) + .bind(user.id) + .execute(&app.db) + .await + .unwrap(); + sqlx::query( + "INSERT INTO passkeys (user_id, credential_id, public_key, algorithm, aaguid, name) + VALUES ($1, '\\x0102', '\\x0304', -7, gen_random_uuid(), 'planted')", + ) + .bind(user.id) + .execute(&app.db) + .await + .unwrap(); + + app.post("/auth/forgot-password", &json!({ "email": user.email })) + .await; + let token = app + .mail + .wait_for(&user.email, RESET_SUBJECT) + .await + .value_after("token=") + .unwrap(); + let res = app + .post( + "/auth/reset-password", + &json!({ "token": token, "new_password": "Brand-New-Pass-808!" }), + ) + .await; + assert_eq!(res.status().as_u16(), 200); + + let (methods, passkeys): (i64, i64) = sqlx::query_as( + "SELECT (SELECT count(*) FROM two_factor_methods WHERE user_id = $1), + (SELECT count(*) FROM passkeys WHERE user_id = $1)", + ) + .bind(user.id) + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!( + (methods, passkeys), + (1, 0), + "the old method stays, the new passkey goes" + ); + let mail = app + .mail + .wait_for(&user.email, "Your password has been changed") + .await; + assert!(mail.html.contains("planted"), "{}", mail.html); +} From b14371f54cdb2ec49ffa0e44263cdced9483c514 Mon Sep 17 00:00:00 2001 From: SIIR3X <125802911+SIIR3X@users.noreply.github.com> Date: Thu, 24 Sep 2026 08:25:44 +0200 Subject: [PATCH 34/55] fix(admin): delegate only held permissions and keep traces of changes, not of administrators or endpoint secrets --- CHANGELOG.md | 11 +++ docs/dev/privacy.md | 2 +- docs/dev/security-model.md | 14 +++- src/cli.rs | 10 ++- src/domain/registered_client.rs | 57 +++++++++++++++ src/repositories/export.rs | 11 ++- src/repositories/registered_client.rs | 6 +- src/repositories/role.rs | 6 +- src/repositories/session.rs | 4 +- src/repositories/user.rs | 15 +++- src/services/admin/clients.rs | 3 +- src/services/admin/roles.rs | 52 +++++++++---- src/services/admin/users.rs | 12 ++- src/services/reauth.rs | 5 +- src/services/session.rs | 18 +++-- src/services/user.rs | 14 ++-- src/services/webhooks.rs | 6 +- tests/integration/api/account/export.rs | 26 +++++++ tests/integration/api/admin/privileges.rs | 89 +++++++++++++++++++++++ tests/integration/api/admin/webhooks.rs | 38 ++++++++++ 20 files changed, 343 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2394c8..0e71760 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,17 @@ from scratch (read **Upgrading**). ### Security +- 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 diff --git a/docs/dev/privacy.md b/docs/dev/privacy.md index 612c88d..2d1da7b 100644 --- a/docs/dev/privacy.md +++ b/docs/dev/privacy.md @@ -58,7 +58,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 (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. A change made by an administrator names neither the administrator nor their address | +| 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`) | diff --git a/docs/dev/security-model.md b/docs/dev/security-model.md index 7225409..c864b8a 100644 --- a/docs/dev/security-model.md +++ b/docs/dev/security-model.md @@ -255,8 +255,9 @@ the database together, is out of scope. - 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 adds to a role they hold a - permission they lack, and the default role never grants administration. + 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 @@ -273,8 +274,12 @@ the database together, is out of scope. 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; a webhook's audit keeps the - host it points to (never the path or query), and redeliveries are audited. +- 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 @@ -451,3 +456,4 @@ when a cited test no longer exists. | 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` | diff --git a/src/cli.rs b/src/cli.rs index 2d119d1..577e0b0 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -241,6 +241,9 @@ pub async fn register_client( 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())?; @@ -255,7 +258,12 @@ pub async fn register_client( AuditAction::ClientRegistered }, ip_address: None, - metadata: json!({ "client_id": saved.client_id, "by": "command_line" }), + metadata: { + let mut metadata = + crate::domain::registered_client::audit_changes(previous.as_ref(), &saved); + metadata["by"] = json!("command_line"); + metadata + }, }, ) .await diff --git a/src/domain/registered_client.rs b/src/domain/registered_client.rs index a42c2df..3474d05 100644 --- a/src/domain/registered_client.rs +++ b/src/domain/registered_client.rs @@ -120,6 +120,63 @@ 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_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::*; diff --git a/src/repositories/export.rs b/src/repositories/export.rs index aac9a5d..3f240ef 100644 --- a/src/repositories/export.rs +++ b/src/repositories/export.rs @@ -116,7 +116,10 @@ SELECT jsonb_build_object( SELECT jsonb_agg(jsonb_build_object( 'kind', r.kind, 'requested_at', floor(extract(epoch FROM r.created_at))::bigint, - 'ip_address', host(r.request_ip), + -- 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 ( @@ -138,7 +141,11 @@ SELECT jsonb_build_object( '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 diff --git a/src/repositories/registered_client.rs b/src/repositories/registered_client.rs index f96de72..5bfa7de 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 } diff --git a/src/repositories/role.rs b/src/repositories/role.rs index 5ac4266..9e2fe1d 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) } diff --git a/src/repositories/session.rs b/src/repositories/session.rs index 7da1cc6..6e23438 100644 --- a/src/repositories/session.rs +++ b/src/repositories/session.rs @@ -183,10 +183,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(()) } diff --git a/src/repositories/user.rs b/src/repositories/user.rs index 30befde..2610c4d 100644 --- a/src/repositories/user.rs +++ b/src/repositories/user.rs @@ -69,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(()) } @@ -302,10 +306,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 } diff --git a/src/services/admin/clients.rs b/src/services/admin/clients.rs index 7dafd8e..93989e1 100644 --- a/src/services/admin/clients.rs +++ b/src/services/admin/clients.rs @@ -59,6 +59,7 @@ 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?; 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")); @@ -90,7 +91,7 @@ pub async fn save( } else { AuditAction::ClientRegistered }, - json!({ "client_id": saved.client_id }), + client_domain::audit_changes(previous.as_ref(), &saved), ), ) .await?; diff --git a/src/services/admin/roles.rs b/src/services/admin/roles.rs index a2f9613..8e523c3 100644 --- a/src/services/admin/roles.rs +++ b/src/services/admin/roles.rs @@ -44,6 +44,7 @@ pub async fn create( )); } let permissions = known_permissions(state, permissions).await?; + ensure_actor_holds(state, actor, &permissions).await?; require_reauth(state, actor, "admin_create_role").await?; let mut tx = state.db.begin().await?; @@ -73,21 +74,21 @@ pub async fn set_permissions( ) -> Result<(Role, Vec), AppError> { let role = find(state, name).await?; let permissions = known_permissions(state, permissions).await?; - // Adding to a role one holds a permission one lacks would be granting it - // to oneself (every account holds the default role, so this covers it). - if role_repo::holds_role(&state.db, actor.user_id, role.id).await? { - let current = role_repo::find_all_with_permissions(&state.db) - .await? - .into_iter() - .find(|(r, _)| r.id == role.id) - .map(|(_, granted)| granted) - .unwrap_or_default(); - for added in permissions.iter().filter(|p| !current.contains(p)) { - if !role_repo::user_has_permission(&state.db, actor.user_id, added).await? { - return Err(AppError::Forbidden); - } - } - } + // Nobody delegates what they do not hold: a role manager adding to any + // role a permission they lack could grant it to an accomplice, who would + // grant it back. + let current = role_repo::find_all_with_permissions(&state.db) + .await? + .into_iter() + .find(|(r, _)| r.id == role.id) + .map(|(_, granted)| granted) + .unwrap_or_default(); + let added: Vec = permissions + .iter() + .filter(|p| !current.contains(p)) + .cloned() + .collect(); + ensure_actor_holds(state, actor, &added).await?; // Every account holds the default role: administration in it would make // every new account an administrator. if role.is_default @@ -159,7 +160,8 @@ pub async fn assign( ) -> Result<(), AppError> { refuse_own_account(actor, user_id)?; let role = find(state, name).await?; - let user = user_repo::find_by_id(&state.db, user_id) + // 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?; @@ -168,6 +170,10 @@ pub async fn assign( // 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)?; 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(_) => {} @@ -245,6 +251,20 @@ pub(crate) async fn ensure_can_administer( Ok(()) } +/// Refuse to grant a permission the administrator does not hold. +async fn ensure_actor_holds( + state: &AppState, + actor: &Actor, + permissions: &[String], +) -> Result<(), AppError> { + for permission in permissions { + if !role_repo::user_has_permission(&state.db, actor.user_id, permission).await? { + return Err(AppError::Forbidden); + } + } + Ok(()) +} + async fn find(state: &AppState, name: &str) -> Result { role_repo::find_by_name(&state.db, name) .await? diff --git a/src/services/admin/users.rs b/src/services/admin/users.rs index 68711e9..a9828c5 100644 --- a/src/services/admin/users.rs +++ b/src/services/admin/users.rs @@ -70,13 +70,15 @@ pub async fn suspend(state: &AppState, actor: &Actor, user_id: Uuid) -> Result<( )); } - let manages_roles = - role_repo::user_has_permission(&state.db, user_id, crate::domain::role::ROLES_MANAGE) - .await?; let mut tx = state.db.begin().await?; if !user_repo::suspend(&mut *tx, user_id).await? { return Ok(()); } + // 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?; } @@ -241,7 +243,9 @@ pub async fn force_password_reset( let revoked = drop_access_factors_in(state, actor, user_id).await?; forget_sessions(state, &revoked).await; } - auth_svc::send_reset_link(state, &user, actor.ip, None, true).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 } /// Remove every way into the account other than its password: second diff --git a/src/services/reauth.rs b/src/services/reauth.rs index 4818203..4d6c0ed 100644 --- a/src/services/reauth.rs +++ b/src/services/reauth.rs @@ -64,8 +64,11 @@ pub async fn reauthenticate( reason: &'static str, ) -> Result<(), AppError> { 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 e5f74f7..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).await; - 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/user.rs b/src/services/user.rs index 5cb125e..06a0f44 100644 --- a/src/services/user.rs +++ b/src/services/user.rs @@ -141,7 +141,9 @@ pub async fn change_username( 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| { @@ -149,7 +151,7 @@ pub async fn change_username( })?; audit::append( - &state.db, + &mut *tx, &NewAuditEntry { user_id: Some(user_id), request_id, @@ -160,6 +162,7 @@ pub async fn change_username( ) .await .map_err(|e| AppError::Internal(e.into()))?; + tx.commit().await?; Ok(()) } @@ -430,15 +433,16 @@ pub(crate) async fn erase_account( // 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?; let manages_roles = crate::repositories::role::user_has_permission( - &state.db, + &mut *tx, user_id, crate::domain::role::ROLES_MANAGE, ) .await?; - let mut tx = state.db.begin().await?; - // Appended before the deletion: the foreign key then sets its user_id to NULL. audit::append( &mut *tx, diff --git a/src/services/webhooks.rs b/src/services/webhooks.rs index ff8b7e9..e9c08f7 100644 --- a/src/services/webhooks.rs +++ b/src/services/webhooks.rs @@ -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()), } } diff --git a/tests/integration/api/account/export.rs b/tests/integration/api/account/export.rs index 99e4da1..e349a64 100644 --- a/tests/integration/api/account/export.rs +++ b/tests/integration/api/account/export.rs @@ -105,3 +105,29 @@ async fn the_export_names_no_administrator_nor_their_address() { ); assert_eq!(entry["ip_address"], Value::Null); } + +/// Failed sign-ins typed for the account may be anyone's: the export shows +/// their network, not a stranger's exact address (SEC-70). +#[tokio::test] +async fn the_export_shows_only_the_network_of_strangers() { + let app = TestApp::spawn().await; + let user = fixtures::authenticated_user(&app, 1).await; + sqlx::query( + "INSERT INTO login_attempts (user_id, attempted_identifier, was_successful, failure_reason, request_ip) + VALUES ($1, $2, FALSE, 'invalid_password', '203.0.113.77')", + ) + .bind(user.id) + .bind(&user.email) + .execute(&app.db) + .await + .unwrap(); + + let text = app + .get_auth("/users/me/export", &user.access_token) + .await + .text() + .await + .unwrap(); + assert!(!text.contains("203.0.113.77"), "{text}"); + assert!(text.contains("203.0.113.0"), "{text}"); +} diff --git a/tests/integration/api/admin/privileges.rs b/tests/integration/api/admin/privileges.rs index c0df090..5f8ba15 100644 --- a/tests/integration/api/admin/privileges.rs +++ b/tests/integration/api/admin/privileges.rs @@ -265,3 +265,92 @@ async fn an_administrator_removes_the_ways_in_of_a_compromised_account() { async fn admin_with_index(app: &TestApp, index: usize) -> Admin { admin(app, index).await } + +/// Nobody grants a permission they do not hold, to any role: a role manager +/// cannot create or extend a role with more than they have (SEC-70). +#[tokio::test] +async fn nobody_grants_a_permission_they_lack() { + let app = TestApp::spawn().await; + let admin = admin(&app, 1).await; + let (_, token) = role_manager(&app, &admin).await; + + let (status, _) = send( + &app, + Method::POST, + "/admin/roles", + &token, + json!({ "name": "superadmin", "permissions": ["roles:manage", "users:manage"] }), + ) + .await; + assert_eq!(status, 403); + let (status, _) = send( + &app, + Method::POST, + "/admin/roles", + &admin.token, + json!({ "name": "empty", "permissions": [] }), + ) + .await; + assert_eq!(status, 201); + let (status, _) = send( + &app, + Method::PUT, + "/admin/roles/empty/permissions", + &token, + json!({ "permissions": ["audit:read"] }), + ) + .await; + assert_eq!( + status, 403, + "a role the manager does not hold is no exception" + ); +} + +/// A forced reset leaves no trace of the administrator's address in the link +/// the owner exports, and a client change is audited as a diff (SEC-70). +#[tokio::test] +async fn administrative_traces_describe_the_change_not_the_administrator() { + let app = TestApp::spawn().await; + let admin = admin(&app, 1).await; + let target = fixtures::authenticated_user(&app, 2).await; + let (status, _) = send( + &app, + Method::POST, + &format!("/admin/users/{}/password-reset", target.id), + &admin.token, + json!({}), + ) + .await; + assert_eq!(status, 204); + let addresses: i64 = sqlx::query_scalar( + "SELECT count(*) FROM password_reset_tokens WHERE user_id = $1 AND request_ip IS NOT NULL", + ) + .bind(target.id) + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(addresses, 0); + + for uri in ["https://one.example.com/cb", "https://two.example.com/cb"] { + let (status, body) = send( + &app, + Method::PUT, + "/admin/clients/audited-app", + &admin.token, + json!({ "display_name": "Audited", "redirect_uris": [uri] }), + ) + .await; + assert!(status == 200 || status == 201, "{status} {body}"); + } + let changes: Value = sqlx::query_scalar( + "SELECT metadata->'changes' FROM audit_log WHERE action = 'client_updated' + ORDER BY created_at DESC LIMIT 1", + ) + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!( + changes["redirect_hosts"], + json!({ "before": ["one.example.com"], "after": ["two.example.com"] }) + ); +} diff --git a/tests/integration/api/admin/webhooks.rs b/tests/integration/api/admin/webhooks.rs index 284506e..13d9eea 100644 --- a/tests/integration/api/admin/webhooks.rs +++ b/tests/integration/api/admin/webhooks.rs @@ -484,3 +484,41 @@ async fn a_redelivery_is_audited() { .unwrap(); assert_eq!(redelivered, 1); } + +/// A failed delivery records a fixed message: the HTTP client's error carries +/// the full URL, whose query may hold the endpoint's own token (SEC-70). +#[tokio::test] +async fn a_failed_delivery_never_records_the_endpoint_url() { + let app = TestApp::spawn_with_config(|c| { + c.webhooks.allow_private_networks = true; + c.webhooks.allow_http = true; + }) + .await; + let admin = admin(&app, 1).await; + let (_, created) = send( + &app, + Method::POST, + "/admin/webhooks", + &admin.token, + json!({ "url": "http://127.0.0.1:9/hook?token=endpoint-secret", "events": ["user.created"] }), + ) + .await; + + fixtures::register_user(&app, 2).await; + webhooks::deliver_once(&app.state).await.unwrap(); + let id = created["id"].as_str().unwrap(); + let mut deliveries = delivery_state(&app, &admin.token, id).await; + for _ in 0..50 { + if deliveries[0]["last_error"].is_string() { + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + deliveries = delivery_state(&app, &admin.token, id).await; + } + let error = deliveries[0]["last_error"].as_str().unwrap_or_default(); + assert!(!error.is_empty(), "{deliveries}"); + assert!( + !error.contains("endpoint-secret") && !error.contains("127.0.0.1"), + "{error}" + ); +} From 70ef70dd30c512c8d2cd59dfee0e5e408e51f563 Mon Sep 17 00:00:00 2001 From: SIIR3X <125802911+SIIR3X@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:37:50 +0200 Subject: [PATCH 35/55] fix(db): keep new audit partitions append-only, tie trace erasure to deletion and cap stored hash costs --- CHANGELOG.md | 8 ++++ docs/dev/security-model.md | 15 ++++-- migrations/0010_audit_log.sql | 11 ++++- migrations/0012_personal_data.sql | 20 +++++--- migrations/SHA256SUMS | 4 +- src/repositories/user.rs | 9 ++-- src/services/user.rs | 3 +- src/utils/crypto.rs | 28 +++++++++-- src/utils/password.rs | 37 +++++++++++++++ tests/integration/migrations/runtime_role.rs | 50 ++++++++++++++++++++ 10 files changed, 162 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e71760..021fb8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,14 @@ from scratch (read **Upgrading**). ### Security +- 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 diff --git a/docs/dev/security-model.md b/docs/dev/security-model.md index c864b8a..902ba43 100644 --- a/docs/dev/security-model.md +++ b/docs/dev/security-model.md @@ -26,7 +26,9 @@ the database together, is out of scope. 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. + 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 password answers like a wrong one (`401 invalid_credentials`) and is recorded like one, so budgets read the same too. Registration @@ -156,7 +158,9 @@ the database together, is out of scope. - 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, and each is bound to its account (webhook secrets to their endpoint) as - associated data: a ciphertext copied onto another row does not decrypt. Only + 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 @@ -325,7 +329,11 @@ the database together, is out of scope. 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. PostgreSQL logs slow + 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. @@ -457,3 +465,4 @@ when a cited test no longer exists. | 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` | diff --git a/migrations/0010_audit_log.sql b/migrations/0010_audit_log.sql index 529a1a8..a7b7fb3 100644 --- a/migrations/0010_audit_log.sql +++ b/migrations/0010_audit_log.sql @@ -116,7 +116,9 @@ 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; + -- 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; @@ -136,6 +138,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 diff --git a/migrations/0012_personal_data.sql b/migrations/0012_personal_data.sql index d4329c0..d97b41b 100644 --- a/migrations/0012_personal_data.sql +++ b/migrations/0012_personal_data.sql @@ -3,8 +3,7 @@ -- audit rows and delete accounts, so they run with their owner's privileges -- like rotate_audit_log_partitions. --- 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. @@ -13,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; @@ -26,6 +28,11 @@ 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 SECURITY DEFINER SET search_path = public, pg_temp; @@ -58,8 +65,6 @@ 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 @@ -79,8 +84,9 @@ BEGIN 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 SECURITY DEFINER SET search_path = public, pg_temp; diff --git a/migrations/SHA256SUMS b/migrations/SHA256SUMS index 9702773..002e2c4 100644 --- a/migrations/SHA256SUMS +++ b/migrations/SHA256SUMS @@ -7,9 +7,9 @@ f49cf1363738d98e0faab782cf8db3907097da2897ef60aec0969d0ea41bc47f 0005_sessions. 9a77f658341cc377e103c9f95687816d35e4eb8d30d04b1e79f48364ae8296c6 0007_two_factor.sql c155c5d85738ffc9b08a879001e05875fc4b263afde85b56f32f96f5abf375a5 0008_account_tokens.sql 6c37703c71c8892899764c86bf00b86f09afd362ac17262d4b27045643c316e8 0009_login_attempts.sql -54950d7f925c6d2d8eb4878e7e5b1905b11510a08e054cb313de91d9dd709047 0010_audit_log.sql +009f1f8464309871a0a853986042ad54d07fa59b9c72b3d3e5362f38ac26a0a3 0010_audit_log.sql 76b27021b2b3e978ee4222edf821fbda8d78f39693fdd446279dc4d4d61377dc 0011_event_outbox.sql -c5192b0923eca493d1c194d74d6db96d1c1804e7dffb7f5445be6761c7e26144 0012_personal_data.sql +818974e32858b33358247a7644fb1ee9bc11e3f3216b1338a3898e6fed167d24 0012_personal_data.sql f6434d27992410fe4844cf77d826695d2c064bf9f7cc2533d6ad43982493c011 0013_known_devices.sql b4d5cc42ee28d7ed7f91888176b18a5d9e3f195fe15f131896f4a128b34a997a 0014_magic_links.sql 84077e0347d23ace25d6cc07e19d83dabd2652fdf33794de759765730b5fa6f0 0015_personal_access_tokens.sql diff --git a/src/repositories/user.rs b/src/repositories/user.rs index 2610c4d..7fe3628 100644 --- a/src/repositories/user.rs +++ b/src/repositories/user.rs @@ -334,9 +334,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) diff --git a/src/services/user.rs b/src/services/user.rs index 06a0f44..883c0a4 100644 --- a/src/services/user.rs +++ b/src/services/user.rs @@ -461,8 +461,7 @@ 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?; } diff --git a/src/utils/crypto.rs b/src/utils/crypto.rs index c13474b..ffc29f4 100644 --- a/src/utils/crypto.rs +++ b/src/utils/crypto.rs @@ -123,6 +123,8 @@ pub fn decode_encryption_key(b64: &str) -> Result<[u8; 32], CryptoError> { /// 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"; @@ -153,13 +155,15 @@ struct KeyEntry { 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, @@ -365,6 +369,20 @@ pub fn decrypt_with_aad(encoded: &str, key: &[u8; 32], aad: &[u8]) -> Result bool { } } +/// Whether the parameters written in `hash` would cost far more than the +/// configured ones: a hash planted in the database with, say, 4 GiB of memory +/// would take an instance down at each sign-in attempt. The bound leaves room +/// for configured costs lowered since: four times the configuration, and at +/// least 256 MiB, 16 iterations and 16 lanes. +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(4)).max(262_144) + || params.t_cost() > (cfg.argon2_iterations.saturating_mul(4)).max(16) + || params.p_cost() > (cfg.argon2_parallelism.saturating_mul(4)).max(16) +} + /// 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`). @@ -130,6 +147,10 @@ pub async fn verify_async( hash_value: &str, cfg: &CryptoConfig, ) -> Result { + 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() @@ -246,6 +267,22 @@ mod rehash_tests { ); assert!(!needs_rehash("not a phc string", &config(8, 1, 1))); } + + /// 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(65_536, 3, 4)).unwrap(); + assert!( + !exceeds_configured_cost(&lowered, &cfg), + "a cost lowered since stays readable" + ); + } } #[cfg(test)] diff --git a/tests/integration/migrations/runtime_role.rs b/tests/integration/migrations/runtime_role.rs index 9147167..7b6bff3 100644 --- a/tests/integration/migrations/runtime_role.rs +++ b/tests/integration/migrations/runtime_role.rs @@ -183,3 +183,53 @@ async fn the_maintenance_functions_keep_the_owners_floors() { .unwrap(); assert_eq!(purged, 0, "an account pending for minutes was purged"); } + +/// Partitions created after the grants lose UPDATE and DELETE for the runtime +/// role, the lookahead is bounded, and erasing an account's traces deletes +/// the account: none rewrites the audit trail of an account that stays +/// (SEC-71). +#[tokio::test] +async fn the_audit_trail_stays_out_of_the_runtime_roles_reach() { + let db = TestDb::new().await; + let user_id: uuid::Uuid = sqlx::query_scalar( + "INSERT INTO users (username, email, password_hash) + VALUES ('trail_keeper', 'trail.keeper@example.com', repeat('h', 60)) RETURNING id", + ) + .fetch_one(&db.pool) + .await + .unwrap(); + let mut runtime = runtime_connection(&db).await; + + sqlx::raw_sql("SELECT rotate_audit_log_partitions(12, 1000)") + .execute(&db.pool) + .await + .unwrap(); + let (partitions, writable): (i64, i64) = sqlx::query_as( + "SELECT count(*), + count(*) FILTER (WHERE has_table_privilege('auth_api', c.oid, 'UPDATE') + OR has_table_privilege('auth_api', c.oid, 'DELETE')) + 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'", + ) + .fetch_one(&db.pool) + .await + .unwrap(); + assert!( + partitions <= 27, + "{partitions} partitions: the lookahead is bounded" + ); + assert_eq!(writable, 0, "a partition is writable by the runtime role"); + + sqlx::query("SELECT forget_account_traces($1)") + .bind(user_id) + .execute(&mut runtime) + .await + .unwrap(); + let remaining: i64 = sqlx::query_scalar("SELECT count(*) FROM users WHERE id = $1") + .bind(user_id) + .fetch_one(&db.pool) + .await + .unwrap(); + assert_eq!(remaining, 0, "the traces go only with the account"); +} From ab34c693aa842b8db60d9ef34e4746d5a16eb0d8 Mon Sep 17 00:00:00 2001 From: SIIR3X <125802911+SIIR3X@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:49:56 +0200 Subject: [PATCH 36/55] fix(oauth): reserve introspection to resource servers and honour or refuse every OpenID Connect parameter --- CHANGELOG.md | 14 ++ crates/verifier/tests/verify.rs | 4 +- docs/dev/api/openapi.yaml | 20 ++- docs/dev/api/routes.md | 13 +- docs/dev/guides/integration.md | 2 + docs/dev/security-model.md | 21 ++- migrations/0004_registered_clients.sql | 6 +- migrations/0005_sessions.sql | 4 + migrations/0006_authorization_codes.sql | 2 + migrations/SHA256SUMS | 6 +- src/domain/oauth.rs | 6 + src/domain/registered_client.rs | 8 + src/domain/session.rs | 14 ++ src/fuzzing.rs | 1 + src/handlers/admin/clients.rs | 7 + src/handlers/oauth.rs | 4 +- src/repositories/authorization_code.rs | 9 +- src/repositories/registered_client.rs | 15 ++ src/repositories/session.rs | 19 +- src/services/admin/clients.rs | 5 + src/services/auth/session.rs | 1 + src/services/auth/tokens.rs | 3 + src/services/authorize.rs | 32 +++- src/services/device.rs | 6 + src/services/oauth.rs | 168 ++++++++++++++++-- src/services/personal_access_token.rs | 1 + src/services/reauth.rs | 12 +- src/utils/jwt.rs | 18 ++ .../api/clients/authorization_code.rs | 121 +++++++++++++ .../integration/api/clients/introspection.rs | 74 +++++++- .../repositories/authorization_codes.rs | 1 + tests/security/delegation.rs | 45 +++++ 32 files changed, 619 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 021fb8d..8d73398 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,18 @@ from scratch (read **Upgrading**). ### Security +- 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. @@ -262,6 +274,8 @@ from scratch (read **Upgrading**). 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. +- 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 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/docs/dev/api/openapi.yaml b/docs/dev/api/openapi.yaml index e26c003..27306f8 100644 --- a/docs/dev/api/openapi.yaml +++ b/docs/dev/api/openapi.yaml @@ -6473,11 +6473,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: @@ -6969,6 +6973,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 @@ -6978,7 +6989,7 @@ components: type: - string - 'null' - description: '`access_token`, `refresh_token` or `personal_access_token`.' + description: '`access_token` or `refresh_token`.' LoginRequest: type: object required: @@ -7462,6 +7473,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: diff --git a/docs/dev/api/routes.md b/docs/dev/api/routes.md index 09b6708..a0364ec 100644 --- a/docs/dev/api/routes.md +++ b/docs/dev/api/routes.md @@ -159,11 +159,14 @@ 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`), `scope`, `client_id`, `sub`, `exp`, `iat` -and, for access tokens, `iss`, `aud` and `jti`. A refresh token is described -only to its own client, and a personal access token never. Anything unknown, +**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 diff --git a/docs/dev/guides/integration.md b/docs/dev/guides/integration.md index 38978f5..e5d2b2b 100644 --- a/docs/dev/guides/integration.md +++ b/docs/dev/guides/integration.md @@ -166,6 +166,8 @@ A token's claims: | `roles` | Role names; absent from scoped and client credentials tokens | | `permissions` | Permission names, intersected with the consented scopes | | `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/security-model.md b/docs/dev/security-model.md index 902ba43..e2fd3f3 100644 --- a/docs/dev/security-model.md +++ b/docs/dev/security-model.md @@ -225,9 +225,15 @@ the database together, is out of scope. 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. 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). + 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 and the client's current scopes on every @@ -242,8 +248,12 @@ 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 refresh token is - introspected only by its own client, and personal access tokens never are. + 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. @@ -466,3 +476,4 @@ when a cited test no longer exists. | 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` | diff --git a/migrations/0004_registered_clients.sql b/migrations/0004_registered_clients.sql index 6d3d672..9bdfb68 100644 --- a/migrations/0004_registered_clients.sql +++ b/migrations/0004_registered_clients.sql @@ -16,7 +16,10 @@ -- 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. +-- 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, @@ -28,6 +31,7 @@ CREATE TABLE registered_clients ( 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}$'), diff --git a/migrations/0005_sessions.sql b/migrations/0005_sessions.sql index 63bd199..4850e95 100644 --- a/migrations/0005_sessions.sql +++ b/migrations/0005_sessions.sql @@ -40,6 +40,10 @@ CREATE TABLE sessions ( -- 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), diff --git a/migrations/0006_authorization_codes.sql b/migrations/0006_authorization_codes.sql index 783bdfb..b974668 100644 --- a/migrations/0006_authorization_codes.sql +++ b/migrations/0006_authorization_codes.sql @@ -17,6 +17,8 @@ CREATE TABLE authorization_codes ( 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(), diff --git a/migrations/SHA256SUMS b/migrations/SHA256SUMS index 002e2c4..23d0d8b 100644 --- a/migrations/SHA256SUMS +++ b/migrations/SHA256SUMS @@ -1,9 +1,9 @@ f68dfef5383a0f730e87df31c73443ae4dc79e4dabb5301290736812c9079b3e 0001_extensions.sql e6e6e7ec30c6268bcce94c143b9afaa28a0f52afe47fa2cc05ab850c7f932f02 0002_users.sql c6e3ddfcd4e5d5c1a0c20ed88e4ecda498f0e1693d236038a2854504380a1092 0003_rbac.sql -b5c48c512a330e12b1fdc5a648f7b82e079a9e02285c39b41d1a38894fbc1d58 0004_registered_clients.sql -f49cf1363738d98e0faab782cf8db3907097da2897ef60aec0969d0ea41bc47f 0005_sessions.sql -18981b163c5fef8eaa8ec9c8df12b11c10464c4794bf4fbcb875e7a0f304005e 0006_authorization_codes.sql +044c137913fb9d27d7af24063b7e7bb8e6f1e299c17c1659165b5d6326e84d84 0004_registered_clients.sql +dea82120a98c1cef8fab3064cc46edf577dd2408ff9c7a9e134a48f7ff5ca44c 0005_sessions.sql +b5015380ae092193f8d92ed035244005ad202c0fc9fdff2f9594058ce6f1cd47 0006_authorization_codes.sql 9a77f658341cc377e103c9f95687816d35e4eb8d30d04b1e79f48364ae8296c6 0007_two_factor.sql c155c5d85738ffc9b08a879001e05875fc4b263afde85b56f32f96f5abf375a5 0008_account_tokens.sql 6c37703c71c8892899764c86bf00b86f09afd362ac17262d4b27045643c316e8 0009_login_attempts.sql diff --git a/src/domain/oauth.rs b/src/domain/oauth.rs index 6971809..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", } } } diff --git a/src/domain/registered_client.rs b/src/domain/registered_client.rs index 3474d05..dbe25c0 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 { @@ -169,6 +171,11 @@ pub fn audit_changes( }), 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)), @@ -193,6 +200,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 fabce47..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 { @@ -86,6 +96,9 @@ pub struct Session { 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 { @@ -261,6 +274,7 @@ mod tests { client_id: None, compromise_reason: None, mfa: false, + auth_time: None, } } diff --git a/src/fuzzing.rs b/src/fuzzing.rs index 72384ba..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) { diff --git a/src/handlers/admin/clients.rs b/src/handlers/admin/clients.rs index abce1ea..9332986 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, } @@ -57,12 +59,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, @@ -131,6 +137,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 { diff --git a/src/handlers/oauth.rs b/src/handlers/oauth.rs index 168b8fb..eedc013 100644 --- a/src/handlers/oauth.rs +++ b/src/handlers/oauth.rs @@ -419,10 +419,10 @@ pub async fn approve_request( )] pub async fn deny_request( State(state): State, - _auth: FirstPartyUser, + 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 })) } diff --git a/src/repositories/authorization_code.rs b/src/repositories/authorization_code.rs index cb264d8..3c8c442 100644 --- a/src/repositories/authorization_code.rs +++ b/src/repositories/authorization_code.rs @@ -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,17 @@ 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 { 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,6 +51,7 @@ pub async fn create(pool: &PgPool, input: &NewAuthorizationCode<'_>) -> Result( 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>( diff --git a/src/repositories/session.rs b/src/repositories/session.rs index 6e23438..48b9585 100644 --- a/src/repositories/session.rs +++ b/src/repositories/session.rs @@ -147,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, mfa) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + (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) @@ -164,6 +164,7 @@ pub async fn rotate( .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?; @@ -382,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/services/admin/clients.rs b/src/services/admin/clients.rs index 93989e1..75b679b 100644 --- a/src/services/admin/clients.rs +++ b/src/services/admin/clients.rs @@ -31,6 +31,7 @@ pub async fn save( actor: &Actor, client: &NewRegisteredClient<'_>, allows_client_credentials: Option, + allows_introspection: Option, ) -> Result<(RegisteredClient, bool), AppError> { client_domain::check_settings( client.client_id, @@ -82,6 +83,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( diff --git a/src/services/auth/session.rs b/src/services/auth/session.rs index 8c7d35b..72189b2 100644 --- a/src/services/auth/session.rs +++ b/src/services/auth/session.rs @@ -224,6 +224,7 @@ pub async fn refresh_token( new_session.id, new_session.scopes.as_deref(), new_session.client_id.as_deref(), + &new_session.session_type, state, ) .await?; diff --git a/src/services/auth/tokens.rs b/src/services/auth/tokens.rs index 1010c29..ec6b145 100644 --- a/src/services/auth/tokens.rs +++ b/src/services/auth/tokens.rs @@ -149,6 +149,7 @@ pub(crate) async fn issue_tokens( session.id, scopes, session.client_id.as_deref(), + &session.session_type, state, ) .await?; @@ -205,6 +206,7 @@ pub(crate) async fn build_access_token( session_id: uuid::Uuid, scopes: Option<&[String]>, client_id: Option<&str>, + session_type: &SessionType, state: &AppState, ) -> Result { let issued_at = state.clock.now(); @@ -243,6 +245,7 @@ pub(crate) async fn build_access_token( 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. diff --git a/src/services/authorize.rs b/src/services/authorize.rs index f0cb832..3948300 100644 --- a/src/services/authorize.rs +++ b/src/services/authorize.rs @@ -56,6 +56,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, @@ -145,6 +147,7 @@ pub async fn approve(state: &AppState, approval: &Approval<'_>) -> Result) -> Result, held: &[String]) -> Option> { +pub(crate) fn consent(requested: Option<&[String]>, held: &[String]) -> Option> { requested.map(|requested| { requested .iter() @@ -194,7 +197,7 @@ pub async fn redeem( .map_err(|e| AppError::Internal(e.into()))? else { drop(tx); - revoke_on_replay(state, &hash).await; + revoke_on_replay(state, &hash, request.client_id).await; return Err(AppError::InvalidAuthorizationCode); }; @@ -248,6 +251,11 @@ pub async fn redeem( 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()))?; @@ -257,13 +265,25 @@ pub async fn redeem( /// 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) { 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; + } tracing::warn!(client_id = %seen.client_id, "authorization code replayed after redemption"); if let Some(session_id) = seen.session_id && let Err(e) = auth_svc::revoke_family(state, session_id).await @@ -424,7 +444,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()))? @@ -449,6 +472,7 @@ mod tests { default_max_sessions: 5, client_secret_hash: None, allows_client_credentials: false, + allows_introspection: false, } } diff --git a/src/services/device.rs b/src/services/device.rs index a6c320d..e45d15a 100644 --- a/src/services/device.rs +++ b/src/services/device.rs @@ -551,6 +551,12 @@ async fn update_status( } 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; let updated = serde_json::to_string(&entry).map_err(|e| AppError::Internal(e.into()))?; diff --git a/src/services/oauth.rs b/src/services/oauth.rs index 21b016d..53a8ed3 100644 --- a/src/services/oauth.rs +++ b/src/services/oauth.rs @@ -278,6 +278,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. @@ -342,6 +349,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"); }; @@ -364,6 +413,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 @@ -472,16 +523,67 @@ 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) - .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, + mut request: StoredRequest, + user_id: Uuid, +) -> Result { + 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 still unclaimed, keeping the request's expiry. + let claimed: Option = deadpool_redis::redis::cmd("SET") + .arg(request_key(id)) + .arg(stored) + .arg("XX") + .arg("KEEPTTL") + .query_async(&mut *conn) + .await + .map_err(|e| AppError::Internal(e.into()))?; + if claimed.is_none() { + 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, @@ -493,9 +595,17 @@ pub async fn approve_request( request_id: Option, ) -> Result { let (request, client) = load_request(state, id).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 @@ -512,6 +622,10 @@ pub async fn approve_request( ) .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, @@ -520,6 +634,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, @@ -537,8 +652,9 @@ pub async fn approve_request( } /// 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()), @@ -686,7 +802,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), @@ -750,6 +872,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 = @@ -788,9 +911,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")] @@ -851,6 +978,14 @@ 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()); + } let active = match claims.client_id.as_deref() { // A client credentials token: active while not revoked and the // client may still use the grant. @@ -867,16 +1002,27 @@ 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)) + .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), diff --git a/src/services/personal_access_token.rs b/src/services/personal_access_token.rs index fcfb24e..c1eddc5 100644 --- a/src/services/personal_access_token.rs +++ b/src/services/personal_access_token.rs @@ -204,6 +204,7 @@ pub async fn exchange(state: &AppState, presented: &str) -> 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) => { diff --git a/src/utils/jwt.rs b/src/utils/jwt.rs index 3bd5d85..3bc7f36 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, } } @@ -422,6 +432,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!( @@ -446,6 +458,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!( @@ -640,6 +654,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(); @@ -673,6 +689,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(); diff --git a/tests/integration/api/clients/authorization_code.rs b/tests/integration/api/clients/authorization_code.rs index 866dc83..fd84a4c 100644 --- a/tests/integration/api/clients/authorization_code.rs +++ b/tests/integration/api/clients/authorization_code.rs @@ -719,3 +719,124 @@ async fn a_client_access_token_is_typed_and_names_its_client() { Some(PARTNER) ); } + +/// OpenID Connect parameters this server does not honour are refused, not +/// ignored, and `max_age` / `prompt=login` ask for the password again at the +/// approval (SEC-72). +#[tokio::test] +async fn unsupported_oidc_parameters_are_refused_and_max_age_is_honoured() { + let app = TestApp::spawn().await; + register_client(&app, PARTNER, false, &[], 5).await; + let user = fixtures::authenticated_user(&app, 62).await; + let p = pkce(); + + for (extra, error) in [ + (("prompt", "none"), "interaction_required"), + (("request", "eyJ..."), "request_not_supported"), + ( + ("request_uri", "https://x/req"), + "request_uri_not_supported", + ), + (("response_mode", "fragment"), "invalid_request"), + ] { + let (status, location, _) = + authorize(&app, ¶meters(PARTNER, CALLBACK, &p.challenge, &[extra])).await; + assert_eq!(status, 303, "{extra:?}"); + assert_eq!( + query_param(&location, "error").as_deref(), + Some(error), + "{extra:?}" + ); + } + + // The fixture's session proved its password moments ago, yet not within + // max_age=0: the approval asks for it again. + let request_id = request(&app, PARTNER, CALLBACK, &p, &[("max_age", "0")]).await; + let (status, body) = approve_raw(&app, &user, &request_id, json!({})).await; + assert_eq!( + (status, body["code"].as_str()), + (403, Some("reauthentication_required")) + ); + let (status, body) = approve_raw( + &app, + &user, + &request_id, + json!({ "current_password": user.password }), + ) + .await; + assert_eq!(status, 200, "{body}"); +} + +/// A request belongs to the first signed-in user who looks at it, and a code +/// replayed by another client leaves the session of its own client alone +/// (SEC-72). +#[tokio::test] +async fn a_request_is_decided_by_its_viewer_and_a_foreign_replay_revokes_nothing() { + let app = TestApp::spawn().await; + register_client(&app, PARTNER, false, &[], 5).await; + register_client(&app, PRIMARY, true, &[], 5).await; + let owner = fixtures::authenticated_user(&app, 63).await; + let stranger = fixtures::authenticated_user(&app, 64).await; + let p = pkce(); + + let request_id = request(&app, PARTNER, CALLBACK, &p, &[]).await; + let seen = app + .get_auth( + &format!("/oauth/authorization-requests/{request_id}"), + &owner.access_token, + ) + .await; + assert_eq!(seen.status().as_u16(), 200); + let (status, _) = approve_raw( + &app, + &stranger, + &request_id, + json!({ "current_password": stranger.password }), + ) + .await; + assert_eq!(status, 404, "another user cannot decide it"); + + let code = code_for(&app, &owner, PARTNER, CALLBACK, &p).await; + let (status, tokens) = redeem(&app, &code, &p.verifier, PARTNER, CALLBACK).await; + assert_eq!(status, 200, "{tokens}"); + let (status, _) = redeem(&app, &code, &p.verifier, PRIMARY, CALLBACK).await; + assert_eq!(status, 400); + let (status, _) = refresh(&app, tokens["refresh_token"].as_str().unwrap(), PARTNER).await; + assert_eq!( + status, 200, + "a replay under another client's id revoked nothing" + ); +} + +/// The ID token says when the password was proved for the consent, and an +/// access token says what its subject is (SEC-72). +#[tokio::test] +async fn tokens_say_when_and_who() { + let app = TestApp::spawn().await; + register_client(&app, PARTNER, false, &["openid"], 5).await; + let user = fixtures::authenticated_user(&app, 65).await; + let p = pkce(); + let before = time::OffsetDateTime::now_utc().unix_timestamp() - 5; + let request_id = request(&app, PARTNER, CALLBACK, &p, &[("scope", "openid")]).await; + let (status, body) = approve_raw( + &app, + &user, + &request_id, + json!({ "current_password": user.password }), + ) + .await; + assert_eq!(status, 200, "{body}"); + let code = query_param(body["redirect_to"].as_str().unwrap(), "code").unwrap(); + let (status, tokens) = redeem(&app, &code, &p.verifier, PARTNER, CALLBACK).await; + assert_eq!(status, 200, "{tokens}"); + + let id_token = claims(tokens["id_token"].as_str().unwrap()); + assert!( + id_token["auth_time"].as_i64().unwrap() >= before, + "{id_token}" + ); + assert_eq!( + claims(tokens["access_token"].as_str().unwrap())["sub_type"], + "user" + ); +} diff --git a/tests/integration/api/clients/introspection.rs b/tests/integration/api/clients/introspection.rs index e5e2a22..d645af7 100644 --- a/tests/integration/api/clients/introspection.rs +++ b/tests/integration/api/clients/introspection.rs @@ -2,7 +2,7 @@ use serde_json::{Value, json}; -use super::form; +use super::{claims, form}; use crate::common::{ app::TestApp, fixtures::{self, AuthenticatedUser}, @@ -12,8 +12,8 @@ const RESOURCE_SERVER: (&str, &str) = ("resource-server", "aacs_resource-server- async fn setup(app: &TestApp) { sqlx::query( - "INSERT INTO registered_clients (client_id, display_name, is_primary, client_secret_hash) - VALUES ($1, 'Resource server', FALSE, $2)", + "INSERT INTO registered_clients (client_id, display_name, is_primary, client_secret_hash, allows_introspection) + VALUES ($1, 'Resource server', FALSE, $2, TRUE)", ) .bind(RESOURCE_SERVER.0) .bind(auth_api::utils::crypto::sha256(RESOURCE_SERVER.1.as_bytes()).to_vec()) @@ -250,3 +250,71 @@ async fn introspection_reveals_no_personal_or_foreign_refresh_token() { assert_eq!(introspect(&app, token).await, json!({ "active": false })); } } + +/// Only a resource server introspects the tokens of others; another +/// confidential client learns nothing of them. A resource server registered +/// with scopes learns only those, and the session a token comes from shows +/// (SEC-72). +#[tokio::test] +async fn only_resource_servers_introspect_the_tokens_of_others() { + let app = TestApp::spawn().await; + setup(&app).await; + let plain = ("plain-confidential", "aacs_plain-confidential-secret"); + sqlx::query( + "INSERT INTO registered_clients (client_id, display_name, client_secret_hash) + VALUES ($1, $1, $2)", + ) + .bind(plain.0) + .bind(auth_api::utils::crypto::sha256(plain.1.as_bytes()).to_vec()) + .execute(&app.db) + .await + .unwrap(); + let user = fixtures::authenticated_user(&app, 3).await; + + let (status, body) = form( + &app, + "/oauth/introspect", + &[("token", user.access_token.as_str())], + Some(plain), + ) + .await; + assert_eq!((status, body), (200, json!({ "active": false }))); + + let seen = introspect(&app, &user.access_token).await; + assert_eq!(seen["active"], true); + assert_eq!(seen["session_type"], "web"); + + sqlx::query("UPDATE registered_clients SET scopes = ARRAY['audit:read'] WHERE client_id = $1") + .bind(RESOURCE_SERVER.0) + .execute(&app.db) + .await + .unwrap(); + let narrowed = introspect(&app, &user.access_token).await; + assert!(narrowed.get("scope").is_none(), "{narrowed}"); + + let created: Value = app + .post_auth( + "/users/me/tokens", + &user.access_token, + &json!({ "name": "ci" }), + ) + .await + .json() + .await + .unwrap(); + let exchanged: Value = app + .post( + "/auth/personal-access-tokens/exchange", + &json!({ "token": created["secret"] }), + ) + .await + .json() + .await + .unwrap(); + let script = introspect(&app, exchanged["access_token"].as_str().unwrap()).await; + assert_eq!(script["session_type"], "personal_access_token"); + assert_eq!( + claims(exchanged["access_token"].as_str().unwrap())["session_type"], + "personal_access_token" + ); +} diff --git a/tests/integration/repositories/authorization_codes.rs b/tests/integration/repositories/authorization_codes.rs index 7eee2fe..c3585bf 100644 --- a/tests/integration/repositories/authorization_codes.rs +++ b/tests/integration/repositories/authorization_codes.rs @@ -40,6 +40,7 @@ async fn code(db: &TestDb, user_id: Uuid, hash: &[u8; 32], expires_at: OffsetDat code_challenge: CHALLENGE, scopes: None, nonce: None, + auth_time: OffsetDateTime::now_utc(), expires_at, }, ) diff --git a/tests/security/delegation.rs b/tests/security/delegation.rs index 8382226..844448e 100644 --- a/tests/security/delegation.rs +++ b/tests/security/delegation.rs @@ -331,3 +331,48 @@ async fn a_public_clients_budget_is_split_by_address() { .await; assert_eq!(status, StatusCode::OK, "{body}"); } + +/// A device's consent is what the user held when approving: a permission the +/// user obtains later does not reach the device without a new consent +/// (SEC-72). +#[tokio::test] +async fn a_device_consent_is_frozen_at_the_approval() { + let app = TestApp::spawn().await; + sqlx::query( + "INSERT INTO registered_clients (client_id, display_name, is_primary, default_max_sessions, scopes) + VALUES ('reporting', 'reporting', FALSE, 5, ARRAY['users:read'])", + ) + .execute(&app.db) + .await + .unwrap(); + let user = fixtures::authenticated_user(&app, 6).await; + + let (status, started) = form( + &app, + "/oauth/device_authorization", + &[("client_id", "reporting"), ("scope", "users:read")], + ) + .await; + assert_eq!(status, StatusCode::OK, "{started}"); + let (status, body) = approve( + &app, + &user.access_token, + json!({ "user_code": started["user_code"], "current_password": user.password }), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let (status, body) = poll(&app, started["device_code"].as_str().unwrap(), "reporting").await; + assert_eq!(status, StatusCode::OK, "{body}"); + + let scopes: Vec = sqlx::query_scalar( + "SELECT scopes FROM sessions WHERE user_id = $1 AND client_id = 'reporting'", + ) + .bind(user.id) + .fetch_one(&app.db) + .await + .unwrap(); + assert!( + scopes.is_empty(), + "consented beyond what the user held: {scopes:?}" + ); +} From ecfdf7327a54fff1ee4cd404f4e30b38bb4a3d0d Mon Sep 17 00:00:00 2001 From: SIIR3X <125802911+SIIR3X@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:02:03 +0200 Subject: [PATCH 37/55] fix(deploy): cap production settings, fence the published ports and mask URL codes in logs --- CHANGELOG.md | 12 ++++++ Dockerfile | 4 +- Makefile | 2 +- deploy/api/nftables-auth-api.conf | 20 ++++++++++ deploy/db/users.acl.template | 6 ++- docs/deploy/api/deployment.md | 8 +++- docs/deploy/guides/update.md | 2 +- docs/dev/guides/configuration.md | 8 +++- docs/dev/security-model.md | 11 ++++-- docs/dev/threat-model.md | 4 ++ nginx/nginx.conf | 10 ++++- scripts/rolling-update.sh | 6 ++- src/config/tests.rs | 28 +++++++++++++- src/config/validate.rs | 61 ++++++++++++++++++++++++++++++- src/handlers/mod.rs | 32 ++++++++++++++++ tests/security/headers.rs | 1 + 16 files changed, 196 insertions(+), 19 deletions(-) create mode 100644 deploy/api/nftables-auth-api.conf diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d73398..aa23bea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,15 @@ 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 @@ -274,6 +283,9 @@ from scratch (read **Upgrading**). 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` 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 eaf6664..edff326 100644 --- a/Makefile +++ b/Makefile @@ -226,7 +226,7 @@ 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 \ + 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 diff --git a/deploy/api/nftables-auth-api.conf b/deploy/api/nftables-auth-api.conf new file mode 100644 index 0000000..79aa4e3 --- /dev/null +++ b/deploy/api/nftables-auth-api.conf @@ -0,0 +1,20 @@ +# 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 +# Check: `sudo -u nobody curl -s 127.0.0.1:3001/live` is 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. + +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 + } +} diff --git a/deploy/db/users.acl.template b/deploy/db/users.acl.template index 862e969..d9d5830 100644 --- a/deploy/db/users.acl.template +++ b/deploy/db/users.acl.template @@ -2,6 +2,8 @@ # 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. +# 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 +user auth_api on #REDIS_PASSWORD_SHA256 ~* &* +@all -@dangerous -@admin +eval +evalsha +script|load +eval +evalsha +script|load diff --git a/docs/deploy/api/deployment.md b/docs/deploy/api/deployment.md index 2782680..4b26e19 100644 --- a/docs/deploy/api/deployment.md +++ b/docs/deploy/api/deployment.md @@ -132,11 +132,17 @@ 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/guides/update.md b/docs/deploy/guides/update.md index 7740c33..af56fe6 100644 --- a/docs/deploy/guides/update.md +++ b/docs/deploy/guides/update.md @@ -78,7 +78,7 @@ export NATS_URL=$(pass prod/auth-api/nats-url) export METRICS_TOKEN=$(pass prod/auth-api/metrics-token) ./write-secrets.sh -./rolling-update.sh +sudo -E ./rolling-update.sh ``` `write-secrets.sh` writes the values to `/etc/auth-api/secrets` (a directory diff --git a/docs/dev/guides/configuration.md b/docs/dev/guides/configuration.md index ca8bf4c..a90e5dd 100644 --- a/docs/dev/guides/configuration.md +++ b/docs/dev/guides/configuration.md @@ -248,7 +248,7 @@ With `APP_ENV=production` the service refuses to start when: - `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 wider than `/8` (IPv4) or `/32` (IPv6), from + address), or holds a network wider than `/24` (IPv4) or `/64` (IPv6), from which any peer 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 @@ -262,7 +262,11 @@ With `APP_ENV=production` the service refuses to start when: - `WEBAUTHN_ORIGINS` is empty, or lists an origin that is not HTTPS or not on `WEBAUTHN_RP_ID`; - `ARGON2_MEMORY_KIB` is under `19456` or `ARGON2_ITERATIONS` under `2`; -- `DATABASE_URL`, `DATABASE_READ_URL` or `REDIS_URL` carries no password. +- `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: diff --git a/docs/dev/security-model.md b/docs/dev/security-model.md index e2fd3f3..70d929a 100644 --- a/docs/dev/security-model.md +++ b/docs/dev/security-model.md @@ -362,9 +362,13 @@ the database together, is out of scope. `/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. The production compose - file hands the secrets to the instances as files, out of `docker inspect` - and the process environment. + 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 @@ -477,3 +481,4 @@ when a cited test no longer exists. | 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` | diff --git a/docs/dev/threat-model.md b/docs/dev/threat-model.md index cdc90c2..911abc0 100644 --- a/docs/dev/threat-model.md +++ b/docs/dev/threat-model.md @@ -131,6 +131,10 @@ 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. Budgets on diff --git a/nginx/nginx.conf b/nginx/nginx.conf index f816fd8..0c1da49 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -27,10 +27,18 @@ map "$request_method $uri" $auth_limit_key { } 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"}'; 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/src/config/tests.rs b/src/config/tests.rs index 04317f8..cd9bb1d 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.0/24".parse().unwrap()], }, database: DatabaseConfig { url: "postgres://user:pass@localhost/db".into(), @@ -1267,7 +1267,7 @@ fn validate_rejects_settings_that_undo_their_control() { 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/7"] { + for wide in ["0.0.0.0/0", "::/0", "10.0.0.0/7", "10.0.0.0/8", "fd00::/48"] { let mut config = valid_config(); config.server.trusted_proxy_cidrs = vec![wide.parse().unwrap()]; assert_eq!(invalid_key(config), "TRUSTED_PROXY_CIDRS", "{wide}"); @@ -1282,3 +1282,27 @@ fn a_blank_required_variable_is_missing() { "{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"); +} diff --git a/src/config/validate.rs b/src/config/validate.rs index 24e9b87..14027ec 100644 --- a/src/config/validate.rs +++ b/src/config/validate.rs @@ -83,6 +83,7 @@ impl Config { ), }); } + validate_production_ceilings(self)?; validate_production_argon2(&self.crypto)?; validate_production_encryption_key("ENCRYPTION_KEY", &self.crypto.encryption_key)?; @@ -475,6 +476,56 @@ pub(super) fn validate_production_argon2(crypto: &CryptoConfig) -> Result<(), Co 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(), + }); + } + Ok(()) +} + /// A limit of zero refuses every request. pub(super) fn validate_rate_limits(limits: &RateLimitConfig) -> Result<(), ConfigError> { for (key, value) in [ @@ -556,8 +607,14 @@ const MAX_DEVICE_AUTH_TTL_SECS: u64 = 1800; /// Widest trusted proxy network: anything wider lets arbitrary peers forge /// `X-Forwarded-For` and pick their own address. -const MIN_TRUSTED_PROXY_PREFIX_V4: u8 = 8; -const MIN_TRUSTED_PROXY_PREFIX_V6: u8 = 32; +const MIN_TRUSTED_PROXY_PREFIX_V4: u8 = 24; +const MIN_TRUSTED_PROXY_PREFIX_V6: u8 = 64; + +/// 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. diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 95d3674..9a98bf4 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -187,6 +187,17 @@ async fn ready_detail( (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( @@ -278,6 +289,7 @@ pub fn router_with_metrics(state: AppState) -> (Router, Router) { internal_bearer, )) .with_state(state); + let internal = internal_limits(internal); (app, internal) } @@ -701,6 +713,26 @@ fn me_router() -> Router { 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() { diff --git a/tests/security/headers.rs b/tests/security/headers.rs index 1c9fa74..3453b49 100644 --- a/tests/security/headers.rs +++ b/tests/security/headers.rs @@ -215,6 +215,7 @@ async fn security_headers_enable_hsts_for_https_production() { config.webhooks.allow_private_networks = false; config.crypto.argon2_memory_kib = 19_456; config.crypto.argon2_iterations = 2; + config.pwned_passwords.enabled = true; // The committed development AES key is refused in production. config.crypto.encryption_key = "6M+xtK7VzYMoz/3mc3vJf2e6h9b9yLyx3Eabo/236YE=".into(); // Production requires a password in REDIS_URL; the test Redis has From 705fb2e428cb39c1e2b12ede27e07ddbc4b3b020 Mon Sep 17 00:00:00 2001 From: SIIR3X <125802911+SIIR3X@users.noreply.github.com> Date: Fri, 25 Sep 2026 01:14:09 +0200 Subject: [PATCH 38/55] docs: record the third audit's fixes and accepted risks --- CHANGELOG.md | 2 +- docs/dev/guides/release.md | 1 + docs/dev/threat-model.md | 3 ++- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa23bea..1d27175 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ as described in the [versioning policy](docs/dev/guides/versioning.md). ## [2.1.0] - 2026-09-26 Security release: fixes every finding of the security audit of 2026-09-26 -and of the independent re-audit that followed it. +and of the two 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 diff --git a/docs/dev/guides/release.md b/docs/dev/guides/release.md index cac2ecf..3eea3a8 100644 --- a/docs/dev/guides/release.md +++ b/docs/dev/guides/release.md @@ -60,6 +60,7 @@ HIGH or CRITICAL vulnerability with a fix stops the release. | `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) | | `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/threat-model.md b/docs/dev/threat-model.md index 911abc0..3170af5 100644 --- a/docs/dev/threat-model.md +++ b/docs/dev/threat-model.md @@ -137,7 +137,8 @@ at least once a year. 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. Budgets on + identifier that reaches a person, are never confirmed this way (kept after + the third audit: usernames are public in this product). 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 From edec0965d410eb67e34788d8cb51be0b77821fd3 Mon Sep 17 00:00:00 2001 From: SIIR3X <125802911+SIIR3X@users.noreply.github.com> Date: Fri, 25 Sep 2026 05:26:15 +0200 Subject: [PATCH 39/55] fix(admin): require every permission of a role to grant, withdraw, empty or delete it --- docs/dev/security-model.md | 1 + src/repositories/role.rs | 21 +++++++ src/services/admin/roles.rs | 48 +++++++++------- tests/integration/api/admin/privileges.rs | 69 +++++++++++++++++++++++ 4 files changed, 120 insertions(+), 19 deletions(-) diff --git a/docs/dev/security-model.md b/docs/dev/security-model.md index 70d929a..15babef 100644 --- a/docs/dev/security-model.md +++ b/docs/dev/security-model.md @@ -482,3 +482,4 @@ when a cited test no longer exists. | 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` | diff --git a/src/repositories/role.rs b/src/repositories/role.rs index 9e2fe1d..88d176e 100644 --- a/src/repositories/role.rs +++ b/src/repositories/role.rs @@ -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) diff --git a/src/services/admin/roles.rs b/src/services/admin/roles.rs index 8e523c3..9691b3c 100644 --- a/src/services/admin/roles.rs +++ b/src/services/admin/roles.rs @@ -44,10 +44,10 @@ pub async fn create( )); } let permissions = known_permissions(state, permissions).await?; - ensure_actor_holds(state, actor, &permissions).await?; 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")]))?; @@ -74,21 +74,6 @@ pub async fn set_permissions( ) -> Result<(Role, Vec), AppError> { let role = find(state, name).await?; let permissions = known_permissions(state, permissions).await?; - // Nobody delegates what they do not hold: a role manager adding to any - // role a permission they lack could grant it to an accomplice, who would - // grant it back. - let current = role_repo::find_all_with_permissions(&state.db) - .await? - .into_iter() - .find(|(r, _)| r.id == role.id) - .map(|(_, granted)| granted) - .unwrap_or_default(); - let added: Vec = permissions - .iter() - .filter(|p| !current.contains(p)) - .cloned() - .collect(); - ensure_actor_holds(state, actor, &added).await?; // Every account holds the default role: administration in it would make // every new account an administrator. if role.is_default @@ -101,6 +86,19 @@ pub async fn set_permissions( 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?; role_repo::set_permissions(&mut tx, role.id, &permissions).await?; keep_an_administrator(&mut tx).await?; audit::append( @@ -124,6 +122,9 @@ pub async fn delete(state: &AppState, actor: &Actor, name: &str) -> Result<(), A 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?; @@ -174,6 +175,10 @@ pub async fn assign( 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(_) => {} @@ -204,6 +209,10 @@ pub async fn unassign( 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(()); } @@ -251,14 +260,15 @@ pub(crate) async fn ensure_can_administer( Ok(()) } -/// Refuse to grant a permission the administrator does not hold. +/// 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( - state: &AppState, + tx: &mut sqlx::PgConnection, actor: &Actor, permissions: &[String], ) -> Result<(), AppError> { for permission in permissions { - if !role_repo::user_has_permission(&state.db, actor.user_id, permission).await? { + if !role_repo::user_has_permission(&mut *tx, actor.user_id, permission).await? { return Err(AppError::Forbidden); } } diff --git a/tests/integration/api/admin/privileges.rs b/tests/integration/api/admin/privileges.rs index 5f8ba15..79e361b 100644 --- a/tests/integration/api/admin/privileges.rs +++ b/tests/integration/api/admin/privileges.rs @@ -354,3 +354,72 @@ async fn administrative_traces_describe_the_change_not_the_administrator() { json!({ "before": ["one.example.com"], "after": ["two.example.com"] }) ); } + +/// Granting or withdrawing a role delegates every permission it holds: a +/// role manager can neither hand `admin` to an account they control nor +/// strip it, or a permission of it, from anyone (SEC-74). +#[tokio::test] +async fn nobody_grants_or_withdraws_a_role_holding_more_than_they_have() { + let app = TestApp::spawn().await; + let admin = admin(&app, 1).await; + let (_, token) = role_manager(&app, &admin).await; + let puppet = fixtures::authenticated_user(&app, 31).await; + enroll_second_factor(&app, puppet.id).await; + + let (status, _) = send( + &app, + Method::POST, + &format!("/admin/users/{}/roles", puppet.id), + &token, + json!({ "role": "admin" }), + ) + .await; + assert_eq!(status, 403, "admin goes to no puppet"); + let (status, _) = send( + &app, + Method::DELETE, + &format!("/admin/users/{}/roles/admin", admin.user.id), + &token, + json!({}), + ) + .await; + assert_eq!(status, 403, "nor is it taken from a greater administrator"); + let (status, _) = send( + &app, + Method::PUT, + "/admin/roles/admin/permissions", + &token, + json!({ "permissions": ["roles:manage"] }), + ) + .await; + assert_eq!(status, 403, "nor emptied of what the manager lacks"); + let (status, _) = send( + &app, + Method::DELETE, + "/admin/roles/admin", + &token, + json!({}), + ) + .await; + assert_eq!(status, 403, "nor deleted"); + + // What the manager holds stays theirs to delegate. + let (status, response) = send( + &app, + Method::POST, + &format!("/admin/users/{}/roles", puppet.id), + &token, + json!({ "role": "support" }), + ) + .await; + assert_eq!(status, 204, "{response}"); + let (status, response) = send( + &app, + Method::DELETE, + &format!("/admin/users/{}/roles/support", puppet.id), + &token, + json!({}), + ) + .await; + assert_eq!(status, 204, "{response}"); +} From 1897b7aca55cb97caec3d59035b1d3dc9b88cb27 Mon Sep 17 00:00:00 2001 From: SIIR3X <125802911+SIIR3X@users.noreply.github.com> Date: Fri, 25 Sep 2026 09:38:21 +0200 Subject: [PATCH 40/55] fix(admin): revoke every session with the access factors, reset atomically and keep the primary client on the command line --- docs/dev/api/openapi.yaml | 5 ++- docs/dev/security-model.md | 1 + src/cli.rs | 35 ++++++++++++++++-- src/handlers/admin/clients.rs | 4 ++- src/repositories/session.rs | 6 ++-- src/services/admin/clients.rs | 7 ++++ src/services/admin/users.rs | 43 +++++++++++++++-------- src/services/email_change.rs | 2 ++ src/services/key_rotation.rs | 1 + src/services/user.rs | 20 +++++------ tests/integration/api/admin/clients.rs | 28 ++++++++++----- tests/integration/api/admin/privileges.rs | 40 +++++++++++++++++++++ 12 files changed, 152 insertions(+), 40 deletions(-) diff --git a/docs/dev/api/openapi.yaml b/docs/dev/api/openapi.yaml index 27306f8..ae81dfb 100644 --- a/docs/dev/api/openapi.yaml +++ b/docs/dev/api/openapi.yaml @@ -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: @@ -7492,6 +7492,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: diff --git a/docs/dev/security-model.md b/docs/dev/security-model.md index 15babef..a56bc0e 100644 --- a/docs/dev/security-model.md +++ b/docs/dev/security-model.md @@ -483,3 +483,4 @@ when a cited test no longer exists. | 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` | diff --git a/src/cli.rs b/src/cli.rs index 577e0b0..c39d16c 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -208,7 +208,11 @@ pub async fn grant_role(pool: &PgPool, grant: &RoleGrant) -> Result<(), String> 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 @@ -217,6 +221,23 @@ pub async fn grant_role(pool: &PgPool, grant: &RoleGrant) -> Result<(), 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( @@ -261,7 +282,9 @@ pub async fn register_client( metadata: { let mut metadata = crate::domain::registered_client::audit_changes(previous.as_ref(), &saved); - metadata["by"] = json!("command_line"); + for (key, value) in command_line_origin().as_object().unwrap() { + metadata[key] = value.clone(); + } metadata }, }, @@ -276,6 +299,14 @@ pub async fn register_client( 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/handlers/admin/clients.rs b/src/handlers/admin/clients.rs index 9332986..a576615 100644 --- a/src/handlers/admin/clients.rs +++ b/src/handlers/admin/clients.rs @@ -46,6 +46,8 @@ 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, #[serde(default)] @@ -111,7 +113,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" = [])), diff --git a/src/repositories/session.rs b/src/repositories/session.rs index 48b9585..58607ca 100644 --- a/src/repositories/session.rs +++ b/src/repositories/session.rs @@ -316,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 } diff --git a/src/services/admin/clients.rs b/src/services/admin/clients.rs index 75b679b..dfb2b94 100644 --- a/src/services/admin/clients.rs +++ b/src/services/admin/clients.rs @@ -61,6 +61,13 @@ 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")); diff --git a/src/services/admin/users.rs b/src/services/admin/users.rs index a9828c5..c6908ef 100644 --- a/src/services/admin/users.rs +++ b/src/services/admin/users.rs @@ -214,7 +214,10 @@ pub async fn force_password_reset( require_reauth(state, actor, "admin_force_password_reset").await?; let user = find(state, 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?; + 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( @@ -227,6 +230,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", @@ -237,12 +245,6 @@ pub async fn force_password_reset( events::wake(); forget_sessions(state, &active).await; - // Whoever knew the password may have added their own ways in: on request, - // they go with it. - if revoke_access_factors { - let revoked = drop_access_factors_in(state, actor, user_id).await?; - forget_sessions(state, &revoked).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 @@ -261,29 +263,40 @@ pub async fn remove_access_factors( refuse_own_account(actor, user_id)?; require_reauth(state, actor, "admin_remove_access_factors").await?; find(state, user_id).await?; - let revoked = drop_access_factors_in(state, actor, 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( - state: &AppState, + tx: &mut sqlx::PgConnection, actor: &Actor, user_id: Uuid, -) -> Result, AppError> { - let mut tx = state.db.begin().await?; - user_repo::lock_row(&mut *tx, user_id).await?; - let revoked = session_repo::revoke_personal_access_sessions(&mut *tx, user_id).await?; +) -> 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?; + 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?; - tx.commit().await?; - Ok(revoked) + Ok(()) } /// Delete the account like its owner would, after a recent re-authentication of diff --git a/src/services/email_change.rs b/src/services/email_change.rs index 4cafea6..ecddbbd 100644 --- a/src/services/email_change.rs +++ b/src/services/email_change.rs @@ -277,6 +277,8 @@ 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(()); } diff --git a/src/services/key_rotation.rs b/src/services/key_rotation.rs index 54957c9..d0d04b7 100644 --- a/src/services/key_rotation.rs +++ b/src/services/key_rotation.rs @@ -129,6 +129,7 @@ pub async fn rotate_totp_encryption_key(state: &AppState) -> Result, 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 @@ -436,6 +428,14 @@ pub(crate) async fn erase_account( 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, diff --git a/tests/integration/api/admin/clients.rs b/tests/integration/api/admin/clients.rs index c4eea89..0cb75a2 100644 --- a/tests/integration/api/admin/clients.rs +++ b/tests/integration/api/admin/clients.rs @@ -136,24 +136,36 @@ async fn invalid_client_settings_are_refused() { assert_eq!(status, 422, "{settings}: {response}"); } - let primary = json!({ "display_name": "App", "is_primary": true }); - let (status, _) = send( + // The primary client is the command line's: the administration neither + // designates one nor changes it (SEC-75). + let (status, response) = send( &app, Method::PUT, "/admin/clients/first", &admin.token, - primary.clone(), + json!({ "display_name": "App", "is_primary": true }), ) .await; - assert_eq!(status, 201); + assert_eq!( + (status, response["code"].as_str()), + (409, Some("primary_client_managed_by_command_line")) + ); + sqlx::query( + "INSERT INTO registered_clients (client_id, display_name, is_primary) VALUES ('first', 'App', TRUE)", + ) + .execute(&app.db) + .await + .unwrap(); let (status, response) = send( &app, Method::PUT, - "/admin/clients/second", + "/admin/clients/first", &admin.token, - primary, + json!({ "display_name": "App", "redirect_uris": ["https://evil.example/cb"] }), ) .await; - assert_eq!(status, 409); - assert_eq!(response["code"], "primary_client_exists"); + assert_eq!( + (status, response["code"].as_str()), + (409, Some("primary_client_managed_by_command_line")) + ); } diff --git a/tests/integration/api/admin/privileges.rs b/tests/integration/api/admin/privileges.rs index 79e361b..46a0352 100644 --- a/tests/integration/api/admin/privileges.rs +++ b/tests/integration/api/admin/privileges.rs @@ -242,6 +242,16 @@ async fn an_administrator_removes_the_ways_in_of_a_compromised_account() { .await .unwrap(); assert_eq!(methods, 0); + // Every session goes, the browser one included: whoever planted a factor + // may be signed in through it. + let me = app + .client + .get(app.url("/users/me")) + .bearer_auth(&target.access_token) + .send() + .await + .unwrap(); + assert_eq!(me.status(), 401); app.mail .wait_for(&target.email, "An administrator changed your account") .await; @@ -423,3 +433,33 @@ async fn nobody_grants_or_withdraws_a_role_holding_more_than_they_have() { .await; assert_eq!(status, 204, "{response}"); } + +/// A forced reset asking for the access factors to go is one change: refused +/// for an administrator's last second factor, it revokes nothing (SEC-75). +#[tokio::test] +async fn a_refused_forced_reset_revokes_nothing() { + let app = TestApp::spawn().await; + let admin = admin(&app, 1).await; + let other = admin_with_index(&app, 3).await; + + let (status, response) = send( + &app, + Method::POST, + &format!("/admin/users/{}/password-reset", other.user.id), + &admin.token, + json!({ "revoke_access_factors": true }), + ) + .await; + assert_eq!( + (status, response["code"].as_str()), + (409, Some("administrator_needs_second_factor")) + ); + let me = app + .client + .get(app.url("/users/me")) + .bearer_auth(&other.token) + .send() + .await + .unwrap(); + assert_eq!(me.status(), 200, "the sessions stay"); +} From 3a69ebe9b4625c02e2b223833ed13a4ac06b40cf Mon Sep 17 00:00:00 2001 From: SIIR3X <125802911+SIIR3X@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:50:27 +0200 Subject: [PATCH 41/55] fix(auth): keep budgets from being turned against the owner and fail closed on token budgets --- crates/testkit/src/app.rs | 7 +++ docs/dev/security-model.md | 1 + src/repositories/login_attempt.rs | 13 ++++- src/services/admin/users.rs | 9 +++- src/services/auth/guards.rs | 25 +++++---- src/services/auth/magic_link.rs | 2 +- src/services/auth/mod.rs | 1 - src/services/auth/password_reset.rs | 5 +- src/services/device.rs | 19 ++++--- src/services/email_change.rs | 42 ++++++++++++--- src/services/user.rs | 7 ++- src/utils/redis_counter.rs | 51 ++++++++++++++++++- tests/integration/api/account/email.rs | 35 +++++++++++++ .../repositories/login_attempts.rs | 45 ++++++++++++++++ tests/integration/repositories/mod.rs | 1 + .../security/regressions/session_hardening.rs | 5 +- tests/simulation/redis_outage.rs | 13 ++--- 17 files changed, 240 insertions(+), 41 deletions(-) create mode 100644 tests/integration/repositories/login_attempts.rs diff --git a/crates/testkit/src/app.rs b/crates/testkit/src/app.rs index 6d24f7f..a7a71d4 100644 --- a/crates/testkit/src/app.rs +++ b/crates/testkit/src/app.rs @@ -408,6 +408,13 @@ impl TestApp { 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) { diff --git a/docs/dev/security-model.md b/docs/dev/security-model.md index a56bc0e..d70fddc 100644 --- a/docs/dev/security-model.md +++ b/docs/dev/security-model.md @@ -484,3 +484,4 @@ when a cited test no longer exists. | 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` | diff --git a/src/repositories/login_attempt.rs b/src/repositories/login_attempt.rs index 9e56daa..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"; diff --git a/src/services/admin/users.rs b/src/services/admin/users.rs index c6908ef..beed412 100644 --- a/src/services/admin/users.rs +++ b/src/services/admin/users.rs @@ -156,7 +156,14 @@ pub async fn unlock(state: &AppState, actor: &Actor, user_id: Uuid) -> Result<() ) .await?; tx.commit().await?; - redis_counter::reset(&state.redis, &[&user_svc::reauth_fail_key(user_id)]).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(()) } diff --git a/src/services/auth/guards.rs b/src/services/auth/guards.rs index 4a0258d..3e263cb 100644 --- a/src/services/auth/guards.rs +++ b/src/services/auth/guards.rs @@ -103,11 +103,16 @@ pub(crate) fn account_budget_exceeded(counts: &[i64], account_limit: i64) -> boo /// 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())); @@ -115,7 +120,7 @@ pub(super) async fn mailbox_budget_exhausted( state, &key, MAX_MAILBOX_LINKS_BY_ACCOUNT_AND_IP, - MAILBOX_LINK_ACCOUNT_WINDOW_SECS, + window_secs, ) .await { @@ -126,7 +131,7 @@ pub(super) async fn mailbox_budget_exhausted( state, &format!("{prefix}:{user_id}"), MAX_MAILBOX_LINKS_BY_ACCOUNT, - MAILBOX_LINK_ACCOUNT_WINDOW_SECS, + window_secs, ) .await } @@ -161,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, @@ -186,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. diff --git a/src/services/auth/magic_link.rs b/src/services/auth/magic_link.rs index 5ffb792..ed6cc43 100644 --- a/src/services/auth/magic_link.rs +++ b/src/services/auth/magic_link.rs @@ -52,7 +52,7 @@ async fn send_link( user_agent: Option<&str>, request_id: Option, ) -> Result<(), AppError> { - if mailbox_budget_exhausted(state, "ml_account", user.id, ip).await { + if mailbox_budget_exhausted(state, "ml_account", user.id, ip, MAGIC_LINK_EXPIRY_SECS).await { return Ok(()); } diff --git a/src/services/auth/mod.rs b/src/services/auth/mod.rs index 8375b37..0482a27 100644 --- a/src/services/auth/mod.rs +++ b/src/services/auth/mod.rs @@ -213,7 +213,6 @@ const FORGOT_PASSWORD_IP_WINDOW_SECS: u64 = 900; /// 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; -const MAILBOX_LINK_ACCOUNT_WINDOW_SECS: u64 = 3600; /// Verification e-mail requests per client address (IPv6 /64) per window. const MAX_VERIFICATION_RESENDS_BY_IP: i64 = 5; diff --git a/src/services/auth/password_reset.rs b/src/services/auth/password_reset.rs index c3e734b..602ee15 100644 --- a/src/services/auth/password_reset.rs +++ b/src/services/auth/password_reset.rs @@ -49,7 +49,7 @@ pub(super) async fn issue_password_reset( return Ok(()); }; - if mailbox_budget_exhausted(state, "fp_account", user.id, ip).await { + if mailbox_budget_exhausted(state, "fp_account", user.id, ip, RESET_TOKEN_EXPIRY_SECS).await { return Ok(()); } @@ -248,6 +248,9 @@ pub async fn reset_password( &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; diff --git a/src/services/device.rs b/src/services/device.rs index e45d15a..2be3962 100644 --- a/src/services/device.rs +++ b/src/services/device.rs @@ -390,7 +390,11 @@ fn scan_keys(ip: Option, user_id: Uuid) -> Vec { /// 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, user_id: Uuid) { +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() @@ -400,9 +404,10 @@ async fn note_unknown_code(state: &AppState, ip: Option, user_id: Uui window_secs: SCAN_WINDOW_SECS, }) .collect(); - if let Err(e) = redis_counter::consume(&state.redis, &budgets).await { - tracing::warn!(error = %e, "could not record an unknown device code lookup"); - } + // 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)`. @@ -443,7 +448,7 @@ pub async fn describe( guard_code_scan(state, ip, user_id).await?; let Some((_, _, entry)) = load_entry(state, user_code).await? else { - note_unknown_code(state, ip, user_id).await; + note_unknown_code(state, ip, user_id).await?; return Err(AppError::NotFound); }; if !entry.status.is_undecided() { @@ -496,7 +501,7 @@ pub async fn describe( pub async fn verify(state: &AppState, approval: &Approval<'_>) -> Result<(), AppError> { guard_code_scan(state, approval.ip, approval.user_id).await?; if load_entry(state, approval.user_code).await?.is_none() { - note_unknown_code(state, approval.ip, approval.user_id).await; + note_unknown_code(state, approval.ip, approval.user_id).await?; return Err(AppError::NotFound); } crate::services::reauth::require_recent_reauth_or_password( @@ -543,7 +548,7 @@ async fn update_status( 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, user_id).await; + note_unknown_code(state, ip, user_id).await?; return Err(AppError::NotFound); }; if !entry.status.is_undecided() { diff --git a/src/services/email_change.rs b/src/services/email_change.rs index ecddbbd..8614012 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); { @@ -244,7 +262,9 @@ pub async fn submit_new( { Ok(attempt) if attempt.exceeded => return Err(AppError::RateLimitExceeded), Ok(_) => {} - Err(error) => tracing::warn!(%error, "email change budget unavailable, failing open"), + // 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) @@ -516,13 +536,21 @@ async fn verify_otp( ) -> 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 { @@ -543,7 +571,7 @@ async fn verify_otp( return Err(AppError::TwoFactorFailed); } - redis_counter::reset(&state.redis, &[fail_key]).await; + redis_counter::reset(&state.redis, &[fail_key, &account_key]).await; Ok(()) } diff --git a/src/services/user.rs b/src/services/user.rs index 43d7c2c..1b3d3f8 100644 --- a/src/services/user.rs +++ b/src/services/user.rs @@ -73,8 +73,11 @@ pub async fn verify_password( // 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 { diff --git a/src/utils/redis_counter.rs b/src/utils/redis_counter.rs index ede9cad..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