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",
|
"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]]
|
[[package]]
|
||||||
name = "arraydeque"
|
name = "arraydeque"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
|
@ -273,6 +285,15 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "blake2"
|
||||||
|
version = "0.10.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.10.4"
|
version = "0.10.4"
|
||||||
|
@ -1760,6 +1781,17 @@ dependencies = [
|
||||||
"windows-targets 0.52.6",
|
"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]]
|
[[package]]
|
||||||
name = "pathdiff"
|
name = "pathdiff"
|
||||||
version = "0.2.3"
|
version = "0.2.3"
|
||||||
|
@ -3838,6 +3870,7 @@ name = "zero-to-axum"
|
||||||
version = "0.1.0-dev"
|
version = "0.1.0-dev"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"argon2",
|
||||||
"axum",
|
"axum",
|
||||||
"axum-extra",
|
"axum-extra",
|
||||||
"bollard",
|
"bollard",
|
||||||
|
@ -3847,6 +3880,7 @@ dependencies = [
|
||||||
"hyper",
|
"hyper",
|
||||||
"lettre",
|
"lettre",
|
||||||
"maik",
|
"maik",
|
||||||
|
"password-hash",
|
||||||
"pin-project",
|
"pin-project",
|
||||||
"rand 0.9.1",
|
"rand 0.9.1",
|
||||||
"regex",
|
"regex",
|
||||||
|
|
|
@ -9,12 +9,14 @@ license = "MIT OR Apache-2.0"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = { version = "1.0.71", features = ["backtrace"] }
|
anyhow = { version = "1.0.71", features = ["backtrace"] }
|
||||||
|
argon2 = "0.5.3"
|
||||||
axum = { version = "0.8", features = ["tokio", "http1", "http2", "macros"] }
|
axum = { version = "0.8", features = ["tokio", "http1", "http2", "macros"] }
|
||||||
axum-extra = { version = "0.10", features = ["cookie-private", "typed-header"] }
|
axum-extra = { version = "0.10", features = ["cookie-private", "typed-header"] }
|
||||||
config = { version = "0.15", features = ["toml"] }
|
config = { version = "0.15", features = ["toml"] }
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
hyper = "1.1"
|
hyper = "1.1"
|
||||||
lettre = { version = "0.11.17", features = ["tokio1", "tokio1-native-tls", "tracing", "web"] }
|
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"
|
pin-project = "1.1.0"
|
||||||
rand = "0.9.1"
|
rand = "0.9.1"
|
||||||
serde = { version = "1.0.164", features = ["derive"] }
|
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 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 axum_extra::extract::cookie::{Cookie, PrivateCookieJar};
|
||||||
|
use password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use tracing::info;
|
use tracing::{error, info, warn};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::server::AppState;
|
use crate::server::AppState;
|
||||||
|
|
||||||
pub fn build() -> Router<AppState> {
|
pub fn build() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
|
.route("/signup", post(signup))
|
||||||
.route("/login", post(login))
|
.route("/login", post(login))
|
||||||
.route("/logout", post(logout))
|
.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)]
|
#[derive(Deserialize)]
|
||||||
pub struct LoginForm {
|
pub struct LoginForm {
|
||||||
username: String,
|
email: String,
|
||||||
password: String,
|
password: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Debug for LoginForm {
|
impl fmt::Debug for LoginForm {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
f.debug_struct("LoginForm")
|
f.debug_struct("LoginForm")
|
||||||
.field("username", &self.username)
|
.field("email", &self.email)
|
||||||
.field("password", &"REDACTED")
|
.field("password", &"REDACTED")
|
||||||
.finish()
|
.finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument]
|
#[tracing::instrument(skip(db))]
|
||||||
pub async fn login(
|
pub async fn login(
|
||||||
|
State(AppState { db, .. }): State<AppState>,
|
||||||
jar: PrivateCookieJar,
|
jar: PrivateCookieJar,
|
||||||
Form(form): Form<LoginForm>,
|
Form(form): Form<LoginForm>,
|
||||||
) -> Result<PrivateCookieJar, LoginError> {
|
) -> Result<PrivateCookieJar, LoginError> {
|
||||||
info!("login attempt");
|
info!("login attempt");
|
||||||
|
|
||||||
if form.username != "admin" {
|
let password_hash = sqlx::query!(
|
||||||
return Err(LoginError::UnknownUser);
|
r#"
|
||||||
}
|
SELECT password FROM users WHERE email = $1 LIMIT 1;
|
||||||
|
"#,
|
||||||
|
form.email
|
||||||
|
)
|
||||||
|
.fetch_one(&db)
|
||||||
|
.await;
|
||||||
|
|
||||||
if form.password != "hunter2" {
|
let password_hash = match password_hash {
|
||||||
return Err(LoginError::InvalidPassword);
|
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"));
|
let authed_jar = jar.add(Cookie::new("username", "admin"));
|
||||||
|
|
||||||
Ok(authed_jar)
|
Ok(authed_jar)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(thiserror::Error, Debug)]
|
||||||
pub enum LoginError {
|
pub enum LoginError {
|
||||||
UnknownUser,
|
#[error("Invalid Password")]
|
||||||
InvalidPassword,
|
InvalidPassword,
|
||||||
|
#[error("Unknown User")]
|
||||||
|
UnknownUser,
|
||||||
|
#[error("Unknown Error: {0}")]
|
||||||
|
Unknown(#[from] anyhow::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IntoResponse for LoginError {
|
impl IntoResponse for LoginError {
|
||||||
fn into_response(self) -> axum::response::Response {
|
fn into_response(self) -> axum::response::Response {
|
||||||
match self {
|
match self {
|
||||||
LoginError::UnknownUser => (StatusCode::UNAUTHORIZED, "Unknown User"),
|
|
||||||
LoginError::InvalidPassword => (StatusCode::UNAUTHORIZED, "Invalid Password"),
|
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()
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,11 +9,21 @@ async fn login_succeeds_with_valid_credentials() -> Result<()> {
|
||||||
let server = TestServer::spawn().await;
|
let server = TestServer::spawn().await;
|
||||||
let client = reqwest::Client::builder().cookie_store(true).build()?;
|
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
|
// Login
|
||||||
let resp = client
|
let resp = client
|
||||||
.post(server.url("/auth/login"))
|
.post(server.url("/auth/login"))
|
||||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||||
.body("username=admin&password=hunter2")
|
.body("email=admin1&password=hunter2")
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
@ -43,10 +53,22 @@ async fn login_succeeds_with_valid_credentials() -> Result<()> {
|
||||||
async fn login_fails_with_invalid_credentials() -> Result<()> {
|
async fn login_fails_with_invalid_credentials() -> Result<()> {
|
||||||
let server = TestServer::spawn().await;
|
let server = TestServer::spawn().await;
|
||||||
let client = reqwest::Client::new();
|
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
|
let resp = client
|
||||||
.post(server.url("/auth/login"))
|
.post(server.url("/auth/login"))
|
||||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||||
.body("username=admin&password=hunter3")
|
.body("email=admin2&password=hunter3")
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
@ -70,7 +92,7 @@ async fn login_rejects_missing_credentials() -> Result<()> {
|
||||||
let resp = client
|
let resp = client
|
||||||
.post(server.url("/auth/login"))
|
.post(server.url("/auth/login"))
|
||||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||||
.body("username=&password=")
|
.body("email=&password=")
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|
|
@ -95,7 +95,10 @@ impl TestServer {
|
||||||
self.server_task_handle.abort();
|
self.server_task_handle.abort();
|
||||||
let _ = self.server_task_handle.await;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue