signup & login
This commit is contained in:
parent
b5dec9c793
commit
abb48d471e
6 changed files with 196 additions and 17 deletions
34
Cargo.lock
generated
34
Cargo.lock
generated
|
@ -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",
|
||||
|
|
|
@ -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"] }
|
||||
|
|
5
migrations/20250716154540_create-users-table.sql
Normal file
5
migrations/20250716154540_create-users-table.sql
Normal file
|
@ -0,0 +1,5 @@
|
|||
CREATE TABLE users(
|
||||
id uuid PRIMARY KEY,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password TEXT NOT NULL
|
||||
);
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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?;
|
||||
|
||||
|
|
|
@ -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(())
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue