diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 925e076..3fcce1d 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.124.0" +version = "0.124.1" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 50aea91..3c7f1d8 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.124.0" +version = "0.124.1" edition = "2021" default-run = "mangalord" diff --git a/backend/src/api/admin/users.rs b/backend/src/api/admin/users.rs index 171cf07..3094e1f 100644 --- a/backend/src/api/admin/users.rs +++ b/backend/src/api/admin/users.rs @@ -16,7 +16,7 @@ use crate::api::auth::{validate_password, validate_username}; use crate::api::pagination::PagedResponse; use crate::app::AppState; use crate::auth::extractor::RequireAdmin; -use crate::auth::password::hash_password; +use crate::auth::password::hash_password_async; use crate::domain::User; use crate::error::{AppError, AppResult}; use crate::repo; @@ -115,7 +115,7 @@ async fn create_user( // reject (and vice versa). validate_username(username)?; validate_password(&input.password)?; - let pwhash = hash_password(&input.password)?; + let pwhash = hash_password_async(input.password.clone()).await?; let user = repo::user::admin_create_user( &state.db, actor.id, diff --git a/backend/src/api/auth.rs b/backend/src/api/auth.rs index e4cf745..4f5d615 100644 --- a/backend/src/api/auth.rs +++ b/backend/src/api/auth.rs @@ -18,7 +18,7 @@ use uuid::Uuid; use crate::app::AppState; use crate::auth::extractor::{CurrentUser, SESSION_COOKIE_NAME}; -use crate::auth::password::{hash_password, verify_password}; +use crate::auth::password::{hash_password_async, verify_password_async}; use crate::auth::token::{generate_token, hash_token}; use crate::config::AuthConfig; use crate::domain::user_preferences::{READER_GAPS, READER_MODES}; @@ -125,7 +125,7 @@ async fn register( validate_username(username)?; validate_password(&input.password)?; - let pwhash = hash_password(&input.password)?; + let pwhash = hash_password_async(input.password.clone()).await?; let user = repo::user::create(&state.db, username, &pwhash).await?; let jar = start_session(&state, &user, jar).await?; Ok((StatusCode::CREATED, jar, Json(AuthResponse { user }))) @@ -154,10 +154,11 @@ async fn login( // response time matches the wrong-password branch — otherwise // an attacker can enumerate usernames by timing the no-user // 401 against the wrong-password 401. - let _ = verify_password(&input.password, dummy_password_hash()); + let _ = verify_password_async(input.password.clone(), dummy_password_hash().to_string()) + .await; return Err(AppError::Unauthenticated); }; - if !verify_password(&input.password, &user.password_hash) { + if !verify_password_async(input.password.clone(), user.password_hash.clone()).await { return Err(AppError::Unauthenticated); } @@ -220,12 +221,12 @@ async fn change_password( // Cap current_password before verify_password runs argon2 (same DoS // vector as login). new_password is bounded by validate_password below. reject_oversized_password(&input.current_password)?; - if !verify_password(&input.current_password, &user.password_hash) { + if !verify_password_async(input.current_password.clone(), user.password_hash.clone()).await { return Err(AppError::Unauthenticated); } validate_password(&input.new_password)?; - let new_hash = hash_password(&input.new_password)?; + let new_hash = hash_password_async(input.new_password.clone()).await?; let mut tx = state.db.begin().await?; sqlx::query("UPDATE users SET password_hash = $1 WHERE id = $2") diff --git a/backend/src/auth/password.rs b/backend/src/auth/password.rs index 7afd308..139c140 100644 --- a/backend/src/auth/password.rs +++ b/backend/src/auth/password.rs @@ -28,6 +28,25 @@ pub fn verify_password(plain: &str, phc: &str) -> bool { .is_ok() } +/// Async wrapper around [`hash_password`] that offloads the CPU- and +/// memory-heavy Argon2 work to the blocking pool. Async handlers must call +/// this rather than the sync primitive: at ~15-50 ms per hash, running it +/// inline stalls every other task sharing the runtime worker thread. +pub async fn hash_password_async(plain: String) -> AppResult { + tokio::task::spawn_blocking(move || hash_password(&plain)) + .await + .map_err(|e| AppError::Other(anyhow::anyhow!("password hash task join: {e}")))? +} + +/// Async wrapper around [`verify_password`]. A join failure (blocking pool +/// gone at shutdown, panic) resolves to `false` — the caller only ever uses +/// the result as a boolean gate, and denying auth is the safe default. +pub async fn verify_password_async(plain: String, phc: String) -> bool { + tokio::task::spawn_blocking(move || verify_password(&plain, &phc)) + .await + .unwrap_or(false) +} + #[cfg(test)] mod tests { use super::*; @@ -56,4 +75,24 @@ mod tests { let b = hash_password("same").unwrap(); assert_ne!(a, b, "two hashes of the same password must differ (salt)"); } + + #[tokio::test] + async fn async_hash_then_verify_roundtrip() { + let phc = hash_password_async("correct horse battery staple".to_string()) + .await + .unwrap(); + assert!(phc.starts_with("$argon2id$")); + assert!(verify_password_async("correct horse battery staple".to_string(), phc).await); + } + + #[tokio::test] + async fn async_verify_rejects_wrong_password() { + let phc = hash_password_async("hunter2".to_string()).await.unwrap(); + assert!(!verify_password_async("hunter3".to_string(), phc).await); + } + + #[tokio::test] + async fn async_verify_rejects_malformed_hash() { + assert!(!verify_password_async("anything".to_string(), "not a real phc".to_string()).await); + } } diff --git a/backend/src/repo/user.rs b/backend/src/repo/user.rs index d4acd25..caa7be8 100644 --- a/backend/src/repo/user.rs +++ b/backend/src/repo/user.rs @@ -381,7 +381,7 @@ pub async fn bootstrap_admin( .await?; } None => { - let hash = crate::auth::password::hash_password(password)?; + let hash = crate::auth::password::hash_password_async(password.to_string()).await?; sqlx::query("INSERT INTO users (username, password_hash, is_admin) VALUES ($1, $2, true)") .bind(username) .bind(&hash) diff --git a/frontend/package.json b/frontend/package.json index 48e3967..a47750d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.124.0", + "version": "0.124.1", "private": true, "type": "module", "scripts": {