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
|
||||
/// (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
|
||||
|
||||
Reference in New Issue
Block a user