signup & login

This commit is contained in:
azdle 2025-07-17 09:44:46 -05:00
parent b5dec9c793
commit abb48d471e
6 changed files with 196 additions and 17 deletions

34
Cargo.lock generated
View file

@ -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",

View file

@ -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"] }

View file

@ -0,0 +1,5 @@
CREATE TABLE users(
id uuid PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
password TEXT NOT NULL
);

View file

@ -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<AppState> {
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<AppState>,
jar: PrivateCookieJar,
Form(form): Form<SignupForm>,
) -> Result<PrivateCookieJar, SignupError> {
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::<String, anyhow::Error>(
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<AppState>,
jar: PrivateCookieJar,
Form(form): Form<LoginForm>,
) -> Result<PrivateCookieJar, LoginError> {
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()
}

View file

@ -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?;

View file

@ -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(())
}