7d7621e387
- 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
106 lines
4.8 KiB
Rust
106 lines
4.8 KiB
Rust
//! Outbound port for OPAQUE aPAKE envelope persistence.
|
|
//!
|
|
//! The registration record (encrypted "envelope" blob) and its
|
|
//! metadata live in three columns on `auth.users` — introduced by the
|
|
//! Phase 0 migration (`20260926000000_auth_opaque.sql`). This trait
|
|
//! wraps the row-level access so:
|
|
//!
|
|
//! * the OPAQUE handlers (Phase 1+) depend on a small, mockable
|
|
//! interface rather than a `PgPool`,
|
|
//! * unit tests can drive envelope reads/writes without a live DB,
|
|
//! * a future E2EE-phase migration can slot in per-device bridges
|
|
//! alongside this trait without disturbing the OPAQUE auth path.
|
|
//!
|
|
//! The trait is deliberately narrow — only what the OPAQUE
|
|
//! registration + login flows need. Anything else that touches
|
|
//! `auth.users` still goes through [`UserStoragePort`].
|
|
//!
|
|
//! ## `clear_registration` and `force_password_change_at_next_login`
|
|
//!
|
|
//! When an operator resets a user's password (Phase 4+ admin flow), we
|
|
//! need to invalidate the existing OPAQUE envelope AND force the user
|
|
//! to pick a new passphrase on their next login — otherwise the
|
|
//! admin-set password becomes a durable credential. [`clear_registration`]
|
|
//! does both in one round-trip: NULLs the four OPAQUE columns AND
|
|
//! sets `force_password_change_at_next_login = TRUE`. Individual
|
|
//! callers should NOT set that flag independently to avoid drift
|
|
//! between the two writes.
|
|
|
|
use async_trait::async_trait;
|
|
use chrono::{DateTime, Utc};
|
|
use uuid::Uuid;
|
|
|
|
use crate::common::errors::Result;
|
|
|
|
/// Server-stored OPAQUE registration record for one user.
|
|
///
|
|
/// Rebuilt on every read from three columns: the envelope blob, the
|
|
/// ciphersuite version it was minted under, and the first-registration
|
|
/// timestamp. `opaque_migrated_at` is intentionally NOT here — it's a
|
|
/// login-time signal, not registration state.
|
|
#[derive(Debug, Clone)]
|
|
pub struct StoredEnvelope {
|
|
/// Serialised `opaque_ke::RegistrationUpload` payload. Server-opaque;
|
|
/// only the client with the correct passphrase can use it to complete
|
|
/// the login handshake.
|
|
pub envelope: Vec<u8>,
|
|
/// The ciphersuite version this envelope was minted under. Handlers
|
|
/// compare against
|
|
/// [`OpaqueService::ciphersuite_version`](crate::infrastructure::services::opaque_service::OpaqueService::ciphersuite_version)
|
|
/// and refuse login with a specific `error_type` when they diverge
|
|
/// — the client must re-register under the current suite.
|
|
pub ciphersuite_version: i16,
|
|
/// When this account first minted an OPAQUE envelope. Preserved
|
|
/// across re-registrations (password changes) via a NULL check in
|
|
/// [`OpaqueRepositoryPort::write_registration`].
|
|
pub registered_at: DateTime<Utc>,
|
|
}
|
|
|
|
/// Secondary (outbound) port for OPAQUE envelope persistence.
|
|
///
|
|
/// Concrete impl lives in
|
|
/// [`crate::infrastructure::repositories::pg::opaque_pg_repository`].
|
|
#[cfg_attr(feature = "test_utils", mockall::automock)]
|
|
#[async_trait]
|
|
pub trait OpaqueRepositoryPort: Send + Sync + 'static {
|
|
/// Write (or overwrite) the OPAQUE registration for `user_id`.
|
|
///
|
|
/// Idempotent w.r.t. `opaque_registered_at`: the first-registration
|
|
/// timestamp is preserved across re-registrations. Only the
|
|
/// envelope + ciphersuite_version rotate on password change.
|
|
///
|
|
/// Does NOT touch `opaque_migrated_at` — that's flipped by the
|
|
/// login endpoint after the first successful OPAQUE handshake.
|
|
async fn write_registration(
|
|
&self,
|
|
user_id: Uuid,
|
|
envelope: &[u8],
|
|
ciphersuite_version: i16,
|
|
) -> Result<()>;
|
|
|
|
/// Read the current envelope for `user_id`. Returns `None` when
|
|
/// the user has no OPAQUE registration (Phase 0 default, or
|
|
/// account was cleared by the admin reset flow).
|
|
async fn read_registration(&self, user_id: Uuid) -> Result<Option<StoredEnvelope>>;
|
|
|
|
/// Invalidate the OPAQUE registration for `user_id` and stamp the
|
|
/// force-change-at-next-login flag in one transaction. Used by
|
|
/// admin-side password reset (Phase 4+) — see the module-level
|
|
/// note above for why the flag is co-located with the clear.
|
|
///
|
|
/// Idempotent: clearing an already-empty registration is a no-op
|
|
/// 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<()>;
|
|
}
|