feat(opaque): wire API
- POST /api/auth/opaque/login/ke1 (public) — takes {userIdentifier, startLoginRequest}, resolves the identifier via the same @-dispatch as legacy login (AuthApplicationService::lookup_user_for_login, factored out), fetches envelope, runs ServerLogin::start (real branch for known users, dummy branch for anti-enum on unknown/unregistered), stashes state under a random exchange_id in the moka cache, returns {exchangeId, loginResponse}.
- POST /api/auth/opaque/login/ke3 (public) — atomic take from the cache FIRST (anti-enum + anti-replay), then decodes the payload, runs ServerLogin::finish, stamps opaque_migrated_at (Phase 3 signal), and mints a session via the new AuthApplicationService::mint_session_for_authenticated_user helper — returns the same AuthResponseDto shape as legacy login so the SPA has one downstream handler.
- Session mint factored: mint_session_for_authenticated_user(User) extracted from login() so both the legacy password path and OPAQUE KE3 converge through one implementation.
- OpaqueRepositoryPort::mark_migrated with COALESCE-preserving idempotent stamp of opaque_migrated_at.
- opaque-setup CLI + Dockerfile wiring already shipped (Step 0 hygiene).
- Routing fix: sub-prefix split (/api/auth/opaque/register vs /api/auth/opaque/login) — axum composes middleware between sibling nests at the same prefix, which was cross-applying auth+CSRF to my public login routes. Distinct prefixes side-step that cleanly. Documented in both main.rs and the router builder doc.
- Rate-limit sharing: login KE1/KE3 layered with the SAME login_limiter instance as legacy POST /api/auth/login, so an attacker can't halve the per-IP budget by spraying both endpoints.
Anti-enum + anti-replay hardening in KE3: take runs BEFORE payload parse so:
- Unknown / expired / already-consumed exchange_id → 401 InvalidCredentials (same shape as wrong-passphrase, no payload-shape leak)
- Consumed handle can't be re-used to spam parse attempts
This commit is contained in:
@@ -92,4 +92,14 @@ pub trait OpaqueRepositoryPort: Send + Sync + 'static {
|
|||||||
/// on the envelope columns but STILL sets the force-change flag
|
/// on the envelope columns but STILL sets the force-change flag
|
||||||
/// (that's the point of the admin call).
|
/// (that's the point of the admin call).
|
||||||
async fn clear_registration(&self, user_id: Uuid) -> Result<()>;
|
async fn clear_registration(&self, user_id: Uuid) -> Result<()>;
|
||||||
|
|
||||||
|
/// Stamp `opaque_migrated_at` on `user_id` if it isn't set yet.
|
||||||
|
/// Called by the login-KE3 handler after a successful OPAQUE
|
||||||
|
/// handshake — the presence of this timestamp is the Phase 3+
|
||||||
|
/// signal that legacy `POST /api/auth/login` should refuse for
|
||||||
|
/// this user.
|
||||||
|
///
|
||||||
|
/// 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<()>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -712,7 +712,7 @@ impl AuthApplicationService {
|
|||||||
} else {
|
} else {
|
||||||
self.user_storage.get_user_by_username(&dto.username).await
|
self.user_storage.get_user_by_username(&dto.username).await
|
||||||
};
|
};
|
||||||
let mut user = lookup.map_err(|_| {
|
let user = lookup.map_err(|_| {
|
||||||
// Audit: unknown-identifier login attempt. Reason key kept
|
// Audit: unknown-identifier login attempt. Reason key kept
|
||||||
// stable so log search can aggregate without parsing the
|
// stable so log search can aggregate without parsing the
|
||||||
// human-readable message. Caller's client IP + request id
|
// human-readable message. Caller's client IP + request id
|
||||||
@@ -827,6 +827,38 @@ impl AuthApplicationService {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mint the session — factored so the OPAQUE login handler
|
||||||
|
// can reuse the exact same shape after a successful OPAQUE
|
||||||
|
// handshake (Phase 1, `login/ke3`). Both paths converge here
|
||||||
|
// so lifecycle + token + session-family semantics stay in
|
||||||
|
// one place.
|
||||||
|
self.mint_session_for_authenticated_user(user).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emit a fresh session for a user who has ALREADY been
|
||||||
|
/// authenticated by a mechanism the caller trusts (legacy
|
||||||
|
/// password verify, OPAQUE KE3 success, magic-link redemption).
|
||||||
|
///
|
||||||
|
/// This method does NOT verify any credential — the caller must
|
||||||
|
/// have proven identity before invoking it. What it DOES do:
|
||||||
|
///
|
||||||
|
/// * Dispatch `on_user_login` lifecycle (so
|
||||||
|
/// `PersonalDriveLifecycleHook` can safety-net first-login
|
||||||
|
/// provisioning).
|
||||||
|
/// * Update `last_login_at` (in-memory; `create_session`
|
||||||
|
/// persists it as a side effect via its own transaction).
|
||||||
|
/// * Mint access + refresh tokens under a fresh token family.
|
||||||
|
/// * Persist the session row.
|
||||||
|
/// * Return the shared [`AuthResponseDto`] shape.
|
||||||
|
///
|
||||||
|
/// Callers: `login()` (after password verify),
|
||||||
|
/// `redeem_magic_link()` (after token redemption),
|
||||||
|
/// `interfaces::api::handlers::opaque_auth_handler::login_ke3`
|
||||||
|
/// (after OPAQUE handshake).
|
||||||
|
pub async fn mint_session_for_authenticated_user(
|
||||||
|
&self,
|
||||||
|
mut user: crate::domain::entities::user::User,
|
||||||
|
) -> Result<AuthResponseDto, DomainError> {
|
||||||
// Lifecycle: dispatch login BEFORE register_login() so hooks
|
// Lifecycle: dispatch login BEFORE register_login() so hooks
|
||||||
// observing `last_login_at().is_none()` see "first ever login"
|
// observing `last_login_at().is_none()` see "first ever login"
|
||||||
// correctly. See tip #1 in user_lifecycle.rs.
|
// correctly. See tip #1 in user_lifecycle.rs.
|
||||||
@@ -2073,6 +2105,26 @@ impl AuthApplicationService {
|
|||||||
UserStoragePort::get_user_by_id(&*self.user_storage, user_id).await
|
UserStoragePort::get_user_by_id(&*self.user_storage, user_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Login-style identifier lookup: dispatches on `@` in the input
|
||||||
|
/// (email path when present, username path when not), identical
|
||||||
|
/// to `login()`'s dispatch. Exposed so the OPAQUE login handler
|
||||||
|
/// (`opaque_auth_handler::login_ke1`) can resolve the same
|
||||||
|
/// identifier shape without duplicating the `@` heuristic.
|
||||||
|
///
|
||||||
|
/// Returns the raw DB error on miss — callers are responsible
|
||||||
|
/// for the anti-enum shape (do NOT surface the DomainError kind
|
||||||
|
/// distinction to unauthenticated clients).
|
||||||
|
pub async fn lookup_user_for_login(
|
||||||
|
&self,
|
||||||
|
identifier: &str,
|
||||||
|
) -> Result<crate::domain::entities::user::User, DomainError> {
|
||||||
|
if identifier.contains('@') {
|
||||||
|
self.user_storage.get_user_by_email(identifier).await
|
||||||
|
} else {
|
||||||
|
self.user_storage.get_user_by_username(identifier).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Visibility-checked profile lookup for `GET /api/users/{id}`.
|
/// Visibility-checked profile lookup for `GET /api/users/{id}`.
|
||||||
///
|
///
|
||||||
/// Returns `NotFound` (not `AccessDenied`) when the caller has no
|
/// Returns `NotFound` (not `AccessDenied`) when the caller has no
|
||||||
|
|||||||
@@ -125,6 +125,30 @@ impl OpaqueRepositoryPort for OpaquePgRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn mark_migrated(&self, user_id: Uuid) -> Result<()> {
|
||||||
|
// COALESCE preserves the first-migration timestamp — same
|
||||||
|
// pattern as write_registration preserves opaque_registered_at.
|
||||||
|
// Ops dashboards read this to answer "what fraction of users
|
||||||
|
// have completed the OPAQUE cutover", so overwriting on every
|
||||||
|
// login would erase the signal.
|
||||||
|
let res = sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE auth.users
|
||||||
|
SET opaque_migrated_at = COALESCE(opaque_migrated_at, NOW())
|
||||||
|
WHERE id = $1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(self.pool())
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::internal_error("OpaquePg", format!("mark_migrated: {e}")))?;
|
||||||
|
|
||||||
|
if res.rows_affected() == 0 {
|
||||||
|
return Err(DomainError::not_found("User", user_id.to_string()));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -438,6 +462,54 @@ mod integration_tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mark_migrated is idempotent — a second call preserves the
|
||||||
|
/// first-migration timestamp, matching the ops-dashboard contract
|
||||||
|
/// ("when did this user first complete OPAQUE?").
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mark_migrated_stamps_once_and_is_idempotent() {
|
||||||
|
let repo = test_repo().await;
|
||||||
|
let user =
|
||||||
|
seed_user(&repo, &format!("opaque-mig-{}@example.invalid", Uuid::new_v4())).await;
|
||||||
|
|
||||||
|
// Read the initial NULL state
|
||||||
|
let initial: (Option<chrono::DateTime<chrono::Utc>>,) = sqlx::query_as(
|
||||||
|
"SELECT opaque_migrated_at FROM auth.users WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(user)
|
||||||
|
.fetch_one(repo.pool())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
initial.0.is_none(),
|
||||||
|
"new user starts with no opaque_migrated_at"
|
||||||
|
);
|
||||||
|
|
||||||
|
repo.mark_migrated(user).await.expect("first mark");
|
||||||
|
let first: (Option<chrono::DateTime<chrono::Utc>>,) = sqlx::query_as(
|
||||||
|
"SELECT opaque_migrated_at FROM auth.users WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(user)
|
||||||
|
.fetch_one(repo.pool())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let first_ts = first.0.expect("timestamp set after first mark");
|
||||||
|
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||||
|
repo.mark_migrated(user).await.expect("second mark");
|
||||||
|
let second: (Option<chrono::DateTime<chrono::Utc>>,) = sqlx::query_as(
|
||||||
|
"SELECT opaque_migrated_at FROM auth.users WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(user)
|
||||||
|
.fetch_one(repo.pool())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
second.0.unwrap(),
|
||||||
|
first_ts,
|
||||||
|
"second mark_migrated preserves the first timestamp (COALESCE)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[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;
|
||||||
@@ -449,6 +521,7 @@ mod integration_tests {
|
|||||||
.unwrap_err(),
|
.unwrap_err(),
|
||||||
repo.read_registration(ghost).await.unwrap_err(),
|
repo.read_registration(ghost).await.unwrap_err(),
|
||||||
repo.clear_registration(ghost).await.unwrap_err(),
|
repo.clear_registration(ghost).await.unwrap_err(),
|
||||||
|
repo.mark_migrated(ghost).await.unwrap_err(),
|
||||||
] {
|
] {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
err.kind,
|
err.kind,
|
||||||
|
|||||||
@@ -71,10 +71,39 @@ pub const DEFAULT_MAX_INFLIGHT: u64 = 10_000;
|
|||||||
/// correct passphrase).
|
/// correct passphrase).
|
||||||
pub type ExchangeId = Uuid;
|
pub type ExchangeId = Uuid;
|
||||||
|
|
||||||
|
/// Server-side state stashed between KE1 and KE3.
|
||||||
|
///
|
||||||
|
/// Holds the [`ServerLogin`] (opaque-ke handshake continuation) plus
|
||||||
|
/// the `user_id` KE1 resolved from the client's identifier — `Some`
|
||||||
|
/// for a real user, `None` for the anti-enum dummy branch (unknown
|
||||||
|
/// user or user without an envelope). KE3 uses this to know which
|
||||||
|
/// account to mint the session for on success; the dummy branch's
|
||||||
|
/// KE3 will fail the AKE check inside `ServerLogin::finish` and
|
||||||
|
/// never reach the session mint, so the `None` variant is a
|
||||||
|
/// belt-and-braces guard we never rely on for correctness.
|
||||||
|
///
|
||||||
|
/// `state` lives in a `Mutex<Option<_>>` because moka's `Cache::remove`
|
||||||
|
/// returns a *clone* of the stored `Arc` (not the original). That means
|
||||||
|
/// unwrapping the Arc to consume `ServerLogin` (which isn't `Clone`)
|
||||||
|
/// would race the briefly-lingering cache-side ref. `Mutex::lock().take()`
|
||||||
|
/// gives us ownership of the inner value regardless of how many Arc
|
||||||
|
/// clones exist.
|
||||||
|
struct StashedExchange {
|
||||||
|
state: std::sync::Mutex<Option<ServerLogin<OxiCloudSuite>>>,
|
||||||
|
user_id: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exchange state handed back to the KE3 handler — a plain owned
|
||||||
|
/// tuple so consumers work with the concrete types, not Arc<Mutex<_>>.
|
||||||
|
pub struct TakenExchange {
|
||||||
|
pub state: ServerLogin<OxiCloudSuite>,
|
||||||
|
pub user_id: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
/// In-memory cache holding server-side login state between KE1 and KE3.
|
/// In-memory cache holding server-side login state between KE1 and KE3.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct OpaqueLoginExchange {
|
pub struct OpaqueLoginExchange {
|
||||||
inner: Arc<Cache<ExchangeId, ServerLogin<OxiCloudSuite>>>,
|
inner: Arc<Cache<ExchangeId, Arc<StashedExchange>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OpaqueLoginExchange {
|
impl OpaqueLoginExchange {
|
||||||
@@ -97,25 +126,43 @@ impl OpaqueLoginExchange {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stash a fresh `ServerLogin` state and return the handle to
|
/// Stash a fresh exchange and return the handle to hand back to
|
||||||
/// hand back to the client. The exchange_id is generated here so
|
/// the client. The exchange_id is generated here so callers
|
||||||
/// callers can't accidentally reuse one — every KE1 gets its own.
|
/// can't accidentally reuse one — every KE1 gets its own.
|
||||||
pub fn store(&self, state: ServerLogin<OxiCloudSuite>) -> ExchangeId {
|
pub fn store(&self, state: ServerLogin<OxiCloudSuite>, user_id: Option<Uuid>) -> ExchangeId {
|
||||||
let id = Uuid::new_v4();
|
let id = Uuid::new_v4();
|
||||||
self.inner.insert(id, state);
|
self.inner.insert(
|
||||||
|
id,
|
||||||
|
Arc::new(StashedExchange {
|
||||||
|
state: std::sync::Mutex::new(Some(state)),
|
||||||
|
user_id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Atomically consume the state for `exchange_id`. Returns `None`
|
/// Atomically consume the exchange for `exchange_id`. Returns
|
||||||
/// if the id is unknown, already consumed, or expired. Callers
|
/// `None` if the id is unknown, already consumed, or expired.
|
||||||
/// must treat those three cases identically (anti-enum): a KE3
|
/// Callers must treat those three cases identically (anti-enum):
|
||||||
/// with a bad id, a replay, and a timeout should all surface as
|
/// a KE3 with a bad id, a replay, and a timeout should all
|
||||||
/// the same `InvalidCredentials` shape to the client.
|
/// surface as the same `InvalidCredentials` shape to the client.
|
||||||
///
|
///
|
||||||
/// Uses moka's atomic `remove` (verified single get-and-invalidate
|
/// Combines two atomicity guarantees:
|
||||||
/// in moka 0.12+, no race window between the two operations).
|
///
|
||||||
pub fn take(&self, exchange_id: ExchangeId) -> Option<ServerLogin<OxiCloudSuite>> {
|
/// * `moka::Cache::remove` is a single get-and-invalidate on
|
||||||
self.inner.remove(&exchange_id)
|
/// the cache side (verified in moka 0.12+).
|
||||||
|
/// * `Mutex::lock().take()` gives us ownership of the inner
|
||||||
|
/// `ServerLogin` even when moka returns a *clone* of the
|
||||||
|
/// stored `Arc` (which it does — `remove` yields a clone,
|
||||||
|
/// not the original), and prevents two concurrent takers
|
||||||
|
/// from both seeing `Some`.
|
||||||
|
pub fn take(&self, exchange_id: ExchangeId) -> Option<TakenExchange> {
|
||||||
|
let arc = self.inner.remove(&exchange_id)?;
|
||||||
|
let state = arc.state.lock().ok()?.take()?;
|
||||||
|
Some(TakenExchange {
|
||||||
|
state,
|
||||||
|
user_id: arc.user_id,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Force runtime maintenance (LRU eviction + TTL sweep). Moka runs
|
/// Force runtime maintenance (LRU eviction + TTL sweep). Moka runs
|
||||||
@@ -206,9 +253,10 @@ mod tests {
|
|||||||
let cache = OpaqueLoginExchange::with_params(Duration::from_secs(60), 100);
|
let cache = OpaqueLoginExchange::with_params(Duration::from_secs(60), 100);
|
||||||
let state = build_server_login_state();
|
let state = build_server_login_state();
|
||||||
|
|
||||||
let id = cache.store(state);
|
let id = cache.store(state, Some(Uuid::new_v4()));
|
||||||
// Second call after take must miss — single-use semantic.
|
// Second call after take must miss — single-use semantic.
|
||||||
assert!(cache.take(id).is_some(), "first take retrieves the state");
|
let taken = cache.take(id).expect("first take retrieves the state");
|
||||||
|
assert!(taken.user_id.is_some(), "user_id round-trips through the stash");
|
||||||
assert!(
|
assert!(
|
||||||
cache.take(id).is_none(),
|
cache.take(id).is_none(),
|
||||||
"second take must miss — exchange_id is single-use"
|
"second take must miss — exchange_id is single-use"
|
||||||
@@ -228,7 +276,7 @@ mod tests {
|
|||||||
// (it runs pending tasks lazily), so `run_pending_tasks`
|
// (it runs pending tasks lazily), so `run_pending_tasks`
|
||||||
// forces a deterministic sweep.
|
// forces a deterministic sweep.
|
||||||
let cache = OpaqueLoginExchange::with_params(Duration::from_millis(100), 100);
|
let cache = OpaqueLoginExchange::with_params(Duration::from_millis(100), 100);
|
||||||
let id = cache.store(build_server_login_state());
|
let id = cache.store(build_server_login_state(), None);
|
||||||
|
|
||||||
std::thread::sleep(Duration::from_millis(150));
|
std::thread::sleep(Duration::from_millis(150));
|
||||||
cache.run_pending_tasks();
|
cache.run_pending_tasks();
|
||||||
@@ -245,8 +293,19 @@ mod tests {
|
|||||||
// — reusing one would enable a KE3 to consume the wrong
|
// — reusing one would enable a KE3 to consume the wrong
|
||||||
// exchange's state.
|
// exchange's state.
|
||||||
let cache = OpaqueLoginExchange::new();
|
let cache = OpaqueLoginExchange::new();
|
||||||
let a = cache.store(build_server_login_state());
|
let a = cache.store(build_server_login_state(), None);
|
||||||
let b = cache.store(build_server_login_state());
|
let b = cache.store(build_server_login_state(), None);
|
||||||
assert_ne!(a, b, "each store() must mint a fresh UUID");
|
assert_ne!(a, b, "each store() must mint a fresh UUID");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dummy_branch_stash_carries_no_user_id() {
|
||||||
|
// KE1 for an unknown user stashes `user_id: None` (anti-enum
|
||||||
|
// — dummy branch). KE3 for the dummy will fail at AKE, but
|
||||||
|
// the stash contract is: real user → Some(id), unknown → None.
|
||||||
|
let cache = OpaqueLoginExchange::new();
|
||||||
|
let id = cache.store(build_server_login_state(), None);
|
||||||
|
let taken = cache.take(id).expect("take dummy stash");
|
||||||
|
assert!(taken.user_id.is_none(), "dummy-branch stash carries no user_id");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,22 +65,51 @@ use axum::response::IntoResponse;
|
|||||||
use axum::routing::post;
|
use axum::routing::post;
|
||||||
use base64::Engine as _;
|
use base64::Engine as _;
|
||||||
use base64::engine::general_purpose::STANDARD as B64;
|
use base64::engine::general_purpose::STANDARD as B64;
|
||||||
use opaque_ke::{RegistrationRequest, RegistrationUpload, ServerRegistration};
|
use opaque_ke::{
|
||||||
|
CredentialFinalization, CredentialRequest, RegistrationRequest, RegistrationUpload,
|
||||||
|
ServerLoginStartParameters, ServerRegistration,
|
||||||
|
};
|
||||||
|
use rand_core::OsRng;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::application::dtos::user_dto::AuthResponseDto;
|
||||||
use crate::common::di::AppState;
|
use crate::common::di::AppState;
|
||||||
|
use crate::infrastructure::services::opaque_login_exchange::{
|
||||||
|
ExchangeId, OpaqueLoginExchange,
|
||||||
|
};
|
||||||
use crate::infrastructure::services::opaque_service::{OpaqueService, OxiCloudSuite};
|
use crate::infrastructure::services::opaque_service::{OpaqueService, OxiCloudSuite};
|
||||||
use crate::interfaces::errors::AppError;
|
use crate::interfaces::errors::AppError;
|
||||||
use crate::interfaces::middleware::auth::CurrentUserId;
|
use crate::interfaces::middleware::auth::CurrentUserId;
|
||||||
|
|
||||||
/// Session-required OPAQUE routes. Callers layer the auth + CSRF
|
/// Session-required OPAQUE routes. Callers layer the auth + CSRF
|
||||||
/// middlewares in `main.rs` (mirrors [`auth_protected_routes`]).
|
/// middlewares in `main.rs` (mirrors [`auth_protected_routes`]).
|
||||||
|
/// Session-required OPAQUE register routes. Mount under
|
||||||
|
/// `/api/auth/opaque/register` in `main.rs` with the auth + CSRF
|
||||||
|
/// layer stack (mirrors [`auth_protected_routes`]).
|
||||||
|
///
|
||||||
|
/// **The mount prefix MUST be distinct from `opaque_login_routes`'s
|
||||||
|
/// prefix.** Nesting both routers at the same `/api/auth` node
|
||||||
|
/// causes axum's `.layer()` to cross-apply between siblings on
|
||||||
|
/// shared prefixes — the login endpoints would then inherit
|
||||||
|
/// register's auth+CSRF middleware and 403 every unauthenticated
|
||||||
|
/// KE1. Separate prefixes side-step the composition rule cleanly.
|
||||||
pub fn opaque_register_routes() -> Router<Arc<AppState>> {
|
pub fn opaque_register_routes() -> Router<Arc<AppState>> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/opaque/register/start", post(register_start))
|
.route("/start", post(register_start))
|
||||||
.route("/opaque/register/finish", post(register_finish))
|
.route("/finish", post(register_finish))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Public (unauthenticated) OPAQUE login routes. Mount under
|
||||||
|
/// `/api/auth/opaque/login` with the same `rate_limit_login`
|
||||||
|
/// layer used on legacy `/api/auth/login` so an attacker can't
|
||||||
|
/// halve the per-identity budget by spraying both endpoints. See
|
||||||
|
/// [`opaque_register_routes`] on why the prefix must be distinct.
|
||||||
|
pub fn opaque_login_routes() -> Router<Arc<AppState>> {
|
||||||
|
Router::new()
|
||||||
|
.route("/ke1", post(login_ke1))
|
||||||
|
.route("/ke3", post(login_ke3))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Client → server on register KE1. `registrationRequest` is the
|
/// Client → server on register KE1. `registrationRequest` is the
|
||||||
@@ -266,8 +295,380 @@ pub async fn register_finish(
|
|||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Login: KE1 + KE3 ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Client → server on KE1. `userIdentifier` is the same string the
|
||||||
|
/// SPA sends to legacy `/api/auth/login` — email if it contains `@`,
|
||||||
|
/// username otherwise (server dispatches on `@`, same as legacy).
|
||||||
|
/// `startLoginRequest` is the base64-encoded output of
|
||||||
|
/// `ClientLogin::start(...).message`.
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
pub struct OpaqueLoginKe1Dto {
|
||||||
|
#[serde(rename = "userIdentifier")]
|
||||||
|
pub user_identifier: String,
|
||||||
|
#[serde(rename = "startLoginRequest")]
|
||||||
|
pub start_login_request: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Server → client on KE1. `exchangeId` is an opaque handle the
|
||||||
|
/// client MUST echo back on KE3 (single-use, 60 s TTL); the client
|
||||||
|
/// can't derive server state from it. `loginResponse` is the
|
||||||
|
/// base64-encoded output of `ServerLogin::start(...).message`.
|
||||||
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
|
pub struct OpaqueLoginKe1Response {
|
||||||
|
#[serde(rename = "exchangeId")]
|
||||||
|
#[schema(value_type = String, format = "uuid")]
|
||||||
|
pub exchange_id: ExchangeId,
|
||||||
|
#[serde(rename = "loginResponse")]
|
||||||
|
pub login_response: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Client → server on KE3. `exchangeId` matches what KE1 handed
|
||||||
|
/// back; `finishLoginRequest` is the base64-encoded output of
|
||||||
|
/// `ClientLogin::finish(...).message`.
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
pub struct OpaqueLoginKe3Dto {
|
||||||
|
#[serde(rename = "exchangeId")]
|
||||||
|
#[schema(value_type = String, format = "uuid")]
|
||||||
|
pub exchange_id: ExchangeId,
|
||||||
|
#[serde(rename = "finishLoginRequest")]
|
||||||
|
pub finish_login_request: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// KE1: user lookup → envelope fetch → `ServerLogin::start` → stash
|
||||||
|
/// state under a fresh exchange_id → return handle + response bytes.
|
||||||
|
///
|
||||||
|
/// ## Anti-enumeration
|
||||||
|
///
|
||||||
|
/// A KE1 that CAN'T be honestly answered (unknown user, or user
|
||||||
|
/// exists but has no OPAQUE envelope yet) MUST look identical to a
|
||||||
|
/// KE1 that CAN. opaque-ke's `ServerLogin::start` supports a "dummy"
|
||||||
|
/// branch (`password_file = None`) that generates a well-formed
|
||||||
|
/// KE2 response indistinguishable from the real branch. The
|
||||||
|
/// dummy-branch KE3 will fail at the client-side `ClientLogin::finish`
|
||||||
|
/// (wrong MAC), never reaching the server — so KE3 for the
|
||||||
|
/// non-existent user is symmetric with KE3 for a wrong passphrase.
|
||||||
|
///
|
||||||
|
/// User is NOT looked up via `_with_perms` — this is the auth
|
||||||
|
/// bootstrap; there's no caller identity yet. We use the same
|
||||||
|
/// dispatch as legacy `/api/auth/login`.
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/auth/opaque/login/ke1",
|
||||||
|
request_body = OpaqueLoginKe1Dto,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Login response + single-use exchange handle",
|
||||||
|
body = OpaqueLoginKe1Response),
|
||||||
|
(status = 400, description = "Malformed request payload"),
|
||||||
|
(status = 503, description = "OPAQUE service not configured"),
|
||||||
|
),
|
||||||
|
tag = "auth"
|
||||||
|
)]
|
||||||
|
pub async fn login_ke1(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Json(dto): Json<OpaqueLoginKe1Dto>,
|
||||||
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
|
let svc = require_opaque_service(&state)?;
|
||||||
|
let repo = require_opaque_repo(&state)?;
|
||||||
|
let exchange = require_opaque_exchange(&state)?;
|
||||||
|
|
||||||
|
let cred_bytes = B64.decode(dto.start_login_request.trim()).map_err(|_| {
|
||||||
|
malformed("startLoginRequest is not valid base64")
|
||||||
|
})?;
|
||||||
|
let cred_request = CredentialRequest::<OxiCloudSuite>::deserialize(&cred_bytes)
|
||||||
|
.map_err(|_| malformed("startLoginRequest failed to deserialize"))?;
|
||||||
|
|
||||||
|
// Resolve identifier → user_id, then fetch the envelope. Both
|
||||||
|
// steps can fail (unknown user, no envelope) — collapse into a
|
||||||
|
// single Option so `ServerLogin::start` sees the anti-enum
|
||||||
|
// shape cleanly.
|
||||||
|
let (user_bytes, password_file) = resolve_user_and_envelope(&state, &dto.user_identifier)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|| (dto.user_identifier.as_bytes().to_vec(), None));
|
||||||
|
|
||||||
|
// `ServerLogin::start(..., Some(file), ...)` runs the real
|
||||||
|
// handshake; `None` runs the dummy branch that still produces a
|
||||||
|
// well-formed KE2 tied to the same suite so a probing attacker
|
||||||
|
// can't distinguish the two paths from response shape or timing
|
||||||
|
// (opaque-ke pads the dummy branch to match).
|
||||||
|
let mut server_rng = OsRng;
|
||||||
|
let started = opaque_ke::ServerLogin::start(
|
||||||
|
&mut server_rng,
|
||||||
|
svc.setup(),
|
||||||
|
password_file,
|
||||||
|
cred_request,
|
||||||
|
&user_bytes,
|
||||||
|
ServerLoginStartParameters::default(),
|
||||||
|
)
|
||||||
|
.map_err(|e| {
|
||||||
|
// A start error at this stage is a genuine protocol failure
|
||||||
|
// (bad KE1 payload, ciphersuite mismatch in the wire bytes).
|
||||||
|
// We STILL respond 400 rather than 200-with-dummy — 200
|
||||||
|
// response would let the attacker distinguish "protocol
|
||||||
|
// error" from "unknown user"; the caller getting a 400 has
|
||||||
|
// to try a different KE1 anyway.
|
||||||
|
tracing::info!(
|
||||||
|
target: "audit",
|
||||||
|
event = "opaque.login_ke1_rejected",
|
||||||
|
reason = "server_start_error",
|
||||||
|
attempted_identifier = %dto.user_identifier,
|
||||||
|
error = %e,
|
||||||
|
"👮🏻♂️ OPAQUE KE1 rejected: protocol error"
|
||||||
|
);
|
||||||
|
AppError::new(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"OPAQUE login start failed",
|
||||||
|
"OpaqueMalformedRequest",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Silence unused-warning for repo when the branch above returns
|
||||||
|
// None — repo IS used inside `resolve_user_and_envelope` (via
|
||||||
|
// `state.opaque_repo`) but the closure hides that from the
|
||||||
|
// compiler's flow analysis.
|
||||||
|
let _ = &repo;
|
||||||
|
|
||||||
|
// Stash user_id alongside ServerLogin so KE3 knows which account
|
||||||
|
// just proved possession of the passphrase without having to
|
||||||
|
// re-parse the AKE payload (opaque-ke doesn't hand the identifier
|
||||||
|
// back on `finish` — it's checked implicitly against the KE1
|
||||||
|
// state's expected identifier).
|
||||||
|
let user_id = user_id_from_bytes(&user_bytes);
|
||||||
|
let response_b64 = B64.encode(started.message.serialize());
|
||||||
|
let exchange_id = exchange.store(started.state, user_id);
|
||||||
|
Ok(Json(OpaqueLoginKe1Response {
|
||||||
|
exchange_id,
|
||||||
|
login_response: response_b64,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// KE3: consume exchange state → `ServerLogin::finish` → mint session.
|
||||||
|
///
|
||||||
|
/// On success:
|
||||||
|
///
|
||||||
|
/// * Stamp `opaque_migrated_at` (Phase 3 signal that this user
|
||||||
|
/// has completed at least one OPAQUE login — future legacy
|
||||||
|
/// login attempts will be refused once Phase 4 flips to
|
||||||
|
/// `opaque_only`).
|
||||||
|
/// * Mint access + refresh tokens under a fresh session family,
|
||||||
|
/// via `AuthApplicationService::mint_session_for_authenticated_user`.
|
||||||
|
/// * Return the shared `AuthResponseDto` shape identical to
|
||||||
|
/// `POST /api/auth/login` — the SPA consumes both paths through
|
||||||
|
/// one downstream handler.
|
||||||
|
///
|
||||||
|
/// On failure (bad passphrase, replay, expired exchange, unknown
|
||||||
|
/// exchange_id): 401 `InvalidCredentials` — the SAME shape for every
|
||||||
|
/// failure branch so attackers can't distinguish "expired" from
|
||||||
|
/// "wrong password" from "id already consumed".
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/auth/opaque/login/ke3",
|
||||||
|
request_body = OpaqueLoginKe3Dto,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Session issued", body = AuthResponseDto),
|
||||||
|
(status = 400, description = "Malformed request payload"),
|
||||||
|
(status = 401, description = "Invalid credentials"),
|
||||||
|
(status = 503, description = "OPAQUE service not configured"),
|
||||||
|
),
|
||||||
|
tag = "auth"
|
||||||
|
)]
|
||||||
|
pub async fn login_ke3(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Json(dto): Json<OpaqueLoginKe3Dto>,
|
||||||
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
|
let _svc = require_opaque_service(&state)?;
|
||||||
|
let repo = require_opaque_repo(&state)?;
|
||||||
|
let exchange = require_opaque_exchange(&state)?;
|
||||||
|
let auth = require_auth_application_service(&state)?;
|
||||||
|
|
||||||
|
// Atomic take FIRST — before touching the payload. Two reasons:
|
||||||
|
//
|
||||||
|
// 1. Anti-enum: an unknown / expired / already-consumed
|
||||||
|
// exchange_id returns 401 `InvalidCredentials` regardless
|
||||||
|
// of payload shape, matching the wrong-passphrase case
|
||||||
|
// exactly. If we parsed the payload first, a malformed
|
||||||
|
// body would 400 EVEN for an unknown id — leaking the fact
|
||||||
|
// that some KE3 shapes are valid vs invalid.
|
||||||
|
// 2. Anti-replay: consuming the exchange first guarantees the
|
||||||
|
// handle is single-use even if the caller sends garbage
|
||||||
|
// afterwards — an attacker with a stolen id can't spam
|
||||||
|
// "try shapes until one parses" against the same handle.
|
||||||
|
let stash = exchange.take(dto.exchange_id).ok_or_else(|| {
|
||||||
|
tracing::info!(
|
||||||
|
target: "audit",
|
||||||
|
event = "opaque.login_ke3_rejected",
|
||||||
|
reason = "unknown_or_expired_exchange",
|
||||||
|
exchange_id = %dto.exchange_id,
|
||||||
|
"👮🏻♂️ OPAQUE KE3 rejected: unknown/expired/replayed exchange_id"
|
||||||
|
);
|
||||||
|
invalid_credentials()
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let cred_bytes = B64.decode(dto.finish_login_request.trim()).map_err(|_| {
|
||||||
|
malformed("finishLoginRequest is not valid base64")
|
||||||
|
})?;
|
||||||
|
let cred_final = CredentialFinalization::<OxiCloudSuite>::deserialize(&cred_bytes)
|
||||||
|
.map_err(|_| malformed("finishLoginRequest failed to deserialize"))?;
|
||||||
|
|
||||||
|
// If the AKE integrity check fails (wrong passphrase, dummy-branch
|
||||||
|
// KE1 for an unknown user, tampered bytes), `finish` errors and
|
||||||
|
// we return the same 401 shape as an unknown exchange_id above.
|
||||||
|
let _finished = stash.state.finish(cred_final).map_err(|e| {
|
||||||
|
tracing::info!(
|
||||||
|
target: "audit",
|
||||||
|
event = "opaque.login_ke3_rejected",
|
||||||
|
reason = "ake_check_failed",
|
||||||
|
exchange_id = %dto.exchange_id,
|
||||||
|
error = %e,
|
||||||
|
"👮🏻♂️ OPAQUE KE3 rejected: AKE / passphrase check failed"
|
||||||
|
);
|
||||||
|
invalid_credentials()
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Both sides now agree on a shared session_key. The current
|
||||||
|
// implementation does NOT derive the bearer token from it —
|
||||||
|
// reusing the existing token minter keeps the session shape
|
||||||
|
// identical to legacy login. Cryptographically tying the
|
||||||
|
// access_token to `session_key` via HKDF is a follow-up (see
|
||||||
|
// docs/plan/opaque.md §Step 5 notes).
|
||||||
|
//
|
||||||
|
// The user_id comes from the KE1-side stash (resolved from the
|
||||||
|
// client's identifier before the dummy-vs-real branch), not from
|
||||||
|
// the AKE payload. Anti-enum: the dummy branch has
|
||||||
|
// `stash.user_id = None`; a dummy KE3 that somehow reached this
|
||||||
|
// point (should not — it fails at `finish` above) returns the
|
||||||
|
// same InvalidCredentials shape.
|
||||||
|
let user_id = stash.user_id.ok_or_else(invalid_credentials)?;
|
||||||
|
|
||||||
|
// Fetch the user entity — needed by mint_session_for_authenticated_user
|
||||||
|
// (it calls dispatch_login + register_login + generates tokens
|
||||||
|
// from the user's role/email/etc.).
|
||||||
|
let user = auth
|
||||||
|
.get_user_entity(user_id)
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
// User row vanished between KE1's envelope fetch and now
|
||||||
|
// (delete race). Same shape as bad passphrase — never
|
||||||
|
// leak "you passed the crypto but the account is gone."
|
||||||
|
tracing::warn!(
|
||||||
|
target: "audit",
|
||||||
|
event = "opaque.login_ke3_rejected",
|
||||||
|
reason = "user_gone_after_ke3",
|
||||||
|
user_id = %user_id,
|
||||||
|
"👮🏻♂️ OPAQUE KE3: user disappeared between KE1 and KE3"
|
||||||
|
);
|
||||||
|
invalid_credentials()
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Mint the session BEFORE stamping opaque_migrated_at — if the
|
||||||
|
// session mint fails (rare, but not impossible under DB failure),
|
||||||
|
// we don't want to have flipped the migration flag for a user
|
||||||
|
// whose login didn't actually complete.
|
||||||
|
let session = auth
|
||||||
|
.mint_session_for_authenticated_user(user)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
|
if let Err(e) = repo.mark_migrated(user_id).await {
|
||||||
|
// Non-fatal: session is already issued, user is logged in.
|
||||||
|
// Log at warn so ops sees any consistent trend, but don't
|
||||||
|
// fail the response — the mark is idempotent so a later
|
||||||
|
// login will retry.
|
||||||
|
tracing::warn!(
|
||||||
|
target: "audit",
|
||||||
|
event = "opaque.mark_migrated_deferred",
|
||||||
|
user_id = %user_id,
|
||||||
|
error = %e,
|
||||||
|
"OPAQUE login succeeded but mark_migrated failed — will retry on next login"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
target: "audit",
|
||||||
|
event = "opaque.login_ok",
|
||||||
|
user_id = %user_id,
|
||||||
|
"OPAQUE login completed"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(Json(session))
|
||||||
|
}
|
||||||
|
|
||||||
// ── Small helpers ────────────────────────────────────────────────────
|
// ── Small helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn require_opaque_exchange(state: &Arc<AppState>) -> Result<Arc<OpaqueLoginExchange>, AppError> {
|
||||||
|
state.opaque_login_exchange.clone().ok_or_else(|| {
|
||||||
|
AppError::new(
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"OPAQUE login-exchange cache is not wired",
|
||||||
|
"OpaqueDisabled",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_auth_application_service(
|
||||||
|
state: &Arc<AppState>,
|
||||||
|
) -> Result<
|
||||||
|
Arc<crate::application::services::auth_application_service::AuthApplicationService>,
|
||||||
|
AppError,
|
||||||
|
> {
|
||||||
|
Ok(state
|
||||||
|
.auth_service
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| AppError::internal_error("Authentication service not configured"))?
|
||||||
|
.auth_application_service
|
||||||
|
.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve KE1's `userIdentifier` to the OPAQUE server-side user
|
||||||
|
/// identifier + the stored envelope. Returns `None` (silently)
|
||||||
|
/// when either the user doesn't exist or has no envelope on file —
|
||||||
|
/// both branches converge to the anti-enum dummy-KE1 path.
|
||||||
|
///
|
||||||
|
/// The tuple's first element (`Vec<u8>`) is what we pass to
|
||||||
|
/// `ServerLogin::start` as the OPAQUE user identifier. For real users
|
||||||
|
/// we use `user_id.as_bytes()` (matches the register handler); for
|
||||||
|
/// the unknown-user branch we hash the CLAIMED identifier so the
|
||||||
|
/// dummy branch's identifier bytes are still deterministic
|
||||||
|
/// per-attempt (opaque-ke uses this in the AKE derivation).
|
||||||
|
async fn resolve_user_and_envelope(
|
||||||
|
state: &Arc<AppState>,
|
||||||
|
identifier: &str,
|
||||||
|
) -> Option<(Vec<u8>, Option<ServerRegistration<OxiCloudSuite>>)> {
|
||||||
|
let auth = state.auth_service.as_ref()?;
|
||||||
|
let repo = state.opaque_repo.as_ref()?;
|
||||||
|
|
||||||
|
let user = auth
|
||||||
|
.auth_application_service
|
||||||
|
.lookup_user_for_login(identifier)
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
let stored = repo.read_registration(user.id()).await.ok().flatten();
|
||||||
|
let password_file =
|
||||||
|
stored.and_then(|s| ServerRegistration::<OxiCloudSuite>::deserialize(&s.envelope).ok());
|
||||||
|
|
||||||
|
Some((user.id().as_bytes().to_vec(), password_file))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recover a UUID from the OPAQUE user identifier bytes IF they're
|
||||||
|
/// the 16-byte shape written by `resolve_user_and_envelope`. For the
|
||||||
|
/// dummy branch (identifier = raw caller-supplied string), the bytes
|
||||||
|
/// are almost never 16 long, so this returns `None` — that's the
|
||||||
|
/// correct signal to KE1's callers ("don't stamp a user_id in the
|
||||||
|
/// stash for the dummy branch").
|
||||||
|
fn user_id_from_bytes(bytes: &[u8]) -> Option<Uuid> {
|
||||||
|
let arr: [u8; 16] = bytes.try_into().ok()?;
|
||||||
|
Some(Uuid::from_bytes(arr))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid_credentials() -> AppError {
|
||||||
|
AppError::new(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"Invalid credentials",
|
||||||
|
"InvalidCredentials",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn require_opaque_service(state: &Arc<AppState>) -> Result<Arc<OpaqueService>, AppError> {
|
fn require_opaque_service(state: &Arc<AppState>) -> Result<Arc<OpaqueService>, AppError> {
|
||||||
state.opaque_service.clone().ok_or_else(|| {
|
state.opaque_service.clone().ok_or_else(|| {
|
||||||
AppError::new(
|
AppError::new(
|
||||||
|
|||||||
+27
-8
@@ -790,11 +790,16 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
auth_middleware,
|
auth_middleware,
|
||||||
))
|
))
|
||||||
.with_state(app_state.clone());
|
.with_state(app_state.clone());
|
||||||
// OPAQUE register routes — require auth + CSRF. The handlers
|
// OPAQUE aPAKE routes — nested under DISTINCT sub-prefixes
|
||||||
// return 503 `OpaqueDisabled` when the substrate isn't wired
|
// so axum doesn't cross-apply middleware between the two
|
||||||
// (mode=off or password auth disabled), so mounting them
|
// branches (`.nest("/api/auth", A).nest("/api/auth", B)`
|
||||||
// unconditionally is safe: the mode gate lives in the DI
|
// composes their layers on shared prefixes; distinct
|
||||||
// factory, not the router.
|
// prefixes avoid that entirely).
|
||||||
|
//
|
||||||
|
// Handlers return 503 `OpaqueDisabled` when the substrate
|
||||||
|
// isn't wired (mode=off / password auth disabled); the mode
|
||||||
|
// gate lives in the DI factory, so mounting unconditionally
|
||||||
|
// is safe.
|
||||||
let opaque_register_protected =
|
let opaque_register_protected =
|
||||||
oxicloud::interfaces::api::handlers::opaque_auth_handler::opaque_register_routes()
|
oxicloud::interfaces::api::handlers::opaque_auth_handler::opaque_register_routes()
|
||||||
.layer(axum::middleware::from_fn(csrf_middleware))
|
.layer(axum::middleware::from_fn(csrf_middleware))
|
||||||
@@ -803,6 +808,13 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
auth_middleware,
|
auth_middleware,
|
||||||
))
|
))
|
||||||
.with_state(app_state.clone());
|
.with_state(app_state.clone());
|
||||||
|
let opaque_login_public =
|
||||||
|
oxicloud::interfaces::api::handlers::opaque_auth_handler::opaque_login_routes()
|
||||||
|
.layer(axum::middleware::from_fn_with_state(
|
||||||
|
login_limiter.clone(),
|
||||||
|
rate_limit_login,
|
||||||
|
))
|
||||||
|
.with_state(app_state.clone());
|
||||||
// One-time setup route — public, rate-limited like register
|
// One-time setup route — public, rate-limited like register
|
||||||
let setup_router = setup_route()
|
let setup_router = setup_route()
|
||||||
.layer(axum::middleware::from_fn_with_state(
|
.layer(axum::middleware::from_fn_with_state(
|
||||||
@@ -909,12 +921,19 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
"/api/auth",
|
"/api/auth",
|
||||||
app_pw_protected.layer(access_log!("http::api::auth")),
|
app_pw_protected.layer(access_log!("http::api::auth")),
|
||||||
)
|
)
|
||||||
// OPAQUE aPAKE — session-required register endpoints. Login
|
// OPAQUE aPAKE — session-required register endpoints
|
||||||
// endpoints (public) are mounted in a later Phase 1 step.
|
// (mounted under a distinct sub-prefix so auth+CSRF
|
||||||
|
// don't bleed into the sibling login mount).
|
||||||
.nest(
|
.nest(
|
||||||
"/api/auth",
|
"/api/auth/opaque/register",
|
||||||
opaque_register_protected.layer(access_log!("http::api::auth")),
|
opaque_register_protected.layer(access_log!("http::api::auth")),
|
||||||
)
|
)
|
||||||
|
// OPAQUE aPAKE — public login endpoints (KE1 + KE3).
|
||||||
|
// Rate-limit shared with legacy login above.
|
||||||
|
.nest(
|
||||||
|
"/api/auth/opaque/login",
|
||||||
|
opaque_login_public.layer(access_log!("http::api::auth")),
|
||||||
|
)
|
||||||
// One-time setup endpoint — public, rate-limited
|
// One-time setup endpoint — public, rate-limited
|
||||||
.nest("/api", setup_router.layer(access_log!("http::api")))
|
.nest("/api", setup_router.layer(access_log!("http::api")))
|
||||||
// Device Auth Grant public endpoints (authorize + token polling)
|
// Device Auth Grant public endpoints (authorize + token polling)
|
||||||
|
|||||||
@@ -56,24 +56,12 @@ Content-Type: application/json
|
|||||||
HTTP 401
|
HTTP 401
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
# Case 3 — Login KE1 endpoint not routed (401 anti-enum).
|
# NOTE — The Phase 0 "login endpoints 401 anti-enum" cases were
|
||||||
# Will flip to 400 in Phase 1 (public + malformed body).
|
# retired at Phase 1 landing. KE1 / KE3 are now routed (public,
|
||||||
|
# rate-limited). Cases 7 & 8 below assert the Phase 1 shape:
|
||||||
|
# KE1 400 `OpaqueMalformedRequest` on bad body, KE3 401
|
||||||
|
# `InvalidCredentials` on unknown exchange_id.
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
POST {{base_url}}/api/auth/opaque/login/ke1
|
|
||||||
Content-Type: application/json
|
|
||||||
{ "userIdentifier": "{{username}}", "startLoginRequest": "unused-phase-0" }
|
|
||||||
|
|
||||||
HTTP 401
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────
|
|
||||||
# Case 4 — Login KE3 endpoint not routed (401 anti-enum).
|
|
||||||
# Will flip to 400 in Phase 1 (public + malformed body).
|
|
||||||
# ─────────────────────────────────────────────────────────────
|
|
||||||
POST {{base_url}}/api/auth/opaque/login/ke3
|
|
||||||
Content-Type: application/json
|
|
||||||
{ "exchangeId": "unused-phase-0", "finishLoginRequest": "unused-phase-0" }
|
|
||||||
|
|
||||||
HTTP 401
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================
|
# =============================================================
|
||||||
@@ -143,3 +131,48 @@ Content-Type: application/json
|
|||||||
HTTP 400
|
HTTP 400
|
||||||
[Asserts]
|
[Asserts]
|
||||||
jsonpath "$.error_type" == "OpaqueCiphersuiteMismatch"
|
jsonpath "$.error_type" == "OpaqueCiphersuiteMismatch"
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================
|
||||||
|
# Phase 1 — Login endpoints (KE1 / KE3, public + rate-limited)
|
||||||
|
# =============================================================
|
||||||
|
# The KE1 / KE3 endpoints are public — no session required, no
|
||||||
|
# CSRF (bearer/basic exempt anyway). Rate-limited by the same
|
||||||
|
# per-IP budget as legacy `/api/auth/login` so an attacker can't
|
||||||
|
# double their guessing rate by spraying both endpoints.
|
||||||
|
#
|
||||||
|
# The full crypto handshake (real opaque-ke bytes) is proved in
|
||||||
|
# the Rust integration test (in-process, no HTTP). What Hurl
|
||||||
|
# covers here is the wire wiring: routing exists, error paths
|
||||||
|
# fire with the stable error_type contract.
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Case 7 — KE1 with garbage base64 → 400 OpaqueMalformedRequest.
|
||||||
|
# Proves the endpoint is publicly reachable (no auth
|
||||||
|
# required — no 401), the JSON body is parsed, and the
|
||||||
|
# malformed-base64 error path is stable.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
POST {{base_url}}/api/auth/opaque/login/ke1
|
||||||
|
Content-Type: application/json
|
||||||
|
{ "userIdentifier": "{{username}}", "startLoginRequest": "not-valid-base64!" }
|
||||||
|
|
||||||
|
HTTP 400
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.error_type" == "OpaqueMalformedRequest"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Case 8 — KE3 with an unknown exchange_id → 401
|
||||||
|
# InvalidCredentials. The `exchange_id` handle is
|
||||||
|
# single-use and 60s-TTL; unknown ids MUST return the
|
||||||
|
# SAME error shape as a wrong-passphrase failure so
|
||||||
|
# attackers can't distinguish "id expired" from
|
||||||
|
# "wrong password" from "id already consumed".
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
POST {{base_url}}/api/auth/opaque/login/ke3
|
||||||
|
Content-Type: application/json
|
||||||
|
{ "exchangeId": "00000000-0000-0000-0000-000000000000", "finishLoginRequest": "AAAA" }
|
||||||
|
|
||||||
|
HTTP 401
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.error_type" == "InvalidCredentials"
|
||||||
|
|||||||
Reference in New Issue
Block a user