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
+14
View File
@@ -102,4 +102,18 @@ pub trait OpaqueRepositoryPort: Send + Sync + 'static {
/// Idempotent (COALESCE preserves the first-migration timestamp
/// so a later login doesn't rewrite the operational signal).
async fn mark_migrated(&self, user_id: Uuid) -> Result<()>;
/// True iff `user_id` has completed at least one successful
/// OPAQUE login (i.e. `opaque_migrated_at IS NOT NULL`). Read
/// by the legacy login gate in Phase 4 to refuse password
/// authentication for users who've already proven OPAQUE
/// capability — the admin-reset path re-opens legacy by
/// NULL-ing this column via [`clear_registration`], so the
/// state is coherent without a separate carve-out.
///
/// Returns `false` for missing users (anti-enum: the legacy
/// gate must not distinguish "user gone" from "user not
/// migrated" — the wrong-password branch already covered the
/// user-lookup miss upstream).
async fn is_migrated(&self, user_id: Uuid) -> Result<bool>;
}
@@ -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
+22
View File
@@ -1878,10 +1878,32 @@ impl AppServiceFactory {
// PersonalDriveLifecycleHook, which already holds an Arc to the
// folder service via the user_lifecycle dispatcher.
if self.config.features.enable_auth {
// OPAQUE repo built here (pre-`create_auth_services`) so
// the AuthApplicationService gets the Phase 4 gate wired
// at construction time — before Arc-wrapping locks the
// shape. Gate fires only when `effective_mode != Off`;
// an OPAQUE-off deployment gets `None` and legacy login
// stays open for every user (including anyone with a
// stale `opaque_migrated_at` from a previous rollout).
let opaque_repo_for_auth: Option<
Arc<dyn crate::application::ports::opaque_ports::OpaqueRepositoryPort>,
> = {
use crate::infrastructure::services::opaque_service::OpaqueMode;
if self.config.opaque.effective_mode(&self.config.auth) != OpaqueMode::Off {
Some(Arc::new(
crate::infrastructure::repositories::pg::OpaquePgRepository::new(
pool.clone(),
),
))
} else {
None
}
};
let services = crate::infrastructure::auth_factory::create_auth_services(
&self.config,
pool.clone(),
user_lifecycle.clone(),
opaque_repo_for_auth,
)
.await
.map_err(|e| {
+12
View File
@@ -18,6 +18,7 @@ pub async fn create_auth_services(
config: &AppConfig,
pool: Arc<PgPool>,
user_lifecycle: Arc<UserLifecycleService>,
opaque_repo: Option<Arc<dyn crate::application::ports::opaque_ports::OpaqueRepositoryPort>>,
) -> Result<AuthServices> {
// Create JWT token service (TokenServicePort implementation)
let token_service: Arc<JwtTokenService> = Arc::new(JwtTokenService::new(
@@ -71,6 +72,17 @@ pub async fn create_auth_services(
Arc::new(MagicLinkTokenPgRepository::new(pool.clone()));
auth_app_service = auth_app_service.with_magic_link_repo(magic_link_repo);
// Wire the OPAQUE repo when the substrate is active. Enables the
// Phase 4 legacy-login gate — `AuthApplicationService::login`
// refuses `POST /api/auth/login` for users with
// `opaque_migrated_at IS NOT NULL` (see the field doc for the
// safety analysis). When the OPAQUE mode is `off` at the config
// layer this is None and the gate never fires — legacy stays open
// for every user regardless of any historical migration state.
if let Some(repo) = opaque_repo {
auth_app_service = auth_app_service.with_opaque_repo(repo);
}
// Configure OIDC service if enabled
if config.oidc.enabled {
tracing::info!(
@@ -149,6 +149,27 @@ impl OpaqueRepositoryPort for OpaquePgRepository {
Ok(())
}
async fn is_migrated(&self, user_id: Uuid) -> Result<bool> {
// Cheap presence check on the partial index
// `idx_users_opaque_migrated`. `fetch_optional` returning `None`
// covers both "no such user" and "user exists but not migrated";
// Phase 4's gate collapses both to `false` (anti-enum — the
// wrong-password branch upstream has already covered the
// user-lookup miss).
let row: Option<(Option<chrono::DateTime<chrono::Utc>>,)> = sqlx::query_as(
r#"
SELECT opaque_migrated_at
FROM auth.users
WHERE id = $1
"#,
)
.bind(user_id)
.fetch_optional(self.pool())
.await
.map_err(|e| DomainError::internal_error("OpaquePg", format!("is_migrated: {e}")))?;
Ok(row.and_then(|(t,)| t).is_some())
}
async fn clear_registration(&self, user_id: Uuid) -> Result<()> {
// One UPDATE writes both the envelope invalidation AND the
// force-change flag — matches the atomicity we promise in the
@@ -510,6 +531,60 @@ mod integration_tests {
);
}
/// `is_migrated` returns false until `mark_migrated` stamps
/// `opaque_migrated_at`, then flips to true. `clear_registration`
/// re-opens the fallback by NULL-ing the column. This is the
/// exact state machine the Phase 4 legacy-login gate reads.
#[tokio::test]
async fn is_migrated_tracks_mark_and_clear_state_transitions() {
let repo = test_repo().await;
let user = seed_user(
&repo,
&format!("opaque-ism-{}@example.invalid", Uuid::new_v4()),
)
.await;
// Fresh user: no envelope, no migration mark.
assert!(
!repo.is_migrated(user).await.unwrap(),
"fresh user must not be marked migrated"
);
// Mark migrated — should flip the read to true. The service-
// level gate refuses legacy login from this point onward.
repo.mark_migrated(user).await.expect("mark migrated");
assert!(
repo.is_migrated(user).await.unwrap(),
"user must be marked migrated after mark_migrated"
);
// Admin password reset (clear_registration) MUST re-open the
// legacy fallback by NULL-ing opaque_migrated_at — otherwise
// an admin-reset user would be locked out of their own account
// (no envelope, but Phase 4 gate still refuses legacy).
repo.clear_registration(user)
.await
.expect("clear registration");
assert!(
!repo.is_migrated(user).await.unwrap(),
"clear_registration must NULL opaque_migrated_at to re-open the legacy fallback"
);
}
/// Missing user reads as `false` (anti-enum). The service-layer
/// gate must not distinguish "user gone" from "user not migrated"
/// — the upstream user lookup + password check already covered
/// the "unknown identifier" branch.
#[tokio::test]
async fn is_migrated_returns_false_for_missing_user() {
let repo = test_repo().await;
let ghost = Uuid::new_v4();
assert!(
!repo.is_migrated(ghost).await.unwrap(),
"missing user must read as not-migrated (anti-enum)"
);
}
#[tokio::test]
async fn missing_user_surfaces_notfound_on_write_and_read_and_clear() {
let repo = test_repo().await;
@@ -466,6 +466,22 @@ pub async fn login(
// AFTER the password check specifically so an attacker
// without the password can't discover an account's
// verification state from the response shape.
// Phase 4: the service refuses legacy login for
// OPAQUE-migrated users with this exact message. Remap to a
// stable `error_type` the SPA can branch on — a legit
// caller reaching this branch would already have taken the
// OPAQUE path (the SPA's login form calls
// `/api/auth/opaque/login/lookup` first), so this response
// primarily serves legacy clients and downgrade-attack
// detection. 403 keeps the shape consistent with the other
// policy refusals (PasswordLoginDisabled, EmailNotVerified).
if err.message == "Password login refused: this account has migrated to OPAQUE" {
return Err(AppError::new(
StatusCode::FORBIDDEN,
"Password login is no longer available for this account. Please sign in via the OPAQUE flow.",
"OpaqueLoginRequired",
));
}
if err.message == "Email not verified" {
// Best-effort auto-send. We swallow any error and still
// return the same EmailNotVerified response — the