diff --git a/Cargo.lock b/Cargo.lock index 7ebc587..fc9db08 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -68,6 +68,18 @@ dependencies = [ "libc", ] +[[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 = "async-compression" version = "0.4.18" @@ -82,17 +94,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "async-trait" -version = "0.1.83" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "autocfg" version = "1.4.0" @@ -101,14 +102,14 @@ checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" [[package]] name = "axum" -version = "0.7.9" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +checksum = "6d6fd624c75e18b3b4c6b9caf42b1afe24437daaee904069137d8bab077be8b8" dependencies = [ - "async-trait", - "axum-core 0.4.5", + "axum-core", "axum-macros", "bytes", + "form_urlencoded", "futures-util", "http", "http-body", @@ -116,7 +117,7 @@ dependencies = [ "hyper", "hyper-util", "itoa", - "matchit 0.7.3", + "matchit", "memchr", "mime", "percent-encoding", @@ -134,54 +135,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "axum" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d6fd624c75e18b3b4c6b9caf42b1afe24437daaee904069137d8bab077be8b8" -dependencies = [ - "axum-core 0.5.0", - "bytes", - "futures-util", - "http", - "http-body", - "http-body-util", - "itoa", - "matchit 0.8.4", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "rustversion", - "serde", - "sync_wrapper", - "tower", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-core" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" -dependencies = [ - "async-trait", - "bytes", - "futures-util", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "rustversion", - "sync_wrapper", - "tower-layer", - "tower-service", - "tracing", -] - [[package]] name = "axum-core" version = "0.5.0" @@ -208,8 +161,8 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fc6f625a1f7705c6cf62d0d070794e94668988b1c38111baeec177c715f7b" dependencies = [ - "axum 0.8.1", - "axum-core 0.5.0", + "axum", + "axum-core", "bytes", "cookie", "futures-util", @@ -226,9 +179,9 @@ dependencies = [ [[package]] name = "axum-macros" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" +checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" dependencies = [ "proc-macro2", "quote", @@ -250,12 +203,27 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "base64ct" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" + [[package]] name = "bitflags" version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" +[[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" @@ -318,7 +286,8 @@ checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b" name = "calathea" version = "0.1.0" dependencies = [ - "axum 0.7.9", + "argon2", + "axum", "axum-extra", "chrono", "r2d2", @@ -477,6 +446,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -820,12 +790,6 @@ version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" -[[package]] -name = "matchit" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" - [[package]] name = "matchit" version = "0.8.4" @@ -952,6 +916,17 @@ dependencies = [ "regex", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core", + "subtle", +] + [[package]] name = "percent-encoding" version = "2.3.1" @@ -1360,6 +1335,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.92" diff --git a/Cargo.toml b/Cargo.toml index 09669e7..a4d6dce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,8 @@ path = "src/main.rs" name = "calathea" [dependencies] -axum = { version = "0.7.9", features = ["macros"] } +argon2 = "0.5.3" +axum = { version = "0.8.0", features = ["macros"] } axum-extra = { version = "0.10.0", features = ["cookie"] } chrono = { version = "0.4.39", features = ["serde"] } r2d2 = "0.8.10" diff --git a/src/db/mod.rs b/src/db/mod.rs index c51a94c..bf038ce 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -66,7 +66,7 @@ pub fn run_migrations(pool: &DbPool, migrations: &[&str]) -> rusqlite::Result<() while next_version < migrations.len() { let tx = conn.transaction()?; - tx.execute(migrations[next_version], ())?; + tx.execute_batch(migrations[next_version])?; let cur_time = chrono::Local::now().timestamp(); tx.execute( diff --git a/src/db/session.rs b/src/db/session.rs index 5264407..90b7020 100644 --- a/src/db/session.rs +++ b/src/db/session.rs @@ -1,11 +1,21 @@ +use argon2::password_hash::rand_core::{OsRng, RngCore}; +use rusqlite::Row; + use super::{DbError, DbPool, User}; pub struct Session { - id: u64, - user_id: i32, + pub id: u64, + pub user_id: i32, } impl Session { + pub fn from_row(row: &Row<'_>) -> Result { + Ok(Self { + id: row.get("id")?, + user_id: row.get("user_id")?, + }) + } + pub fn user_from_id(pool: &DbPool, id: u64) -> Result, DbError> { let conn = pool.get()?; @@ -16,4 +26,14 @@ impl Session { Err(err) => Err(DbError::Db(err)), } } + + pub fn new_for_user(pool: &DbPool, user_id: i32) -> Result { + let id: u64 = OsRng.next_u64(); + + let conn = pool.get()?; + let mut stmt = + conn.prepare("insert into sessions (id, user_id) values ($1, $2) returning *")?; + + Ok(stmt.query_row((&id, &user_id), Self::from_row)?) + } } diff --git a/src/db/user.rs b/src/db/user.rs index 949ea79..6369bf7 100644 --- a/src/db/user.rs +++ b/src/db/user.rs @@ -1,3 +1,7 @@ +use argon2::{ + password_hash::{rand_core::OsRng, SaltString}, + Argon2, PasswordHash, PasswordHasher, PasswordVerifier, +}; use rusqlite::Row; use serde::{Deserialize, Serialize}; @@ -5,17 +9,17 @@ use super::{DbError, DbPool}; #[derive(Serialize, Deserialize, Clone)] pub struct User { - id: i32, - username: String, - password_hash: String, - admin: bool, + pub id: i32, + pub username: String, + pub password_hash: String, + pub admin: bool, } #[derive(Serialize, Deserialize)] pub struct NewUser { - username: String, - password: String, - admin: bool, + pub username: String, + pub password: String, + pub admin: bool, } impl User { @@ -27,19 +31,48 @@ impl User { admin: row.get("admin")?, }) } + + pub fn by_username(pool: &DbPool, username: impl AsRef) -> Result, DbError> { + let conn = pool.get()?; + + let mut stmt = conn.prepare("select * from users where username = $1")?; + + match stmt.query_row((username.as_ref(),), User::from_row) { + Ok(user) => Ok(Some(user)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(err) => Err(DbError::Db(err)), + } + } + + pub fn verify_password(&self, password: impl AsRef) -> bool { + let password_hash = PasswordHash::new(&self.password_hash).unwrap(); + + Argon2::default() + .verify_password(password.as_ref().as_bytes(), &password_hash) + .is_ok() + } +} + +fn hash_password(password: impl AsRef) -> String { + let salt = SaltString::generate(&mut OsRng); + let argon2 = Argon2::default(); + + argon2 + .hash_password(password.as_ref().as_bytes(), &salt) + .unwrap() + .to_string() } impl NewUser { pub fn insert(self, pool: &DbPool) -> Result { let conn = pool.get()?; + let password_hash = hash_password(&self.password); + let mut stmt = conn.prepare( "insert into users (username, password_hash, admin) values ($1, $2, $3) returning *", )?; - Ok(stmt.query_row( - (&self.username, &self.password, &self.admin), - User::from_row, - )?) + Ok(stmt.query_row((&self.username, password_hash, &self.admin), User::from_row)?) } } diff --git a/src/server/auth.rs b/src/server/auth.rs index 8ff8151..eb31aea 100644 --- a/src/server/auth.rs +++ b/src/server/auth.rs @@ -1,14 +1,21 @@ use axum::{ extract::{Request, State}, - http::StatusCode, + http::{HeaderMap, StatusCode}, middleware::Next, - response::{IntoResponse, Response}, + response::{Html, IntoResponse, Response}, + routing::{get, post}, + Form, Router, }; -use axum_extra::extract::CookieJar; +use axum_extra::extract::{ + cookie::{Cookie, SameSite}, + CookieJar, +}; +use serde::Deserialize; +use tera::Context; -use crate::db::Session; +use crate::db::{Session, User}; -use super::error::AppError; +use super::{error::AppError, render_view}; pub async fn auth_middleware( State(ctx): State, @@ -34,3 +41,55 @@ pub async fn auth_middleware( StatusCode::UNAUTHORIZED.into_response() } } + +pub fn app() -> Router { + Router::new().route("/login", get(get_login).post(post_login)) +} + +#[derive(Deserialize)] +struct Login { + username: String, + password: String, +} + +async fn get_login( + State(ctx): State, + headers: HeaderMap, +) -> Result, AppError> { + let context = Context::new(); + + Ok(Html(render_view( + &ctx.tera, + "views/login.html", + &context, + &headers, + )?)) +} + +async fn post_login( + State(ctx): State, + jar: CookieJar, + Form(login): Form, +) -> Result<(CookieJar, Html), AppError> { + if let Some(user) = User::by_username(&ctx.pool, &login.username)? { + if user.verify_password(&login.password) { + let session = Session::new_for_user(&ctx.pool, user.id)?; + let session_id = session.id.to_string(); + + Ok(( + jar.add( + Cookie::build(("session_id", session_id)) + .secure(true) + .http_only(true) + .same_site(SameSite::Lax) + .build(), + ), + Html(String::new()), + )) + } else { + Err(AppError::Unauthorized) + } + } else { + Err(AppError::Unauthorized) + } +} diff --git a/src/server/error.rs b/src/server/error.rs index 1d8049c..2765887 100644 --- a/src/server/error.rs +++ b/src/server/error.rs @@ -8,6 +8,7 @@ use crate::db; pub enum AppError { Db(db::DbError), Tera(tera::Error), + Unauthorized, NotFound, } @@ -16,6 +17,7 @@ impl fmt::Display for AppError { match self { Self::Db(_) => write!(f, "database error"), Self::Tera(_) => write!(f, "error rendering template"), + Self::Unauthorized => write!(f, "unauthorized"), Self::NotFound => write!(f, "not found"), } } @@ -26,7 +28,7 @@ impl std::error::Error for AppError { match self { Self::Db(err) => Some(err), Self::Tera(err) => Some(err), - Self::NotFound => None, + Self::NotFound | Self::Unauthorized => None, } } } @@ -65,6 +67,7 @@ impl IntoResponse for AppError { fn into_response(self) -> axum::response::Response { match self { Self::NotFound => StatusCode::NOT_FOUND.into_response(), + Self::Unauthorized => StatusCode::UNAUTHORIZED.into_response(), _ => { tracing::error!("{}", self.stack()); diff --git a/src/server/mod.rs b/src/server/mod.rs index 606ae95..c7251bc 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -59,6 +59,7 @@ pub fn app(ctx: crate::Context, static_dir: &str) -> axum::Router { ctx.clone(), auth::auth_middleware, )) + .merge(auth::app()) .route("/", get(get_index)) .nest_service("/static", ServeDir::new(static_dir)) .with_state(ctx.clone()); diff --git a/src/server/plants.rs b/src/server/plants.rs index 99515de..a0a373d 100644 --- a/src/server/plants.rs +++ b/src/server/plants.rs @@ -13,7 +13,7 @@ use super::error::AppError; pub fn app() -> axum::Router { Router::new() - .route("/:id", get(get_plant_page)) + .route("/{id}", get(get_plant_page)) .route("/", post(post_plant)) } diff --git a/templates/views/login.html b/templates/views/login.html new file mode 100644 index 0000000..bc92a47 --- /dev/null +++ b/templates/views/login.html @@ -0,0 +1,9 @@ +
+
+ + + + + +
+