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
@@ -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