feat(opaque): legacy login refuses migrated OPAQUE users
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user