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