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:
Edouard Vanbelle
2026-08-05 23:35:05 +02:00
parent 60cf9d976b
commit 21607e3e7f
29 changed files with 611 additions and 33 deletions
+78 -2
View File
@@ -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