feat(opaque): handle the force password change
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user