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:
+27
-8
@@ -790,11 +790,16 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
auth_middleware,
|
||||
))
|
||||
.with_state(app_state.clone());
|
||||
// OPAQUE register routes — require auth + CSRF. The handlers
|
||||
// return 503 `OpaqueDisabled` when the substrate isn't wired
|
||||
// (mode=off or password auth disabled), so mounting them
|
||||
// unconditionally is safe: the mode gate lives in the DI
|
||||
// factory, not the router.
|
||||
// OPAQUE aPAKE routes — nested under DISTINCT sub-prefixes
|
||||
// so axum doesn't cross-apply middleware between the two
|
||||
// branches (`.nest("/api/auth", A).nest("/api/auth", B)`
|
||||
// composes their layers on shared prefixes; distinct
|
||||
// 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 =
|
||||
oxicloud::interfaces::api::handlers::opaque_auth_handler::opaque_register_routes()
|
||||
.layer(axum::middleware::from_fn(csrf_middleware))
|
||||
@@ -803,6 +808,13 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
auth_middleware,
|
||||
))
|
||||
.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
|
||||
let setup_router = setup_route()
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
@@ -909,12 +921,19 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
"/api/auth",
|
||||
app_pw_protected.layer(access_log!("http::api::auth")),
|
||||
)
|
||||
// OPAQUE aPAKE — session-required register endpoints. Login
|
||||
// endpoints (public) are mounted in a later Phase 1 step.
|
||||
// OPAQUE aPAKE — session-required register endpoints
|
||||
// (mounted under a distinct sub-prefix so auth+CSRF
|
||||
// don't bleed into the sibling login mount).
|
||||
.nest(
|
||||
"/api/auth",
|
||||
"/api/auth/opaque/register",
|
||||
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
|
||||
.nest("/api", setup_router.layer(access_log!("http::api")))
|
||||
// Device Auth Grant public endpoints (authorize + token polling)
|
||||
|
||||
Reference in New Issue
Block a user