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
33 changes: 33 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 2 additions & 1 deletion migrations/0001_users_table.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
15 changes: 8 additions & 7 deletions src/models/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ pub struct DBUser {
pub email: String,
pub username: String,
pub password_hash: String,
pub salt: String,
}

// Essential user information.
Expand All @@ -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,
}
38 changes: 30 additions & 8 deletions src/routes/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -27,9 +32,9 @@ pub async fn greet(req: HttpRequest, data: web::Data<AppState>) -> Result<HttpRe
}

#[post("/api/users/")]
pub async fn create_user(user: web::Json<DBUser>, data: web::Data<AppState>) -> Result<HttpResponse, AppError> {
pub async fn create_user(user: web::Json<UserRegisterCredentials>, data: web::Data<AppState>) -> Result<HttpResponse, AppError> {
// 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")
Expand All @@ -46,11 +51,21 @@ pub async fn create_user(user: web::Json<DBUser>, data: web::Data<AppState>) ->
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| {
Expand All @@ -70,7 +85,7 @@ pub async fn create_user(user: web::Json<DBUser>, data: web::Data<AppState>) ->
#[post("api/token/get/")]
async fn login(user: web::Json<UserLoginCredentials>, data: web::Data<AppState>) -> Result<HttpResponse, AppError> {
// 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
Expand All @@ -82,7 +97,14 @@ async fn login(user: web::Json<UserLoginCredentials>, data: web::Data<AppState>)
// Send jwt token pair on successful login
match record {
Some(record) => {
if user.verify_password(&record.get::<String, _>("password")) {
let pass = &record.get::<String, _>("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::<i32, _>("id").to_string(),
Expand Down
6 changes: 5 additions & 1 deletion tests/auth_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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;
Expand Down
24 changes: 12 additions & 12 deletions tests/common/mod.rs
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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();

Expand All @@ -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
}

Expand All @@ -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;
Expand Down
Loading