feat(opaque): add change password

This commit is contained in:
Edouard Vanbelle
2026-07-28 00:18:53 +02:00
parent 94c6121f3b
commit bd8384c9b5
6 changed files with 223 additions and 54 deletions
@@ -468,41 +468,41 @@ mod integration_tests {
#[tokio::test]
async fn mark_migrated_stamps_once_and_is_idempotent() {
let repo = test_repo().await;
let user =
seed_user(&repo, &format!("opaque-mig-{}@example.invalid", Uuid::new_v4())).await;
let user = seed_user(
&repo,
&format!("opaque-mig-{}@example.invalid", Uuid::new_v4()),
)
.await;
// Read the initial NULL state
let initial: (Option<chrono::DateTime<chrono::Utc>>,) = sqlx::query_as(
"SELECT opaque_migrated_at FROM auth.users WHERE id = $1",
)
.bind(user)
.fetch_one(repo.pool())
.await
.unwrap();
let initial: (Option<chrono::DateTime<chrono::Utc>>,) =
sqlx::query_as("SELECT opaque_migrated_at FROM auth.users WHERE id = $1")
.bind(user)
.fetch_one(repo.pool())
.await
.unwrap();
assert!(
initial.0.is_none(),
"new user starts with no opaque_migrated_at"
);
repo.mark_migrated(user).await.expect("first mark");
let first: (Option<chrono::DateTime<chrono::Utc>>,) = sqlx::query_as(
"SELECT opaque_migrated_at FROM auth.users WHERE id = $1",
)
.bind(user)
.fetch_one(repo.pool())
.await
.unwrap();
let first: (Option<chrono::DateTime<chrono::Utc>>,) =
sqlx::query_as("SELECT opaque_migrated_at FROM auth.users WHERE id = $1")
.bind(user)
.fetch_one(repo.pool())
.await
.unwrap();
let first_ts = first.0.expect("timestamp set after first mark");
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
repo.mark_migrated(user).await.expect("second mark");
let second: (Option<chrono::DateTime<chrono::Utc>>,) = sqlx::query_as(
"SELECT opaque_migrated_at FROM auth.users WHERE id = $1",
)
.bind(user)
.fetch_one(repo.pool())
.await
.unwrap();
let second: (Option<chrono::DateTime<chrono::Utc>>,) =
sqlx::query_as("SELECT opaque_migrated_at FROM auth.users WHERE id = $1")
.bind(user)
.fetch_one(repo.pool())
.await
.unwrap();
assert_eq!(
second.0.unwrap(),
first_ts,
@@ -256,7 +256,10 @@ mod tests {
let id = cache.store(state, Some(Uuid::new_v4()));
// Second call after take must miss — single-use semantic.
let taken = cache.take(id).expect("first take retrieves the state");
assert!(taken.user_id.is_some(), "user_id round-trips through the stash");
assert!(
taken.user_id.is_some(),
"user_id round-trips through the stash"
);
assert!(
cache.take(id).is_none(),
"second take must miss — exchange_id is single-use"
@@ -306,6 +309,9 @@ mod tests {
let cache = OpaqueLoginExchange::new();
let id = cache.store(build_server_login_state(), None);
let taken = cache.take(id).expect("take dummy stash");
assert!(taken.user_id.is_none(), "dummy-branch stash carries no user_id");
assert!(
taken.user_id.is_none(),
"dummy-branch stash carries no user_id"
);
}
}
@@ -76,9 +76,7 @@ use uuid::Uuid;
use crate::application::dtos::user_dto::AuthResponseDto;
use crate::common::di::AppState;
use crate::infrastructure::services::opaque_login_exchange::{
ExchangeId, OpaqueLoginExchange,
};
use crate::infrastructure::services::opaque_login_exchange::{ExchangeId, OpaqueLoginExchange};
use crate::infrastructure::services::opaque_service::{OpaqueService, OxiCloudSuite};
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::CurrentUserId;
@@ -381,9 +379,9 @@ pub async fn login_ke1(
let repo = require_opaque_repo(&state)?;
let exchange = require_opaque_exchange(&state)?;
let cred_bytes = B64.decode(dto.start_login_request.trim()).map_err(|_| {
malformed("startLoginRequest is not valid base64")
})?;
let cred_bytes = B64
.decode(dto.start_login_request.trim())
.map_err(|_| malformed("startLoginRequest is not valid base64"))?;
let cred_request = CredentialRequest::<OxiCloudSuite>::deserialize(&cred_bytes)
.map_err(|_| malformed("startLoginRequest failed to deserialize"))?;
@@ -513,9 +511,9 @@ pub async fn login_ke3(
invalid_credentials()
})?;
let cred_bytes = B64.decode(dto.finish_login_request.trim()).map_err(|_| {
malformed("finishLoginRequest is not valid base64")
})?;
let cred_bytes = B64
.decode(dto.finish_login_request.trim())
.map_err(|_| malformed("finishLoginRequest is not valid base64"))?;
let cred_final = CredentialFinalization::<OxiCloudSuite>::deserialize(&cred_bytes)
.map_err(|_| malformed("finishLoginRequest failed to deserialize"))?;
@@ -552,22 +550,19 @@ pub async fn login_ke3(
// Fetch the user entity — needed by mint_session_for_authenticated_user
// (it calls dispatch_login + register_login + generates tokens
// from the user's role/email/etc.).
let user = auth
.get_user_entity(user_id)
.await
.map_err(|_| {
// User row vanished between KE1's envelope fetch and now
// (delete race). Same shape as bad passphrase — never
// leak "you passed the crypto but the account is gone."
tracing::warn!(
target: "audit",
event = "opaque.login_ke3_rejected",
reason = "user_gone_after_ke3",
user_id = %user_id,
"👮🏻‍♂️ OPAQUE KE3: user disappeared between KE1 and KE3"
);
invalid_credentials()
})?;
let user = auth.get_user_entity(user_id).await.map_err(|_| {
// User row vanished between KE1's envelope fetch and now
// (delete race). Same shape as bad passphrase — never
// leak "you passed the crypto but the account is gone."
tracing::warn!(
target: "audit",
event = "opaque.login_ke3_rejected",
reason = "user_gone_after_ke3",
user_id = %user_id,
"👮🏻‍♂️ OPAQUE KE3: user disappeared between KE1 and KE3"
);
invalid_credentials()
})?;
// Mint the session BEFORE stamping opaque_migrated_at — if the
// session mint fails (rare, but not impossible under DB failure),
@@ -653,9 +648,7 @@ pub struct OpaqueParamsResponse {
),
tag = "auth"
)]
pub async fn opaque_params(
State(state): State<Arc<AppState>>,
) -> impl IntoResponse {
pub async fn opaque_params(State(state): State<Arc<AppState>>) -> impl IntoResponse {
// Reads from OpaqueService when substrate is wired; falls back
// to the OpaqueConfig defaults otherwise so an
// `enabled=false` payload still has plausible-shape numeric