From abb48d471ea02841973d6d91687f430f926a00e8 Mon Sep 17 00:00:00 2001 From: azdle Date: Thu, 17 Jul 2025 09:44:46 -0500 Subject: [PATCH] signup & login --- Cargo.lock | 34 +++++ Cargo.toml | 2 + .../20250716154540_create-users-table.sql | 5 + src/server/routes/auth/mod.rs | 139 ++++++++++++++++-- tests/auth.rs | 28 +++- tests/fixture/mod.rs | 5 +- 6 files changed, 196 insertions(+), 17 deletions(-) create mode 100644 migrations/20250716154540_create-users-table.sql diff --git a/Cargo.lock b/Cargo.lock index f6dd050..8c5a5c4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -103,6 +103,18 @@ dependencies = [ "backtrace", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + [[package]] name = "arraydeque" version = "0.5.1" @@ -273,6 +285,15 @@ dependencies = [ "serde", ] +[[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" @@ -1760,6 +1781,17 @@ dependencies = [ "windows-targets 0.52.6", ] +[[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 = "pathdiff" version = "0.2.3" @@ -3838,6 +3870,7 @@ name = "zero-to-axum" version = "0.1.0-dev" dependencies = [ "anyhow", + "argon2", "axum", "axum-extra", "bollard", @@ -3847,6 +3880,7 @@ dependencies = [ "hyper", "lettre", "maik", + "password-hash", "pin-project", "rand 0.9.1", "regex", diff --git a/Cargo.toml b/Cargo.toml index 488a2af..1e3f909 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,12 +9,14 @@ license = "MIT OR Apache-2.0" [dependencies] anyhow = { version = "1.0.71", features = ["backtrace"] } +argon2 = "0.5.3" axum = { version = "0.8", features = ["tokio", "http1", "http2", "macros"] } axum-extra = { version = "0.10", features = ["cookie-private", "typed-header"] } config = { version = "0.15", features = ["toml"] } futures-util = "0.3" hyper = "1.1" lettre = { version = "0.11.17", features = ["tokio1", "tokio1-native-tls", "tracing", "web"] } +password-hash = { version = "0.5.0", features = ["getrandom", "std"] } pin-project = "1.1.0" rand = "0.9.1" serde = { version = "1.0.164", features = ["derive"] } diff --git a/migrations/20250716154540_create-users-table.sql b/migrations/20250716154540_create-users-table.sql new file mode 100644 index 0000000..5eb6968 --- /dev/null +++ b/migrations/20250716154540_create-users-table.sql @@ -0,0 +1,5 @@ +CREATE TABLE users( + id uuid PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + password TEXT NOT NULL +); diff --git a/src/server/routes/auth/mod.rs b/src/server/routes/auth/mod.rs index c2e3d21..cd0c374 100644 --- a/src/server/routes/auth/mod.rs +++ b/src/server/routes/auth/mod.rs @@ -1,63 +1,176 @@ use std::fmt; -use axum::{http::StatusCode, response::IntoResponse, routing::post, Form, Router}; +use anyhow::Context as _; +use argon2::Argon2; +use axum::{extract::State, http::StatusCode, response::IntoResponse, routing::post, Form, Router}; use axum_extra::extract::cookie::{Cookie, PrivateCookieJar}; +use password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}; use serde::Deserialize; -use tracing::info; +use tracing::{error, info, warn}; +use uuid::Uuid; use crate::server::AppState; pub fn build() -> Router { Router::new() + .route("/signup", post(signup)) .route("/login", post(login)) .route("/logout", post(logout)) } +#[derive(Deserialize)] +pub struct SignupForm { + email: String, + password: String, +} + +impl fmt::Debug for SignupForm { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("SignupForm") + .field("email", &self.email) + .field("password", &"REDACTED") + .finish() + } +} + +#[tracing::instrument(skip(db))] +pub async fn signup( + State(AppState { db, .. }): State, + jar: PrivateCookieJar, + Form(form): Form, +) -> Result { + info!("signup attempt"); + + info!("hash password: {}", &form.password); + let password_hash: String = tokio::task::spawn_blocking(async move || { + let salt = SaltString::generate(&mut OsRng); + let argon2 = Argon2::default(); + Ok::( + argon2 + .hash_password(form.password.as_bytes(), &salt)? + .to_string(), + ) + }) + .await + .context("run password hashing operation")? + .await?; + info!("hashed password: {password_hash}"); + + warn!("db: {db:?}"); + + let user_id = Uuid::new_v4(); + sqlx::query!( + r#" + INSERT INTO users (id, email, password) + VALUES ($1, $2, $3) + "#, + user_id, + form.email, + password_hash, + ) + .execute(&db) + .await + .context("insert new user into database")?; + + let authed_jar = jar.add(Cookie::new("username", form.email)); + + Ok(authed_jar) +} + +#[derive(thiserror::Error, Debug)] +pub enum SignupError { + #[error("Unknown Error: {0}")] + Unknown(#[from] anyhow::Error), +} + +impl IntoResponse for SignupError { + fn into_response(self) -> axum::response::Response { + match self { + SignupError::Unknown(e) => { + error!(?e, "returning INTERNAL SERVER ERROR"); + (StatusCode::INTERNAL_SERVER_ERROR, "Unknown Error") + } + } + .into_response() + } +} + #[derive(Deserialize)] pub struct LoginForm { - username: String, + email: String, password: String, } impl fmt::Debug for LoginForm { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("LoginForm") - .field("username", &self.username) + .field("email", &self.email) .field("password", &"REDACTED") .finish() } } -#[tracing::instrument] +#[tracing::instrument(skip(db))] pub async fn login( + State(AppState { db, .. }): State, jar: PrivateCookieJar, Form(form): Form, ) -> Result { info!("login attempt"); - if form.username != "admin" { - return Err(LoginError::UnknownUser); - } + let password_hash = sqlx::query!( + r#" + SELECT password FROM users WHERE email = $1 LIMIT 1; + "#, + form.email + ) + .fetch_one(&db) + .await; - if form.password != "hunter2" { - return Err(LoginError::InvalidPassword); - } + let password_hash = match password_hash { + Ok(ph) => ph, + Err(sqlx::Error::RowNotFound) => return Err(LoginError::UnknownUser), + Err(e) => Err(e).context("get user info from db")?, + }; + + tokio::task::spawn_blocking(async move || { + let parsed_hash = + PasswordHash::new(&password_hash.password).context("parse stored password hash")?; + + match Argon2::default().verify_password(form.password.as_bytes(), &parsed_hash) { + Ok(()) => Ok(()), + Err(password_hash::Error::Password) => Err(LoginError::InvalidPassword), + Err(e) => Err(e).context("verify password hash")?, + } + }) + .await + .context("spawn password verifier task")? + .await?; let authed_jar = jar.add(Cookie::new("username", "admin")); Ok(authed_jar) } +#[derive(thiserror::Error, Debug)] pub enum LoginError { - UnknownUser, + #[error("Invalid Password")] InvalidPassword, + #[error("Unknown User")] + UnknownUser, + #[error("Unknown Error: {0}")] + Unknown(#[from] anyhow::Error), } impl IntoResponse for LoginError { fn into_response(self) -> axum::response::Response { match self { - LoginError::UnknownUser => (StatusCode::UNAUTHORIZED, "Unknown User"), LoginError::InvalidPassword => (StatusCode::UNAUTHORIZED, "Invalid Password"), + LoginError::UnknownUser => (StatusCode::UNAUTHORIZED, "Unknown User"), + LoginError::Unknown(e) => { + error!(?e, "returning INTERNAL SERVER ERROR"); + (StatusCode::INTERNAL_SERVER_ERROR, "Unknown Error") + } } .into_response() } diff --git a/tests/auth.rs b/tests/auth.rs index 2507c3f..8f56487 100644 --- a/tests/auth.rs +++ b/tests/auth.rs @@ -9,11 +9,21 @@ async fn login_succeeds_with_valid_credentials() -> Result<()> { let server = TestServer::spawn().await; let client = reqwest::Client::builder().cookie_store(true).build()?; + // Signup + let resp = client + .post(server.url("/auth/signup")) + .header("Content-Type", "application/x-www-form-urlencoded") + .body("email=admin1&password=hunter2") + .send() + .await?; + + assert_eq!(resp.status(), 200, "signup succeeds"); + // Login let resp = client .post(server.url("/auth/login")) .header("Content-Type", "application/x-www-form-urlencoded") - .body("username=admin&password=hunter2") + .body("email=admin1&password=hunter2") .send() .await?; @@ -43,10 +53,22 @@ async fn login_succeeds_with_valid_credentials() -> Result<()> { async fn login_fails_with_invalid_credentials() -> Result<()> { let server = TestServer::spawn().await; let client = reqwest::Client::new(); + + // Signup + let resp = client + .post(server.url("/auth/signup")) + .header("Content-Type", "application/x-www-form-urlencoded") + .body("email=admin2&password=hunter2") + .send() + .await?; + + assert_eq!(resp.status(), 200, "signup succeeds"); + + // Login let resp = client .post(server.url("/auth/login")) .header("Content-Type", "application/x-www-form-urlencoded") - .body("username=admin&password=hunter3") + .body("email=admin2&password=hunter3") .send() .await?; @@ -70,7 +92,7 @@ async fn login_rejects_missing_credentials() -> Result<()> { let resp = client .post(server.url("/auth/login")) .header("Content-Type", "application/x-www-form-urlencoded") - .body("username=&password=") + .body("email=&password=") .send() .await?; diff --git a/tests/fixture/mod.rs b/tests/fixture/mod.rs index 48ff36e..e20850b 100644 --- a/tests/fixture/mod.rs +++ b/tests/fixture/mod.rs @@ -95,7 +95,10 @@ impl TestServer { self.server_task_handle.abort(); let _ = self.server_task_handle.await; - self.db.stop().await; + // Shutdown server if this is the last one. + if let Some(db) = Arc::into_inner(self.db) { + db.stop().await; + } Ok(()) }