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<()>;
}
@@ -712,7 +712,7 @@ impl AuthApplicationService {
} else {
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
// stable so log search can aggregate without parsing the
// 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
// observing `last_login_at().is_none()` see "first ever login"
// 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
}
/// 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}`.
///
/// Returns `NotFound` (not `AccessDenied`) when the caller has no