feat(opaque): handle the force password change
This commit is contained in:
@@ -246,6 +246,21 @@ export interface AuthResponse {
|
|||||||
refresh_token: string;
|
refresh_token: string;
|
||||||
token_type: string;
|
token_type: string;
|
||||||
expires_in: number;
|
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);
|
session.setUser(data.user);
|
||||||
postRegisterNotice = null;
|
postRegisterNotice = null;
|
||||||
|
// 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 });
|
await goto(resolve(redirectTarget), { replaceState: true });
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError && err.errorType === 'EmailNotVerified') {
|
if (err instanceof ApiError && err.errorType === 'EmailNotVerified') {
|
||||||
// Server auto-sent a verification magic-link on the
|
// Server auto-sent a verification magic-link on the
|
||||||
|
|||||||
@@ -267,6 +267,23 @@ pub struct AuthResponseDto {
|
|||||||
pub refresh_token: String,
|
pub refresh_token: String,
|
||||||
pub token_type: String,
|
pub token_type: String,
|
||||||
pub expires_in: i64,
|
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)]
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||||
|
|||||||
@@ -965,12 +965,37 @@ impl AuthApplicationService {
|
|||||||
self.session_storage.create_session(session).await?;
|
self.session_storage.create_session(session).await?;
|
||||||
|
|
||||||
// Authentication response
|
// Authentication response
|
||||||
|
let force_password_change = self.read_force_password_change(user.id()).await;
|
||||||
Ok(AuthResponseDto {
|
Ok(AuthResponseDto {
|
||||||
user: UserDto::from(user),
|
user: UserDto::from(user),
|
||||||
access_token,
|
access_token,
|
||||||
refresh_token,
|
refresh_token,
|
||||||
token_type: "Bearer".to_string(),
|
token_type: "Bearer".to_string(),
|
||||||
expires_in: self.token_service.refresh_token_expiry_secs(),
|
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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1204,12 +1229,14 @@ impl AuthApplicationService {
|
|||||||
cross_browser_confirmed = cross_browser_confirmed,
|
cross_browser_confirmed = cross_browser_confirmed,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let force_password_change = self.read_force_password_change(user.id()).await;
|
||||||
let auth = AuthResponseDto {
|
let auth = AuthResponseDto {
|
||||||
user: UserDto::from(user),
|
user: UserDto::from(user),
|
||||||
access_token,
|
access_token,
|
||||||
refresh_token,
|
refresh_token,
|
||||||
token_type: "Bearer".to_string(),
|
token_type: "Bearer".to_string(),
|
||||||
expires_in: self.token_service.refresh_token_expiry_secs(),
|
expires_in: self.token_service.refresh_token_expiry_secs(),
|
||||||
|
force_password_change,
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(MagicLinkRedeemResult::Allowed(Box::new(
|
Ok(MagicLinkRedeemResult::Allowed(Box::new(
|
||||||
@@ -1345,12 +1372,19 @@ impl AuthApplicationService {
|
|||||||
.rotate_session(session.id(), new_session)
|
.rotate_session(session.id(), new_session)
|
||||||
.await?;
|
.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 {
|
Ok(AuthResponseDto {
|
||||||
user: UserDto::from(user),
|
user: UserDto::from(user),
|
||||||
access_token,
|
access_token,
|
||||||
refresh_token: new_refresh_token,
|
refresh_token: new_refresh_token,
|
||||||
token_type: "Bearer".to_string(),
|
token_type: "Bearer".to_string(),
|
||||||
expires_in: self.token_service.refresh_token_expiry_secs(),
|
expires_in: self.token_service.refresh_token_expiry_secs(),
|
||||||
|
force_password_change,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1839,6 +1873,22 @@ impl AuthApplicationService {
|
|||||||
// Save updated user
|
// Save updated user
|
||||||
self.user_storage.update_user(user.clone()).await?;
|
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
|
// Optional: revoke all sessions to force re-login with new password
|
||||||
self.session_storage
|
self.session_storage
|
||||||
.revoke_all_user_sessions(user_id)
|
.revoke_all_user_sessions(user_id)
|
||||||
@@ -3319,12 +3369,14 @@ impl AuthApplicationService {
|
|||||||
}
|
}
|
||||||
self.session_storage.create_session(session).await?;
|
self.session_storage.create_session(session).await?;
|
||||||
|
|
||||||
|
let force_password_change = self.read_force_password_change(user.id()).await;
|
||||||
let auth_response = AuthResponseDto {
|
let auth_response = AuthResponseDto {
|
||||||
user: UserDto::from(user),
|
user: UserDto::from(user),
|
||||||
access_token,
|
access_token,
|
||||||
refresh_token,
|
refresh_token,
|
||||||
token_type: "Bearer".to_string(),
|
token_type: "Bearer".to_string(),
|
||||||
expires_in: self.token_service.refresh_token_expiry_secs(),
|
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)
|
// 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
|
/// Updates a user's profile image (URL or data URI). Not part of the
|
||||||
/// `UserRepository` trait — called directly from `AuthApplicationService`.
|
/// `UserRepository` trait — called directly from `AuthApplicationService`.
|
||||||
pub async fn update_image(
|
pub async fn update_image(
|
||||||
|
|||||||
Reference in New Issue
Block a user