fix: run Argon2 password hashing on the blocking pool
Argon2id hashing (~15-50ms, 19 MiB) ran synchronously inside async handlers, stalling every task sharing those runtime worker threads under concurrent auth load. Add spawn_blocking wrappers hash_password_async / verify_password_async and switch all async call sites; sync primitives stay for the login timing-equaliser and unit tests. Bump to 0.124.1. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.124.0"
|
version = "0.124.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.124.0"
|
version = "0.124.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "mangalord"
|
default-run = "mangalord"
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use crate::api::auth::{validate_password, validate_username};
|
|||||||
use crate::api::pagination::PagedResponse;
|
use crate::api::pagination::PagedResponse;
|
||||||
use crate::app::AppState;
|
use crate::app::AppState;
|
||||||
use crate::auth::extractor::RequireAdmin;
|
use crate::auth::extractor::RequireAdmin;
|
||||||
use crate::auth::password::hash_password;
|
use crate::auth::password::hash_password_async;
|
||||||
use crate::domain::User;
|
use crate::domain::User;
|
||||||
use crate::error::{AppError, AppResult};
|
use crate::error::{AppError, AppResult};
|
||||||
use crate::repo;
|
use crate::repo;
|
||||||
@@ -115,7 +115,7 @@ async fn create_user(
|
|||||||
// reject (and vice versa).
|
// reject (and vice versa).
|
||||||
validate_username(username)?;
|
validate_username(username)?;
|
||||||
validate_password(&input.password)?;
|
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(
|
let user = repo::user::admin_create_user(
|
||||||
&state.db,
|
&state.db,
|
||||||
actor.id,
|
actor.id,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::app::AppState;
|
use crate::app::AppState;
|
||||||
use crate::auth::extractor::{CurrentUser, SESSION_COOKIE_NAME};
|
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::auth::token::{generate_token, hash_token};
|
||||||
use crate::config::AuthConfig;
|
use crate::config::AuthConfig;
|
||||||
use crate::domain::user_preferences::{READER_GAPS, READER_MODES};
|
use crate::domain::user_preferences::{READER_GAPS, READER_MODES};
|
||||||
@@ -125,7 +125,7 @@ async fn register(
|
|||||||
validate_username(username)?;
|
validate_username(username)?;
|
||||||
validate_password(&input.password)?;
|
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 user = repo::user::create(&state.db, username, &pwhash).await?;
|
||||||
let jar = start_session(&state, &user, jar).await?;
|
let jar = start_session(&state, &user, jar).await?;
|
||||||
Ok((StatusCode::CREATED, jar, Json(AuthResponse { user })))
|
Ok((StatusCode::CREATED, jar, Json(AuthResponse { user })))
|
||||||
@@ -154,10 +154,11 @@ async fn login(
|
|||||||
// response time matches the wrong-password branch — otherwise
|
// response time matches the wrong-password branch — otherwise
|
||||||
// an attacker can enumerate usernames by timing the no-user
|
// an attacker can enumerate usernames by timing the no-user
|
||||||
// 401 against the wrong-password 401.
|
// 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);
|
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);
|
return Err(AppError::Unauthenticated);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,12 +221,12 @@ async fn change_password(
|
|||||||
// Cap current_password before verify_password runs argon2 (same DoS
|
// Cap current_password before verify_password runs argon2 (same DoS
|
||||||
// vector as login). new_password is bounded by validate_password below.
|
// vector as login). new_password is bounded by validate_password below.
|
||||||
reject_oversized_password(&input.current_password)?;
|
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);
|
return Err(AppError::Unauthenticated);
|
||||||
}
|
}
|
||||||
validate_password(&input.new_password)?;
|
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?;
|
let mut tx = state.db.begin().await?;
|
||||||
sqlx::query("UPDATE users SET password_hash = $1 WHERE id = $2")
|
sqlx::query("UPDATE users SET password_hash = $1 WHERE id = $2")
|
||||||
|
|||||||
@@ -28,6 +28,25 @@ pub fn verify_password(plain: &str, phc: &str) -> bool {
|
|||||||
.is_ok()
|
.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<String> {
|
||||||
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -56,4 +75,24 @@ mod tests {
|
|||||||
let b = hash_password("same").unwrap();
|
let b = hash_password("same").unwrap();
|
||||||
assert_ne!(a, b, "two hashes of the same password must differ (salt)");
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -381,7 +381,7 @@ pub async fn bootstrap_admin(
|
|||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
None => {
|
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)")
|
sqlx::query("INSERT INTO users (username, password_hash, is_admin) VALUES ($1, $2, true)")
|
||||||
.bind(username)
|
.bind(username)
|
||||||
.bind(&hash)
|
.bind(&hash)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.124.0",
|
"version": "0.124.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Reference in New Issue
Block a user