feat(opaque): handle the force password change

This commit is contained in:
Edouard Vanbelle
2026-08-02 03:01:26 +02:00
parent e8de07768e
commit 903d769e03
5 changed files with 148 additions and 1 deletions
+15
View File
@@ -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;
}
/**
+15 -1
View File
@@ -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
+17
View File
@@ -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)]
@@ -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)
@@ -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<bool> {
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(