From 903d769e034467dfd5b938e1d5de81636666b30f Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sun, 2 Aug 2026 03:01:26 +0200 Subject: [PATCH] feat(opaque): handle the force password change --- frontend/src/lib/api/types.ts | 15 ++++++ frontend/src/routes/login/+page.svelte | 16 +++++- src/application/dtos/user_dto.rs | 17 ++++++ .../services/auth_application_service.rs | 52 +++++++++++++++++++ .../repositories/pg/user_pg_repository.rs | 49 +++++++++++++++++ 5 files changed, 148 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index f6db9397..b3907220 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -246,6 +246,21 @@ export interface AuthResponse { refresh_token: string; token_type: string; expires_in: number; + /** + * Mirrors `auth.users.force_password_change_at_next_login` — set + * TRUE by the admin password-reset flow (see backend + * `OpaquePgRepository::clear_registration`) so admin-picked + * passwords stay temporary until the user changes them. When true, + * the SPA's post-login handler must route to `/settings/security` + * (or the equivalent change-password surface) instead of the + * user's home. Cleared server-side by a successful + * `POST /api/auth/change-password`. + * + * Optional on the wire because the backend `#[serde(default)]`s + * to `false` — older clients / non-login endpoints hitting this + * type won't nil-deref. + */ + force_password_change?: boolean; } /** diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte index d938113f..80bc738b 100644 --- a/frontend/src/routes/login/+page.svelte +++ b/frontend/src/routes/login/+page.svelte @@ -163,7 +163,21 @@ } session.setUser(data.user); postRegisterNotice = null; - await goto(resolve(redirectTarget), { replaceState: true }); + // When the backend flags `force_password_change` the user's + // current credential is an admin-set temporary password; + // route them to the profile page (`?forcePasswordChange=1` + // lights up an in-page banner) instead of the requested + // destination. The redirect target is preserved as `next` + // so the profile flow can bounce back after they pick a + // real password. + if (data.force_password_change) { + const next = encodeURIComponent(redirectTarget); + await goto(resolve(`/profile?forcePasswordChange=1&next=${next}`), { + replaceState: true + }); + } else { + await goto(resolve(redirectTarget), { replaceState: true }); + } } catch (err) { if (err instanceof ApiError && err.errorType === 'EmailNotVerified') { // Server auto-sent a verification magic-link on the diff --git a/src/application/dtos/user_dto.rs b/src/application/dtos/user_dto.rs index 059164af..6db3a368 100644 --- a/src/application/dtos/user_dto.rs +++ b/src/application/dtos/user_dto.rs @@ -267,6 +267,23 @@ pub struct AuthResponseDto { pub refresh_token: String, pub token_type: String, pub expires_in: i64, + /// When `true`, the caller must be routed to the change-password + /// flow before any other action. Set on the login response for + /// users whose `auth.users.force_password_change_at_next_login` + /// column is TRUE — the admin password-reset flow flips that + /// column atomically alongside `clear_registration` so admin-set + /// passwords remain temporary until the user picks their own. + /// Cleared by a successful `POST /api/auth/change-password`. + /// + /// SPA policy: if this is `true`, redirect to `/settings/password` + /// (or the equivalent) immediately after the login handler settles. + /// Backend does not gate any endpoints on this flag — it's a + /// soft-enforcement signal; a client that ignores it keeps its + /// session, but the responsibility falls on the SPA to route + /// correctly. Backend enforcement (session scope claim) is a + /// possible follow-up if the soft path proves insufficient. + #[serde(default)] + pub force_password_change: bool, } #[derive(Debug, Serialize, Deserialize, ToSchema)] diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index a3c9ed41..85c5ec6e 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -965,15 +965,40 @@ impl AuthApplicationService { self.session_storage.create_session(session).await?; // Authentication response + let force_password_change = self.read_force_password_change(user.id()).await; Ok(AuthResponseDto { user: UserDto::from(user), access_token, refresh_token, token_type: "Bearer".to_string(), expires_in: self.token_service.refresh_token_expiry_secs(), + force_password_change, }) } + /// Read `force_password_change_at_next_login` for the given user, + /// with fail-open semantics on repo error (returns `false` and + /// logs a warn). Every callsite that builds an `AuthResponseDto` + /// uses this — mint_session (legacy + OPAQUE), magic-link + /// redemption, refresh, OIDC callback — so the flag surfaces + /// consistently across all login shapes, and a DB blip doesn't + /// spam every response with a spurious change-password prompt. + async fn read_force_password_change(&self, user_id: Uuid) -> bool { + self.user_storage + .is_force_password_change(user_id) + .await + .unwrap_or_else(|e| { + tracing::warn!( + target: "audit", + event = "auth.force_password_change_read_failed", + user_id = %user_id, + error = %e, + "force_password_change lookup failed; treating as false" + ); + false + }) + } + /// Redeem a magic-link token and emit a fresh session in one shot. /// /// The flow: @@ -1204,12 +1229,14 @@ impl AuthApplicationService { cross_browser_confirmed = cross_browser_confirmed, ); + let force_password_change = self.read_force_password_change(user.id()).await; let auth = AuthResponseDto { user: UserDto::from(user), access_token, refresh_token, token_type: "Bearer".to_string(), expires_in: self.token_service.refresh_token_expiry_secs(), + force_password_change, }; Ok(MagicLinkRedeemResult::Allowed(Box::new( @@ -1345,12 +1372,19 @@ impl AuthApplicationService { .rotate_session(session.id(), new_session) .await?; + // Refresh re-reads the flag so an admin flip mid-session + // surfaces on the next refresh even if it wasn't set at + // initial login. The SPA's post-refresh flow (silent, on + // its own timer) can then route the user to change-password + // without waiting for an explicit re-login. + let force_password_change = self.read_force_password_change(user.id()).await; Ok(AuthResponseDto { user: UserDto::from(user), access_token, refresh_token: new_refresh_token, token_type: "Bearer".to_string(), expires_in: self.token_service.refresh_token_expiry_secs(), + force_password_change, }) } @@ -1839,6 +1873,22 @@ impl AuthApplicationService { // Save updated user self.user_storage.update_user(user.clone()).await?; + // Clear the admin-set "temporary password" marker — the user + // has just picked their own password, so the next-login prompt + // has served its purpose. Failure here is non-fatal (login + // will just keep prompting until an admin resets or a later + // change_password succeeds), but log so ops sees any + // consistent drift. + if let Err(e) = self.user_storage.clear_force_password_change(user_id).await { + tracing::warn!( + target: "audit", + event = "auth.force_password_change_clear_failed", + user_id = %user_id, + error = %e, + "clear_force_password_change failed after change_password success" + ); + } + // Optional: revoke all sessions to force re-login with new password self.session_storage .revoke_all_user_sessions(user_id) @@ -3319,12 +3369,14 @@ impl AuthApplicationService { } self.session_storage.create_session(session).await?; + let force_password_change = self.read_force_password_change(user.id()).await; let auth_response = AuthResponseDto { user: UserDto::from(user), access_token, refresh_token, token_type: "Bearer".to_string(), expires_in: self.token_service.refresh_token_expiry_secs(), + force_password_change, }; // 7. Store auth response behind a one-time exchange code (Fix #4: no tokens in URL) diff --git a/src/infrastructure/repositories/pg/user_pg_repository.rs b/src/infrastructure/repositories/pg/user_pg_repository.rs index d1979c27..fdda28d5 100644 --- a/src/infrastructure/repositories/pg/user_pg_repository.rs +++ b/src/infrastructure/repositories/pg/user_pg_repository.rs @@ -112,6 +112,55 @@ impl UserPgRepository { )) } + /// Read `force_password_change_at_next_login`. Written TRUE by the + /// admin password-reset flow (via `OpaquePgRepository::clear_registration`, + /// which sets it alongside the envelope invalidation in one UPDATE) + /// and by admin-side `set_user_password`. Cleared on a successful + /// user-initiated `change_password`. + /// + /// Reads via a single-column SELECT to avoid dragging the full row + /// (with its up-to-512 KiB `image`) on every login-response mint. + /// Returns `false` for missing users so the login path — which has + /// already resolved the user by id — treats a lost race the same + /// as "flag not set" rather than surfacing a 5xx. + pub async fn is_force_password_change(&self, id: Uuid) -> UserRepositoryResult { + let row: Option<(bool,)> = sqlx::query_as( + r#" + SELECT force_password_change_at_next_login + FROM auth.users + WHERE id = $1 + "#, + ) + .bind(id) + .fetch_optional(&*self.pool) + .await + .map_err(Self::map_sqlx_error)?; + Ok(row.map(|(v,)| v).unwrap_or(false)) + } + + /// Clear `force_password_change_at_next_login`. Called by the + /// change-password flow on success so a legitimate self-service + /// password rotation lifts the admin-set "temporary" marker in + /// one round-trip. + /// + /// Deliberately does NOT gate on the current value — flipping FALSE + /// to FALSE is a no-op at the row level. That keeps the caller from + /// needing a read-modify-write. + pub async fn clear_force_password_change(&self, id: Uuid) -> UserRepositoryResult<()> { + sqlx::query( + r#" + UPDATE auth.users + SET force_password_change_at_next_login = FALSE + WHERE id = $1 + "#, + ) + .bind(id) + .execute(&*self.pool) + .await + .map_err(Self::map_sqlx_error)?; + Ok(()) + } + /// Updates a user's profile image (URL or data URI). Not part of the /// `UserRepository` trait — called directly from `AuthApplicationService`. pub async fn update_image(