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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user