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
|
/// Idempotent (COALESCE preserves the first-migration timestamp
|
||||||
/// so a later login doesn't rewrite the operational signal).
|
/// so a later login doesn't rewrite the operational signal).
|
||||||
async fn mark_migrated(&self, user_id: Uuid) -> Result<()>;
|
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
|
/// `email_verified_at IS NULL`. Mirrors
|
||||||
/// `AuthConfig::require_verified_email`.
|
/// `AuthConfig::require_verified_email`.
|
||||||
require_verified_email: bool,
|
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
|
/// TTL for [`AuthApplicationService::user_flags_cache`]. Upper bound on how
|
||||||
@@ -233,6 +240,7 @@ impl AuthApplicationService {
|
|||||||
allowed_auth_methods: vec![AuthMethod::Password, AuthMethod::MagicLink],
|
allowed_auth_methods: vec![AuthMethod::Password, AuthMethod::MagicLink],
|
||||||
auth_policies: Vec::new(),
|
auth_policies: Vec::new(),
|
||||||
require_verified_email: false,
|
require_verified_email: false,
|
||||||
|
opaque_repo: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,6 +365,17 @@ impl AuthApplicationService {
|
|||||||
self.magic_link_repo.is_some()
|
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
|
/// Returns the default quota for the given role, capped to the available
|
||||||
/// disk space on the filesystem that hosts the storage directory.
|
/// disk space on the filesystem that hosts the storage directory.
|
||||||
fn capped_quota(&self, role: &UserRole) -> i64 {
|
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
|
// Gate: `OXICLOUD_REQUIRE_VERIFIED_EMAIL`. Checked AFTER password
|
||||||
// validation so an attacker with only a username cannot probe
|
// validation so an attacker with only a username cannot probe
|
||||||
// account verification state (the response shape is
|
// account verification state (the response shape is
|
||||||
|
|||||||
@@ -1878,10 +1878,32 @@ impl AppServiceFactory {
|
|||||||
// PersonalDriveLifecycleHook, which already holds an Arc to the
|
// PersonalDriveLifecycleHook, which already holds an Arc to the
|
||||||
// folder service via the user_lifecycle dispatcher.
|
// folder service via the user_lifecycle dispatcher.
|
||||||
if self.config.features.enable_auth {
|
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(
|
let services = crate::infrastructure::auth_factory::create_auth_services(
|
||||||
&self.config,
|
&self.config,
|
||||||
pool.clone(),
|
pool.clone(),
|
||||||
user_lifecycle.clone(),
|
user_lifecycle.clone(),
|
||||||
|
opaque_repo_for_auth,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ pub async fn create_auth_services(
|
|||||||
config: &AppConfig,
|
config: &AppConfig,
|
||||||
pool: Arc<PgPool>,
|
pool: Arc<PgPool>,
|
||||||
user_lifecycle: Arc<UserLifecycleService>,
|
user_lifecycle: Arc<UserLifecycleService>,
|
||||||
|
opaque_repo: Option<Arc<dyn crate::application::ports::opaque_ports::OpaqueRepositoryPort>>,
|
||||||
) -> Result<AuthServices> {
|
) -> Result<AuthServices> {
|
||||||
// Create JWT token service (TokenServicePort implementation)
|
// Create JWT token service (TokenServicePort implementation)
|
||||||
let token_service: Arc<JwtTokenService> = Arc::new(JwtTokenService::new(
|
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()));
|
Arc::new(MagicLinkTokenPgRepository::new(pool.clone()));
|
||||||
auth_app_service = auth_app_service.with_magic_link_repo(magic_link_repo);
|
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
|
// Configure OIDC service if enabled
|
||||||
if config.oidc.enabled {
|
if config.oidc.enabled {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
|
|||||||
@@ -149,6 +149,27 @@ impl OpaqueRepositoryPort for OpaquePgRepository {
|
|||||||
Ok(())
|
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<()> {
|
async fn clear_registration(&self, user_id: Uuid) -> Result<()> {
|
||||||
// One UPDATE writes both the envelope invalidation AND the
|
// One UPDATE writes both the envelope invalidation AND the
|
||||||
// force-change flag — matches the atomicity we promise in 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]
|
#[tokio::test]
|
||||||
async fn missing_user_surfaces_notfound_on_write_and_read_and_clear() {
|
async fn missing_user_surfaces_notfound_on_write_and_read_and_clear() {
|
||||||
let repo = test_repo().await;
|
let repo = test_repo().await;
|
||||||
|
|||||||
@@ -466,6 +466,22 @@ pub async fn login(
|
|||||||
// AFTER the password check specifically so an attacker
|
// AFTER the password check specifically so an attacker
|
||||||
// without the password can't discover an account's
|
// without the password can't discover an account's
|
||||||
// verification state from the response shape.
|
// 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" {
|
if err.message == "Email not verified" {
|
||||||
// Best-effort auto-send. We swallow any error and still
|
// Best-effort auto-send. We swallow any error and still
|
||||||
// return the same EmailNotVerified response — the
|
// return the same EmailNotVerified response — the
|
||||||
|
|||||||
Reference in New Issue
Block a user