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:
Edouard Vanbelle
2026-07-27 22:23:15 +02:00
parent ae65c8475f
commit 7d7621e387
7 changed files with 696 additions and 49 deletions
+10
View File
@@ -92,4 +92,14 @@ pub trait OpaqueRepositoryPort: Send + Sync + 'static {
/// on the envelope columns but STILL sets the force-change flag
/// (that's the point of the admin call).
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<()>;
}