feat(opaque): improve password change
- rebuild the opaque envoloppe - revoke all other useer's sessions - send a security email to user
This commit is contained in:
@@ -94,6 +94,20 @@ pub struct UserDto {
|
||||
/// field is what the SPA reads to render the mandatory-mode UI.
|
||||
#[serde(default)]
|
||||
pub force_password_change: bool,
|
||||
/// TRUE when the account has a local Argon2id `password_hash` on
|
||||
/// file. Distinct from `auth_provider`: an SSO-linked account
|
||||
/// (auth_provider != "local") can ALSO carry a local password if
|
||||
/// it was set at signup or later — a hybrid posture. The SPA
|
||||
/// gates the profile page's change-password card on this flag,
|
||||
/// so hybrid users can rotate their local password even though
|
||||
/// they normally sign in via SSO.
|
||||
///
|
||||
/// Populated only by the `/api/auth/me` handler. `From<User>` in
|
||||
/// this file leaves it `false` — other UserDto emitters (admin
|
||||
/// listings, share-recipient responses, group members) do not
|
||||
/// need to surface per-user credential state.
|
||||
#[serde(default)]
|
||||
pub has_password: bool,
|
||||
}
|
||||
|
||||
/// Compact row returned by the paginated admin user table.
|
||||
@@ -176,6 +190,11 @@ impl From<User> for UserDto {
|
||||
// entity before the move.
|
||||
let role = format!("{}", user.role());
|
||||
let can_edit_image = !user.is_oidc_user();
|
||||
// has_password is derivable from the entity — read before the
|
||||
// move. Cheap (bool from Option::is_some), no extra DB round-
|
||||
// trip, so From<User> can populate it uniformly rather than
|
||||
// leaving it false and requiring per-call-site backfill.
|
||||
let has_password = user.has_password();
|
||||
let p = user.into_parts();
|
||||
Self {
|
||||
id: p.id.to_string(),
|
||||
@@ -206,6 +225,7 @@ impl From<User> for UserDto {
|
||||
// leave it false — the flag is per-session-account state,
|
||||
// not a general user attribute.
|
||||
force_password_change: false,
|
||||
has_password,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -363,6 +363,16 @@ pub trait SessionStoragePort: Send + Sync + 'static {
|
||||
/// Revokes all sessions of a user
|
||||
async fn revoke_all_user_sessions(&self, user_id: Uuid) -> Result<u64, DomainError>;
|
||||
|
||||
/// Revokes every session of a user EXCEPT `keep_session_id`.
|
||||
/// Classic "password change" pattern: kills OTHER devices' sessions
|
||||
/// while keeping the caller's current session alive so the SPA can
|
||||
/// complete follow-up work without a session-death race.
|
||||
async fn revoke_other_user_sessions(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
keep_session_id: Uuid,
|
||||
) -> Result<u64, DomainError>;
|
||||
|
||||
/// Revokes all sessions in a token family (used when replay of a revoked token is detected)
|
||||
async fn revoke_session_family(&self, family_id: Uuid) -> Result<u64, DomainError>;
|
||||
|
||||
|
||||
@@ -122,6 +122,26 @@ pub trait OpaqueRepositoryPort: Send + Sync + 'static {
|
||||
/// (that's the point of the admin call).
|
||||
async fn clear_registration(&self, user_id: Uuid) -> Result<()>;
|
||||
|
||||
/// Invalidate the OPAQUE envelope for `user_id` WITHOUT touching
|
||||
/// `force_password_change_at_next_login`. Used by the self-service
|
||||
/// `change_password` path: the user just proved and rotated their
|
||||
/// legacy password, so the OLD envelope (bound to the OLD
|
||||
/// passphrase) MUST go, but no forced-change prompt is needed on
|
||||
/// the next login (the user did just change it themselves).
|
||||
///
|
||||
/// Distinct from [`clear_registration`], which co-flips
|
||||
/// `force_password_change` because that path represents an admin
|
||||
/// override — the user did NOT choose the new value, so they must
|
||||
/// pick their own on next login. Change-password is the inverse:
|
||||
/// user chose the value, no re-choice needed.
|
||||
///
|
||||
/// Also used by `oxicloud-cli opaque reset --user X` for KSF
|
||||
/// rotation recovery — same "envelope stale, don't touch other
|
||||
/// state" semantics.
|
||||
///
|
||||
/// Idempotent: nulling already-null columns is a no-op.
|
||||
async fn clear_envelope_only(&self, user_id: Uuid) -> Result<()>;
|
||||
|
||||
/// Stamp `opaque_migrated_at` on `user_id` if it isn't set yet.
|
||||
/// Called by the login-KE3 handler after a successful OPAQUE
|
||||
/// handshake — the presence of this timestamp is the Phase 3+
|
||||
|
||||
@@ -1816,22 +1816,63 @@ impl AuthApplicationService {
|
||||
Ok(UserDto::from(updated))
|
||||
}
|
||||
|
||||
/// `keep_session_id` — when `Some`, revoke every OTHER session for
|
||||
/// this user but leave the identified one alive. Classic
|
||||
/// "password change" pattern: log the user out from other devices
|
||||
/// but keep the current one authenticated so the SPA can complete
|
||||
/// follow-up work (OPAQUE envelope re-registration) without
|
||||
/// racing a session-death 401. When `None`, revokes all sessions
|
||||
/// (preserves the original behaviour for callers without session
|
||||
/// context).
|
||||
///
|
||||
/// Handler-layer callers should extract the current session_id
|
||||
/// from the request's refresh-token cookie and pass it in; other
|
||||
/// callers (CLI, tests, admin flows that don't have a specific
|
||||
/// current session) leave it `None`.
|
||||
pub async fn change_password(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
dto: ChangePasswordDto,
|
||||
keep_session_id: Option<Uuid>,
|
||||
) -> Result<(), DomainError> {
|
||||
// Get user
|
||||
let mut user = self.user_storage.get_user_by_id(user_id).await?;
|
||||
|
||||
// Block password changes for OIDC-provisioned users
|
||||
if user.is_oidc_user() {
|
||||
// Two structural refusals. Order chosen so the more-specific
|
||||
// "your credential is IdP-managed" wins for pure-OIDC users
|
||||
// (which is the case the message text addresses); the
|
||||
// deployment-wide "password auth is off" wins for everyone
|
||||
// else on an SSO-only deployment.
|
||||
//
|
||||
// 1. Pure-OIDC user (SSO-linked AND no local password).
|
||||
// Hybrid accounts with an OIDC linkage BUT also a
|
||||
// `password_hash` on file are a legitimate posture on
|
||||
// deployments that offer SSO alongside password auth —
|
||||
// they can and must be able to rotate the local
|
||||
// credential from this endpoint.
|
||||
//
|
||||
// 2. Deployment has password auth disabled globally
|
||||
// (`OXICLOUD_AUTH_METHODS` missing `password`, or the
|
||||
// legacy `OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN` alias).
|
||||
// Even a user who still has `password_hash` from before
|
||||
// the operator flipped this shouldn't be updating that
|
||||
// hash — they can't USE it to log in, and leaving a
|
||||
// write path exposed keeps a live credential the
|
||||
// operator likely wanted retired.
|
||||
if user.is_oidc_user() && !user.has_password() {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Auth",
|
||||
"Password changes are not available for SSO/OIDC accounts. Your password is managed by your identity provider.",
|
||||
));
|
||||
}
|
||||
if !self.is_password_login_allowed() {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Auth",
|
||||
"Password login is disabled on this deployment; password change is not available.",
|
||||
));
|
||||
}
|
||||
|
||||
// Verify current password using the injected hasher
|
||||
let Some(hash) = user.password_hash() else {
|
||||
@@ -1894,6 +1935,34 @@ impl AuthApplicationService {
|
||||
// Save updated user
|
||||
self.user_storage.update_user(user.clone()).await?;
|
||||
|
||||
// OPAQUE envelope handling: the OLD envelope was bound to the
|
||||
// OLD passphrase via the OPRF. Left in place, the next OPAQUE
|
||||
// login with the NEW password would derive a mismatched OPRF
|
||||
// output and fail the AKE with InvalidCredentials → user
|
||||
// locked out (Phase 3 SPA doesn't fall back OPAQUE→legacy).
|
||||
//
|
||||
// The RESPONSIBILITY for re-minting the envelope belongs to
|
||||
// the SPA — see `frontend/src/lib/api/endpoints/profile.ts`
|
||||
// → `changePassword` → `syncOpaqueEnvelope(newPw)`. That call
|
||||
// hits the session-authenticated `/register/*` endpoints
|
||||
// immediately after this handler returns 200. It works
|
||||
// because we keep the current session alive below
|
||||
// (`revoke_other_user_sessions` instead of the full-revocation
|
||||
// call this handler used to make).
|
||||
//
|
||||
// The server does NOT clear the envelope here. The SPA
|
||||
// re-registration is monotonic — the envelope transitions
|
||||
// straight from OLD-password bound to NEW-password bound
|
||||
// without a null intermediate. This matters for the migration
|
||||
// ledger: `opaque_migrated_at` stays intact, admin dashboards
|
||||
// don't see a spurious "unmigrated" blip.
|
||||
//
|
||||
// Recovery for the rare SPA-failure case: the operator runs
|
||||
// `oxicloud-cli opaque reset --user <id>` to null the
|
||||
// envelope; the user's next login goes through legacy path
|
||||
// (since `hasOpaque: false` after the CLI reset) and silent-
|
||||
// migration mints a fresh envelope under the new password.
|
||||
|
||||
// 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
|
||||
@@ -1919,10 +1988,29 @@ impl AuthApplicationService {
|
||||
// between change_password success and the new session mint.)
|
||||
self.user_flags_cache.invalidate(&user_id).await;
|
||||
|
||||
// Optional: revoke all sessions to force re-login with new password
|
||||
self.session_storage
|
||||
.revoke_all_user_sessions(user_id)
|
||||
.await?;
|
||||
// Session revocation posture: classic "password change" pattern
|
||||
// — kill every OTHER session for this user (any device / tab
|
||||
// that had cached the old credential), but keep the caller's
|
||||
// CURRENT session alive so the SPA can complete the OPAQUE
|
||||
// envelope re-registration on the same session cookie that
|
||||
// successfully hit this endpoint. Without the `keep_session_id`
|
||||
// preservation, `syncOpaqueEnvelope` in profile.ts would 401
|
||||
// (session gone), the envelope would stay bound to the OLD
|
||||
// password, and the user would be locked out on next OPAQUE
|
||||
// login. `None` = caller has no session context (CLI, admin
|
||||
// flows), fall back to full revocation.
|
||||
match keep_session_id {
|
||||
Some(keep) => {
|
||||
self.session_storage
|
||||
.revoke_other_user_sessions(user_id, keep)
|
||||
.await?;
|
||||
}
|
||||
None => {
|
||||
self.session_storage
|
||||
.revoke_all_user_sessions(user_id)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle: PasswordChanged logout — fired once per logical
|
||||
// revoke-all call. PR 4 may refine to per-session firing.
|
||||
@@ -1994,6 +2082,31 @@ impl AuthApplicationService {
|
||||
///
|
||||
/// Staleness is bounded by [`USER_FLAGS_CACHE_TTL`]; role and active
|
||||
/// changes made through this service invalidate the entry eagerly.
|
||||
/// Look up the session id for a refresh token string. Returns
|
||||
/// `Ok(None)` when the token doesn't match any session (typo,
|
||||
/// revoked, expired), `Err` only on real storage errors. Used by
|
||||
/// the change-password handler to identify the caller's current
|
||||
/// session so `revoke_other_user_sessions` can spare it while
|
||||
/// killing the rest.
|
||||
///
|
||||
/// Kept as a thin lookup — this handler doesn't care about the
|
||||
/// full Session entity, only its id, so the caller doesn't have
|
||||
/// to reason about the wire shape of `Session`.
|
||||
pub async fn get_session_id_by_refresh_token(
|
||||
&self,
|
||||
refresh_token: &str,
|
||||
) -> Result<Option<Uuid>, DomainError> {
|
||||
match self
|
||||
.session_storage
|
||||
.get_session_by_refresh_token(refresh_token)
|
||||
.await
|
||||
{
|
||||
Ok(session) => Ok(Some(session.id())),
|
||||
Err(e) if e.kind == ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_user_flags(&self, user_id: Uuid) -> Result<UserFlags, DomainError> {
|
||||
// Single-flight: concurrent misses for the same user coalesce
|
||||
// into ONE storage lookup; errors are never cached (same herd
|
||||
|
||||
@@ -733,6 +733,109 @@ impl MagicLinkInviteService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fire-and-forget security notification: a password change just
|
||||
/// completed on this account. Sends a bilingual email with the
|
||||
/// timestamp and originating IP so a compromised-account victim
|
||||
/// notices out-of-band and can contact their admin. Fired by
|
||||
/// `change_password` after a successful update.
|
||||
///
|
||||
/// NOT a magic-link — no token minted, no link in the body, no
|
||||
/// TTL to reason about. Pure informational. Distinct audit event
|
||||
/// (`auth.password_changed_notification_*`) so operators can
|
||||
/// spot delivery issues separately from magic-link sends.
|
||||
///
|
||||
/// Deactivated accounts skip — no point mailing someone the
|
||||
/// operator just locked out.
|
||||
///
|
||||
/// NOTE: we intentionally do NOT skip OIDC-linked users. Hybrid
|
||||
/// accounts (SSO + local password) are a real posture: users who
|
||||
/// sign in daily via SSO but ALSO keep a local password as a
|
||||
/// fallback. When they rotate that local password, they DO need
|
||||
/// the notification — the fact that they also have an SSO
|
||||
/// linkage doesn't change the "someone touched my local
|
||||
/// credential" signal. If `change_password` reached success and
|
||||
/// we're here, there was a password worth notifying about (the
|
||||
/// upstream `is_oidc_user() && !has_password()` refusal ensured
|
||||
/// pure-OIDC users never reach this point).
|
||||
///
|
||||
/// `client_ip` is the string the request-scope span already
|
||||
/// stamped (via `trusted_proxy::client_ip_from_parts`). We do NOT
|
||||
/// re-derive it here; caller passes exactly what the audit log
|
||||
/// sees, so the recipient can cross-reference with support.
|
||||
pub async fn send_password_changed_notification(
|
||||
&self,
|
||||
user: &User,
|
||||
client_ip: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
if !user.is_active() {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "auth.password_changed_notification_skipped",
|
||||
reason = "account_deactivated",
|
||||
user_id = %user.id(),
|
||||
"🔔 password-change notification skipped: account deactivated",
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let locale = self.locale_for(user);
|
||||
// ISO-8601 UTC — machine-parseable, unambiguous across time
|
||||
// zones. Human-friendly formatting is a translator concern
|
||||
// for a future iteration; for a security email the exact
|
||||
// timestamp matters more than the pretty rendering.
|
||||
let timestamp = chrono::Utc::now()
|
||||
.format("%Y-%m-%d %H:%M:%S UTC")
|
||||
.to_string();
|
||||
let args: Vec<(&str, &str)> = vec![("ip", client_ip), ("timestamp", ×tamp)];
|
||||
|
||||
let subject = self
|
||||
.i18n_or("server.security.password_changed.subject", &locale, &args)
|
||||
.await;
|
||||
let text_body = self
|
||||
.render_bilingual("server.security.password_changed.body", &locale, &args)
|
||||
.await;
|
||||
|
||||
let message = EmailMessage {
|
||||
to: user.email().to_string(),
|
||||
subject,
|
||||
text_body,
|
||||
html_body: None,
|
||||
};
|
||||
|
||||
match self.email_sender.send(message).await {
|
||||
Ok(outcome) => {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "auth.password_changed_notification_sent",
|
||||
user_id = %user.id(),
|
||||
email = %user.email(),
|
||||
client_ip = %client_ip,
|
||||
smtp_code = outcome.code,
|
||||
smtp_message = %outcome.message,
|
||||
"🔔 password-change notification sent to '{}'",
|
||||
user.email(),
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
// Non-fatal: change_password already succeeded; a
|
||||
// delivery failure here just means the victim of a
|
||||
// hypothetical compromise won't be notified out-of-
|
||||
// band. Log at warn so ops sees consistent SMTP issues.
|
||||
tracing::warn!(
|
||||
target: "audit",
|
||||
event = "auth.password_changed_notification_failed",
|
||||
user_id = %user.id(),
|
||||
email = %user.email(),
|
||||
error = %e.message,
|
||||
"🔔 password-change notification SMTP send failed for '{}'",
|
||||
user.email(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve a translation, falling back to the literal key on any
|
||||
/// lookup error. Identical to the handler-side helper — kept inline
|
||||
/// here because the service layer can't pull in a UI util module
|
||||
|
||||
@@ -52,6 +52,22 @@ pub trait SessionRepository: Send + Sync + 'static {
|
||||
/// Revokes all sessions for a user
|
||||
async fn revoke_all_user_sessions(&self, user_id: Uuid) -> SessionRepositoryResult<u64>;
|
||||
|
||||
/// Revokes every session for `user_id` EXCEPT the one identified by
|
||||
/// `keep_session_id`. Classic "password change" pattern: log the
|
||||
/// user out from every OTHER device, but keep the current device's
|
||||
/// session alive so the SPA can complete follow-up work (e.g. OPAQUE
|
||||
/// envelope re-registration) without a session-death race.
|
||||
///
|
||||
/// Returns the count of revoked rows (excluding the kept one).
|
||||
/// If `keep_session_id` doesn't belong to `user_id` (defensive),
|
||||
/// the WHERE clause still matches nothing to revoke on that row —
|
||||
/// no cross-user side effect.
|
||||
async fn revoke_other_user_sessions(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
keep_session_id: Uuid,
|
||||
) -> SessionRepositoryResult<u64>;
|
||||
|
||||
/// Revokes all sessions in a token family (theft response)
|
||||
async fn revoke_session_family(&self, family_id: Uuid) -> SessionRepositoryResult<u64>;
|
||||
|
||||
|
||||
@@ -218,6 +218,46 @@ impl OpaqueRepositoryPort for OpaquePgRepository {
|
||||
Ok(row.and_then(|(t,)| t).is_some())
|
||||
}
|
||||
|
||||
async fn clear_envelope_only(&self, user_id: Uuid) -> Result<()> {
|
||||
// Nulls the OPAQUE columns (envelope + ciphersuite + registered
|
||||
// + migrated + KSF triple) but DOES NOT touch
|
||||
// `force_password_change_at_next_login`. Called by
|
||||
// `AuthApplicationService::change_password` to invalidate an
|
||||
// envelope bound to the OLD passphrase after the user rotates
|
||||
// their legacy password; silent-migration on the next login
|
||||
// re-mints an envelope under the new passphrase. Distinct
|
||||
// from `clear_registration` (which co-flips force_change) —
|
||||
// see the port doc for the "user chose the new value" vs
|
||||
// "admin picked it" split.
|
||||
//
|
||||
// rows_affected is intentionally NOT checked: `change_password`
|
||||
// may run against a user who never had an OPAQUE envelope
|
||||
// (legacy-only account, or `OXICLOUD_AUTH_OPAQUE_MODE=off`
|
||||
// was in effect during their entire lifetime), and that's not
|
||||
// an error — the WHERE just matches nothing. Only real DB
|
||||
// errors propagate.
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE auth.users
|
||||
SET opaque_envelope = NULL,
|
||||
opaque_ciphersuite_version = NULL,
|
||||
opaque_registered_at = NULL,
|
||||
opaque_migrated_at = NULL,
|
||||
opaque_ksf_memory_kib = NULL,
|
||||
opaque_ksf_iterations = NULL,
|
||||
opaque_ksf_parallelism = NULL
|
||||
WHERE id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.execute(self.pool())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("OpaquePg", format!("clear_envelope_only: {e}"))
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn clear_registration(&self, user_id: Uuid) -> Result<()> {
|
||||
// One UPDATE nulls the whole OPAQUE column set AND flips the
|
||||
// force-change flag — matches the atomicity we promise in
|
||||
|
||||
@@ -287,6 +287,50 @@ impl SessionRepository for SessionPgRepository {
|
||||
.await
|
||||
}
|
||||
|
||||
async fn revoke_other_user_sessions(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
keep_session_id: Uuid,
|
||||
) -> SessionRepositoryResult<u64> {
|
||||
// Classic "password change" revocation: kill every OTHER
|
||||
// session for this user so a stolen credential elsewhere is
|
||||
// invalidated, but leave the caller's own session alive so
|
||||
// the SPA can complete follow-up work (envelope re-register,
|
||||
// etc.) without racing a session-death 401.
|
||||
let user_id_copy = user_id;
|
||||
let keep = keep_session_id;
|
||||
with_transaction(&self.pool, "revoke_other_user_sessions", |tx| {
|
||||
Box::pin(async move {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE auth.sessions
|
||||
SET revoked = true
|
||||
WHERE user_id = $1
|
||||
AND id != $2
|
||||
AND revoked = false
|
||||
"#,
|
||||
)
|
||||
.bind(user_id_copy)
|
||||
.bind(keep)
|
||||
.execute(&mut **tx)
|
||||
.await
|
||||
.map_err(Self::map_sqlx_error)?;
|
||||
|
||||
let affected = result.rows_affected();
|
||||
if affected > 0 {
|
||||
tracing::info!(
|
||||
"Revoked {} other sessions for user {} (kept {})",
|
||||
affected,
|
||||
user_id_copy,
|
||||
keep
|
||||
);
|
||||
}
|
||||
Ok(affected)
|
||||
}) as BoxFuture<'_, SessionRepositoryResult<u64>>
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Revokes all sessions in a token family (theft response)
|
||||
async fn revoke_session_family(&self, family_id: Uuid) -> SessionRepositoryResult<u64> {
|
||||
let result = sqlx::query(
|
||||
@@ -520,6 +564,16 @@ impl SessionStoragePort for SessionPgRepository {
|
||||
.map_err(DomainError::from)
|
||||
}
|
||||
|
||||
async fn revoke_other_user_sessions(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
keep_session_id: Uuid,
|
||||
) -> Result<u64, DomainError> {
|
||||
SessionRepository::revoke_other_user_sessions(self, user_id, keep_session_id)
|
||||
.await
|
||||
.map_err(DomainError::from)
|
||||
}
|
||||
|
||||
async fn revoke_session_family(&self, family_id: Uuid) -> Result<u64, DomainError> {
|
||||
SessionRepository::revoke_session_family(self, family_id)
|
||||
.await
|
||||
|
||||
@@ -657,6 +657,8 @@ pub struct UpdateUserImageDto {
|
||||
)]
|
||||
pub async fn change_password(
|
||||
State(state): State<Arc<AppState>>,
|
||||
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||
headers: HeaderMap,
|
||||
CurrentUserId(user_id): CurrentUserId,
|
||||
Json(dto): Json<ChangePasswordDto>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
@@ -665,12 +667,86 @@ pub async fn change_password(
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::internal_error("Authentication service not configured"))?;
|
||||
|
||||
// Resolve the CURRENT session id from the refresh-token cookie so
|
||||
// the service layer can revoke every OTHER session (classic
|
||||
// password-change security posture) while keeping THIS session
|
||||
// alive — the SPA needs to hit `/api/auth/opaque/register/*`
|
||||
// immediately after this response to re-mint the OPAQUE envelope
|
||||
// under the new password. If we revoked the current session too
|
||||
// (the old behaviour), the follow-up register requests would 401
|
||||
// silently and the envelope would stay bound to the OLD password.
|
||||
//
|
||||
// Best-effort: an unauthenticated or cookie-less caller (a CLI
|
||||
// hitting this endpoint with just a bearer, no refresh cookie)
|
||||
// falls back to `None` → the service revokes ALL sessions, same
|
||||
// as the pre-refactor behaviour. That's the safer default when we
|
||||
// can't identify "this" session.
|
||||
let keep_session_id: Option<Uuid> = {
|
||||
let refresh_tok = cookie_auth::extract_cookie_value(&headers, cookie_auth::REFRESH_COOKIE);
|
||||
match refresh_tok {
|
||||
Some(tok) => auth_service
|
||||
.auth_application_service
|
||||
.get_session_id_by_refresh_token(&tok)
|
||||
.await
|
||||
.ok()
|
||||
.flatten(),
|
||||
None => None,
|
||||
}
|
||||
};
|
||||
|
||||
match auth_service
|
||||
.auth_application_service
|
||||
.change_password(user_id, dto)
|
||||
.change_password(user_id, dto, keep_session_id)
|
||||
.await
|
||||
{
|
||||
Ok(()) => Ok(StatusCode::OK),
|
||||
Ok(()) => {
|
||||
// Fire-and-forget security notification: password changed
|
||||
// at [now] from [client_ip]. Reaches the user out-of-band
|
||||
// so a compromised-account victim can notice and alert
|
||||
// their admin. SMTP delivery failures don't affect the
|
||||
// 200 response (the change already succeeded); the
|
||||
// service's own audit log tracks send outcomes.
|
||||
//
|
||||
// Runs on a background task so a slow SMTP handshake
|
||||
// (30-60 s under a marginal mail server) can't stall the
|
||||
// response to the SPA. Cloning the `Arc<MagicLinkInviteService>`
|
||||
// is a refcount bump; the User entity is re-fetched inside
|
||||
// the task from the same user_id we just verified.
|
||||
if let Some(invite_svc) = state.magic_link_invite_service.as_ref() {
|
||||
let client_ip = crate::interfaces::middleware::trusted_proxy::client_ip_from_parts(
|
||||
&headers,
|
||||
Some(peer),
|
||||
false,
|
||||
)
|
||||
.to_string();
|
||||
let invite = invite_svc.clone();
|
||||
let auth = auth_service.auth_application_service.clone();
|
||||
tokio::spawn(async move {
|
||||
// Refetch the user entity fresh — the change we
|
||||
// just made rewrote the row (password_hash), and
|
||||
// the notification method reads `is_active` +
|
||||
// `is_oidc_user` + `email` off the entity to
|
||||
// decide whether to send and where.
|
||||
match auth.get_user_entity(user_id).await {
|
||||
Ok(u) => {
|
||||
let _ = invite
|
||||
.send_password_changed_notification(&u, &client_ip)
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
target: "audit",
|
||||
event = "auth.password_changed_notification_lookup_failed",
|
||||
user_id = %user_id,
|
||||
error = %e.message,
|
||||
"🔔 skipped notification: could not re-fetch user after change_password"
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
Err(err) => {
|
||||
// Remap the same-as-current guard into a stable error_type
|
||||
// the SPA can surface as "pick a different one" without
|
||||
|
||||
Reference in New Issue
Block a user