feat(opaque): legacy login refuses migrated OPAQUE users

This commit is contained in:
Edouard Vanbelle
2026-08-01 20:25:56 +02:00
parent fac65a7c4d
commit 168d94370a
6 changed files with 211 additions and 0 deletions
@@ -180,6 +180,13 @@ pub struct AuthApplicationService {
/// `email_verified_at IS NULL`. Mirrors
/// `AuthConfig::require_verified_email`.
require_verified_email: bool,
/// OPAQUE envelope repo — populated when the OPAQUE substrate is
/// wired (`OXICLOUD_OPAQUE_MODE != off`). `login()` consults it to
/// enforce the Phase 4 gate: once a user has completed at least
/// one successful OPAQUE handshake (`opaque_migrated_at IS NOT
/// NULL`), legacy `POST /api/auth/login` is refused for that
/// account. `None` = substrate off, no gate applies.
opaque_repo: Option<Arc<dyn crate::application::ports::opaque_ports::OpaqueRepositoryPort>>,
}
/// TTL for [`AuthApplicationService::user_flags_cache`]. Upper bound on how
@@ -233,6 +240,7 @@ impl AuthApplicationService {
allowed_auth_methods: vec![AuthMethod::Password, AuthMethod::MagicLink],
auth_policies: Vec::new(),
require_verified_email: false,
opaque_repo: None,
}
}
@@ -357,6 +365,17 @@ impl AuthApplicationService {
self.magic_link_repo.is_some()
}
/// Wire the OPAQUE envelope repo. Called by the DI factory when the
/// OPAQUE substrate is configured (`OXICLOUD_OPAQUE_MODE != off`).
/// Enables the Phase 4 legacy-login gate — see the field docstring.
pub fn with_opaque_repo(
mut self,
repo: Arc<dyn crate::application::ports::opaque_ports::OpaqueRepositoryPort>,
) -> Self {
self.opaque_repo = Some(repo);
self
}
/// Returns the default quota for the given role, capped to the available
/// disk space on the filesystem that hosts the storage directory.
fn capped_quota(&self, role: &UserRole) -> i64 {
@@ -788,6 +807,59 @@ impl AuthApplicationService {
));
}
// Phase 4 gate: legacy password login is refused for users who
// have completed at least one OPAQUE handshake
// (`opaque_migrated_at IS NOT NULL`). A stale client or a
// downgrade attacker with a stolen password blob is the only
// caller who lands here — the SPA already probes
// `POST /api/auth/opaque/login/lookup` and takes the OPAQUE
// branch when an envelope exists. Admin password reset
// atomically NULLs `opaque_migrated_at` (see
// `opaque_pg_repository.rs::clear_registration`), so the
// state is coherent — no `force_password_change`
// carve-out is needed here.
//
// Checked AFTER password verify so an attacker without the
// password learns nothing new about a user's OPAQUE status:
// only a caller who supplied the right password gets the
// distinguishing "use OPAQUE" signal, and that caller was
// going to be redirected anyway.
//
// Fails OPEN on repo error — a transient DB blip must not
// lock every migrated user out; the same login path will
// succeed on the next attempt when the repo recovers, and
// an operator reading the audit log sees the failure clearly.
if let Some(opaque) = self.opaque_repo.as_ref() {
match opaque.is_migrated(user.id()).await {
Ok(true) => {
tracing::info!(
target: "audit",
event = "auth.login_rejected",
reason = "opaque_migrated_use_opaque",
user_id = %user.id(),
username = %user.display_for_audit(),
"🔐 legacy login refused: user is OPAQUE-migrated ('{}')",
user.display_for_audit(),
);
return Err(DomainError::new(
ErrorKind::AccessDenied,
"Auth",
"Password login refused: this account has migrated to OPAQUE",
));
}
Ok(false) => {}
Err(e) => {
tracing::warn!(
target: "audit",
event = "auth.opaque_migration_check_failed",
user_id = %user.id(),
error = %e,
"OPAQUE migration check failed — allowing legacy login as fallback"
);
}
}
}
// Gate: `OXICLOUD_REQUIRE_VERIFIED_EMAIL`. Checked AFTER password
// validation so an attacker with only a username cannot probe
// account verification state (the response shape is