From 4cbb94cd60a717d6cd7f98a2ef4b32cf4db32848 Mon Sep 17 00:00:00 2001 From: hackpulsar Date: Mon, 18 May 2026 14:46:32 +0200 Subject: [PATCH] add password salting, update migrations & tests --- Cargo.lock | 33 ++++++++++++++++++++++++++++ Cargo.toml | 1 + migrations/0001_users_table.sql | 3 ++- src/models/user.rs | 15 +++++++------ src/routes/auth.rs | 38 ++++++++++++++++++++++++++------- tests/auth_tests.rs | 6 +++++- tests/common/mod.rs | 24 ++++++++++----------- 7 files changed, 91 insertions(+), 29 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d7aca54..332df24 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -362,6 +362,18 @@ dependencies = [ "rustversion", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + [[package]] name = "astral-tokio-tar" version = "0.6.0" @@ -502,6 +514,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -2245,6 +2266,17 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "487f2ccd1e17ce8c1bfab3a65c89525af41cfad4c8659021a1e9a2aacd73b89b" +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "pem" version = "3.0.6" @@ -3228,6 +3260,7 @@ dependencies = [ "actix-multipart", "actix-multipart-test", "actix-web", + "argon2", "chrono", "deadpool-redis", "derive_more 2.1.1", diff --git a/Cargo.toml b/Cargo.toml index 737c16b..95eb5a1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ rand = "0.8.0" log = "0.4" env_logger = "0.11" openssl = "0.10" +argon2 = "0.5" [dev-dependencies] testcontainers = "0.27" diff --git a/migrations/0001_users_table.sql b/migrations/0001_users_table.sql index 8b6c2dc..db7716b 100644 --- a/migrations/0001_users_table.sql +++ b/migrations/0001_users_table.sql @@ -2,5 +2,6 @@ create table users ( id serial primary key, email varchar not null, username varchar not null, - password varchar not null + password_hash varchar not null, + salt varchar not null ) \ No newline at end of file diff --git a/src/models/user.rs b/src/models/user.rs index 6207481..9454662 100644 --- a/src/models/user.rs +++ b/src/models/user.rs @@ -7,6 +7,7 @@ pub struct DBUser { pub email: String, pub username: String, pub password_hash: String, + pub salt: String, } // Essential user information. @@ -19,14 +20,14 @@ pub struct UserInfo { } #[derive(Serialize, Deserialize, Debug)] -pub struct UserLoginCredentials { +pub struct UserRegisterCredentials { pub email: String, - pub password_hash: String, + pub username: String, + pub password: String } -impl UserLoginCredentials { - // Compares given password hash to user password hash - pub fn verify_password(&self, password_hash: &str) -> bool { - self.password_hash == password_hash - } +#[derive(Serialize, Deserialize, Debug)] +pub struct UserLoginCredentials { + pub email: String, + pub password: String, } diff --git a/src/routes/auth.rs b/src/routes/auth.rs index 3774b1b..cf75a03 100644 --- a/src/routes/auth.rs +++ b/src/routes/auth.rs @@ -3,10 +3,15 @@ use redis::AsyncCommands; use serde::Deserialize; use sqlx::Row; use log::{warn, debug}; +use argon2::{ + Argon2, PasswordHash, PasswordHasher, PasswordVerifier, password_hash::{ + SaltString, rand_core::OsRng + } +}; -use crate::AppState; +use crate::{AppState, models::user::UserRegisterCredentials}; use crate::models::jwt::{JwtTokenPair, TokenType}; -use crate::models::user::{DBUser, UserInfo, UserLoginCredentials}; +use crate::models::user::{UserInfo, UserLoginCredentials}; use crate::services::auth::{get_and_validate_jwt, validate_jwt}; use crate::utils::errors::AppError; @@ -27,9 +32,9 @@ pub async fn greet(req: HttpRequest, data: web::Data) -> Result, data: web::Data) -> Result { +pub async fn create_user(user: web::Json, data: web::Data) -> Result { // User JSON to User struct - let user: DBUser = user.into_inner(); + let user: UserRegisterCredentials = user.into_inner(); // Look for a record with given email in the DB let record = sqlx::query("select 1 from users where email = $1") @@ -46,11 +51,21 @@ pub async fn create_user(user: web::Json, data: web::Data) -> return Err(AppError::BadRequest { msg: "User with this email already exists".to_string() }); } + // Password hashing + salting + let salt = SaltString::generate(&mut OsRng); + let argon2 = Argon2::default(); + let password_hash = argon2.hash_password(user.password.as_bytes(), &salt) + .map_err(|e| { + warn!("Failed to hash password [{:?}]", e); + AppError::InternalServerError { msg: "Failed to hash password".to_string() } + })?; + // Perform a query - let record = sqlx::query("insert into users(email, username, password) values ($1, $2, $3) returning id, email, username") + let record = sqlx::query("insert into users(email, username, password_hash, salt) values ($1, $2, $3, $4) returning id, email, username") .bind(user.email.clone()) .bind(user.username.clone()) - .bind(user.password_hash.clone()) + .bind(password_hash.to_string()) + .bind(salt.to_string()) .fetch_one(&data.db_pool) .await .map_err(|e| { @@ -70,7 +85,7 @@ pub async fn create_user(user: web::Json, data: web::Data) -> #[post("api/token/get/")] async fn login(user: web::Json, data: web::Data) -> Result { // Look up user with given email - let record = sqlx::query("select password, id from users where email = $1") + let record = sqlx::query("select password_hash, id from users where email = $1") .bind(user.email.clone()) .fetch_optional(&data.db_pool) .await @@ -82,7 +97,14 @@ async fn login(user: web::Json, data: web::Data) // Send jwt token pair on successful login match record { Some(record) => { - if user.verify_password(&record.get::("password")) { + let pass = &record.get::("password_hash"); + let hash = PasswordHash::new(pass) + .map_err(|e| { + warn!("Password hash parsing failed [{:?}]", e); + AppError::InternalServerError { msg: "Password hash parsing failed".to_string() } + })?; + + if Argon2::default().verify_password(user.password.as_bytes(), &hash).is_ok() { debug!("User logged in [{:?}]", user.into_inner()); Ok(HttpResponse::Ok().json(JwtTokenPair::generate_for( record.get::("id").to_string(), diff --git a/tests/auth_tests.rs b/tests/auth_tests.rs index 88f48d2..9211a10 100644 --- a/tests/auth_tests.rs +++ b/tests/auth_tests.rs @@ -16,6 +16,10 @@ struct RegisterResponse { #[actix_web::test] async fn test_auth_flow() { + let _ = env_logger::builder() + .is_test(true) + .try_init(); + let ctx = setup().await; let app = make_app!(ctx); @@ -31,7 +35,7 @@ async fn test_auth_flow() { assert_eq!(body.username, new_user.username); // Step 2. Login - let resp = login(&app, &new_user.email, &new_user.password_hash).await; + let resp = login(&app, &new_user.email, &new_user.password).await; assert!(resp.status().is_success()); let body: JwtTokenPair = test::read_body_json(resp).await; diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 2b3112e..652b52f 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -1,11 +1,11 @@ use testcontainers::{runners::AsyncRunner, ContainerAsync}; use testcontainers_modules::{postgres::Postgres, redis::Redis}; -use storage_crab::{AppState, create_db_pool, create_redis_pool, models::jwt::JwtTokenPair}; +use storage_crab::{AppState, create_db_pool, create_redis_pool, models::{jwt::JwtTokenPair, user::UserRegisterCredentials}}; use tempfile::TempDir; use actix_web::{dev::{Service, ServiceResponse}, test}; use actix_http::Request; -use storage_crab::{models::user::{DBUser, UserLoginCredentials}}; +use storage_crab::{models::user::{UserLoginCredentials}}; use uuid::Uuid; // Blanket impl, typedef basically @@ -58,12 +58,12 @@ pub async fn setup() -> TestContext { } } -pub async fn login(app: &impl TestApp, email: &str, password_hash: &str) -> ServiceResponse { +pub async fn login(app: &impl TestApp, email: &str, password: &str) -> ServiceResponse { let req = test::TestRequest::post() .uri("/api/token/get/") .set_json(UserLoginCredentials { email: email.to_string(), - password_hash: password_hash.to_string() + password: password.to_string() }) .to_request(); @@ -72,31 +72,31 @@ pub async fn login(app: &impl TestApp, email: &str, password_hash: &str) -> Serv pub async fn register( app: &impl TestApp, - user: &DBUser + user: &UserRegisterCredentials ) -> ServiceResponse { let req = test::TestRequest::post() .uri("/api/users/") - .set_json(DBUser{ + .set_json(UserRegisterCredentials{ email: user.email.clone(), username: user.username.clone(), - password_hash: user.password_hash.clone() + password: user.password.clone() }) .to_request(); return test::call_service(&app, req).await; } -pub fn create_unique_test_user() -> DBUser { - return DBUser { +pub fn create_unique_test_user() -> UserRegisterCredentials { + return UserRegisterCredentials { email: format!("{}@test.com", Uuid::new_v4()).to_string(), username: "test".to_string(), - password_hash: "test".to_string() + password: "test".to_string() }; } #[allow(dead_code)] // Ignore for test helper pub struct Credentials { - pub user: DBUser, + pub user: UserRegisterCredentials, pub tokens: JwtTokenPair } @@ -106,7 +106,7 @@ pub async fn sign_in_new_user(app: &impl TestApp) -> Credentials { let resp = register(&app, &user).await; assert!(resp.status().is_success()); - let resp = login(&app, &user.email, &user.password_hash).await; + let resp = login(&app, &user.email, &user.password).await; assert!(resp.status().is_success()); let tokens: JwtTokenPair = test::read_body_json(resp).await;