Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 99 additions & 12 deletions dwctl/src/api/handlers/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,12 @@ pub async fn register(State(state): State<AppState>, Json(request): Json<Registe

// Hash the password on a blocking thread to avoid blocking async runtime
let password = request.password.clone();
let password_hash = tokio::task::spawn_blocking(move || password::hash_string(&password))
let argon2_params = password::Argon2Params {
memory_kib: password_config.argon2_memory_kib,
iterations: password_config.argon2_iterations,
parallelism: password_config.argon2_parallelism,
};
Comment thread
fergusfinn marked this conversation as resolved.
let password_hash = tokio::task::spawn_blocking(move || password::hash_string_with_params(&password, Some(argon2_params)))
.await
.map_err(|e| Error::Internal {
operation: format!("spawn password hashing task: {e}"),
Expand Down Expand Up @@ -353,7 +358,12 @@ pub async fn confirm_password_reset(
// Hash new password
let new_password_hash = tokio::task::spawn_blocking({
let password = request.new_password.clone();
move || password::hash_string(&password)
let argon2_params = password::Argon2Params {
memory_kib: password_config.argon2_memory_kib,
iterations: password_config.argon2_iterations,
parallelism: password_config.argon2_parallelism,
};
move || password::hash_string_with_params(&password, Some(argon2_params))
})
.await
.map_err(|e| Error::Internal {
Expand Down Expand Up @@ -477,7 +487,12 @@ pub async fn change_password(
// Hash new password
let new_password_hash = tokio::task::spawn_blocking({
let password = request.new_password.clone();
move || password::hash_string(&password)
let argon2_params = password::Argon2Params {
memory_kib: password_config.argon2_memory_kib,
iterations: password_config.argon2_iterations,
parallelism: password_config.argon2_parallelism,
};
move || password::hash_string_with_params(&password, Some(argon2_params))
})
.await
.map_err(|e| Error::Internal {
Expand Down Expand Up @@ -823,7 +838,13 @@ mod tests {
.build();

// Create a user using the repository
let password_hash = password::hash_string("testpassword").unwrap();
// Use weak params for fast testing
let test_params = password::Argon2Params {
memory_kib: 128,
iterations: 1,
parallelism: 1,
};
Comment thread
fergusfinn marked this conversation as resolved.
let password_hash = password::hash_string_with_params("testpassword", Some(test_params)).unwrap();
let mut conn = pool.acquire().await.unwrap();
let mut user_repo = Users::new(&mut conn);

Expand Down Expand Up @@ -924,7 +945,15 @@ mod tests {
.build();

// Create a user using the repository
let password_hash = password::hash_string("correctpassword").unwrap();
let password_hash = password::hash_string_with_params(
"correctpassword",
Some(password::Argon2Params {
memory_kib: 128,
iterations: 1,
parallelism: 1,
}),
)
.unwrap();
let mut conn = pool.acquire().await.unwrap();
let mut user_repo = Users::new(&mut conn);

Expand Down Expand Up @@ -1056,7 +1085,17 @@ mod tests {
is_admin: false,
roles: vec![Role::StandardUser],
auth_source: "native".to_string(),
password_hash: Some(password::hash_string("password").unwrap()),
password_hash: Some(
password::hash_string_with_params(
"password",
Some(password::Argon2Params {
memory_kib: 128,
iterations: 1,
parallelism: 1,
}),
)
.unwrap(),
),
external_user_id: None,
};

Expand Down Expand Up @@ -1381,7 +1420,15 @@ mod tests {
let (app, _bg_services) = app.into_test_server();

// Create a user with a password
let old_password_hash = password::hash_string("oldpassword123").unwrap();
let old_password_hash = password::hash_string_with_params(
"oldpassword123",
Some(password::Argon2Params {
memory_kib: 128,
iterations: 1,
parallelism: 1,
}),
)
.unwrap();
let mut conn = pool.acquire().await.unwrap();
let mut user_repo = Users::new(&mut conn);

Expand Down Expand Up @@ -1525,7 +1572,15 @@ mod tests {
let (app, _bg_services) = app.into_test_server();

// Create a user with a password
let old_password_hash = password::hash_string("oldpassword123").unwrap();
let old_password_hash = password::hash_string_with_params(
"oldpassword123",
Some(password::Argon2Params {
memory_kib: 128,
iterations: 1,
parallelism: 1,
}),
)
.unwrap();
let mut conn = pool.acquire().await.unwrap();
let mut user_repo = Users::new(&mut conn);

Expand Down Expand Up @@ -1598,7 +1653,15 @@ mod tests {
let (app, _bg_services) = app.into_test_server();

// Create a user with a password
let password_hash = password::hash_string("correctpassword").unwrap();
let password_hash = password::hash_string_with_params(
"correctpassword",
Some(password::Argon2Params {
memory_kib: 128,
iterations: 1,
parallelism: 1,
}),
)
.unwrap();
let mut conn = pool.acquire().await.unwrap();
let mut user_repo = Users::new(&mut conn);

Expand Down Expand Up @@ -1702,7 +1765,15 @@ mod tests {
let (app, _bg_services) = app.into_test_server();

// Create a user with a password
let password_hash = password::hash_string("oldpassword123").unwrap();
let password_hash = password::hash_string_with_params(
"oldpassword123",
Some(password::Argon2Params {
memory_kib: 128,
iterations: 1,
parallelism: 1,
}),
)
.unwrap();
let mut conn = pool.acquire().await.unwrap();
let mut user_repo = Users::new(&mut conn);

Expand Down Expand Up @@ -1755,7 +1826,15 @@ mod tests {
let (app, _bg_services) = app.into_test_server();

// Create a user with a password
let password_hash = password::hash_string("oldpassword").unwrap();
let password_hash = password::hash_string_with_params(
"oldpassword",
Some(password::Argon2Params {
memory_kib: 128,
iterations: 1,
parallelism: 1,
}),
)
.unwrap();
let mut conn = pool.acquire().await.unwrap();
let mut user_repo = Users::new(&mut conn);

Expand Down Expand Up @@ -1807,7 +1886,15 @@ mod tests {
let (app, _bg_services) = app.into_test_server();

// Create a user with a password
let password_hash = password::hash_string("oldpassword").unwrap();
let password_hash = password::hash_string_with_params(
"oldpassword",
Some(password::Argon2Params {
memory_kib: 128,
iterations: 1,
parallelism: 1,
}),
)
.unwrap();
let mut conn = pool.acquire().await.unwrap();
let mut user_repo = Users::new(&mut conn);

Expand Down
54 changes: 49 additions & 5 deletions dwctl/src/auth/password.rs
Original file line number Diff line number Diff line change
@@ -1,18 +1,54 @@
//! Password hashing and verification.

use argon2::{
Argon2,
Algorithm, Argon2, Params, Version,
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
};
use base64::{Engine as _, engine::general_purpose};
use rand::{Rng, rngs::OsRng, thread_rng};

use crate::errors::Error;

/// Hash a string using Argon2 (used for passwords and tokens)
pub fn hash_string(input: &str) -> Result<String, Error> {
/// Argon2 hashing parameters.
#[derive(Debug, Clone, Copy)]
pub struct Argon2Params {
pub memory_kib: u32,
pub iterations: u32,
pub parallelism: u32,
}

impl Argon2Params {
/// Create Argon2 instance with these parameters.
fn to_argon2(self) -> Result<Argon2<'static>, Error> {
let params = Params::new(self.memory_kib, self.iterations, self.parallelism, None).map_err(|e| Error::Internal {
operation: format!("create argon2 params: {e}"),
})?;

Ok(Argon2::new(Algorithm::Argon2id, Version::V0x13, params))
}
}

impl Default for Argon2Params {
/// Secure defaults for production (Argon2id RFC recommendations)
fn default() -> Self {
Self {
memory_kib: 19456, // 19 MB
iterations: 2,
parallelism: 1,
}
}
}

/// Hash a string using Argon2 (used for passwords and tokens).
///
/// Uses the provided parameters or secure defaults if None.
pub fn hash_string_with_params(input: &str, params: Option<Argon2Params>) -> Result<String, Error> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let argon2 = if let Some(p) = params {
p.to_argon2()?
} else {
Argon2Params::default().to_argon2()?
};

let hash = argon2.hash_password(input.as_bytes(), &salt).map_err(|e| Error::Internal {
operation: format!("hash string: {e}"),
Expand All @@ -21,12 +57,20 @@ pub fn hash_string(input: &str) -> Result<String, Error> {
Ok(hash.to_string())
}

/// Verify a string against a hash
/// Hash a string using Argon2 with default secure parameters.
pub fn hash_string(input: &str) -> Result<String, Error> {
hash_string_with_params(input, None)
}

/// Verify a string against a hash.
///
/// Note: Verification uses the parameters embedded in the hash itself.
pub fn verify_string(input: &str, hash: &str) -> Result<bool, Error> {
let parsed_hash = PasswordHash::new(hash).map_err(|e| Error::Internal {
operation: format!("parse hash: {e}"),
})?;

// Verification always uses params from the hash
let argon2 = Argon2::default();
Ok(argon2.verify_password(input.as_bytes(), &parsed_hash).is_ok())
}
Expand Down
10 changes: 10 additions & 0 deletions dwctl/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,12 @@ pub struct PasswordConfig {
pub min_length: usize,
/// Maximum password length
pub max_length: usize,
/// Argon2 memory cost in KiB (default: 19456 KiB = 19 MB, secure for production)
pub argon2_memory_kib: u32,
/// Argon2 iterations (default: 2, secure for production)
pub argon2_iterations: u32,
/// Argon2 parallelism (default: 1)
pub argon2_parallelism: u32,
}

/// Security configuration for JWT and CORS.
Expand Down Expand Up @@ -929,6 +935,10 @@ impl Default for PasswordConfig {
Self {
min_length: 8,
max_length: 64,
// Secure defaults for production (Argon2id RFC recommendations)
argon2_memory_kib: 19456, // 19 MB
argon2_iterations: 2,
argon2_parallelism: 1,
}
}
}
Expand Down
8 changes: 7 additions & 1 deletion dwctl/src/db/handlers/password_reset_tokens.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ impl<'c> Repository for PasswordResetTokens<'c> {

#[instrument(skip(self, request), err)]
async fn create(&mut self, request: &Self::CreateRequest) -> Result<Self::Response> {
let token_hash = password::hash_string(&request.raw_token).map_err(|e| DbError::Other(anyhow::anyhow!(e)))?;
let token_hash = password::hash_string_with_params(&request.raw_token, Some(request.argon2_params))
.map_err(|e| DbError::Other(anyhow::anyhow!(e)))?;

let token = sqlx::query_as!(
PasswordResetToken,
Expand Down Expand Up @@ -153,6 +154,11 @@ impl<'c> PasswordResetTokens<'c> {
user_id,
raw_token: raw_token.clone(),
expires_at,
argon2_params: password::Argon2Params {
memory_kib: config.auth.native.password.argon2_memory_kib,
iterations: config.auth.native.password.argon2_iterations,
parallelism: config.auth.native.password.argon2_parallelism,
},
};

let token = self.create(&request).await?;
Expand Down
1 change: 1 addition & 0 deletions dwctl/src/db/models/password_reset_tokens.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ pub struct PasswordResetTokenCreateRequest {
pub user_id: UserId,
pub raw_token: String,
pub expires_at: DateTime<Utc>,
pub argon2_params: crate::auth::password::Argon2Params,
}

/// Request for updating a password reset token (mark as used)
Expand Down
Loading
Loading