diff --git a/Cargo.lock b/Cargo.lock index f1e39c7..f790df1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -181,6 +181,26 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-login" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fbc0d7bd2577dda9aa9cac096e53b30342725d8eea5798169ff2537a214f45" +dependencies = [ + "async-trait", + "axum", + "form_urlencoded", + "serde", + "subtle", + "thiserror", + "tower-cookies", + "tower-layer", + "tower-service", + "tower-sessions", + "tracing", + "urlencoding", +] + [[package]] name = "axum-macros" version = "0.4.1" @@ -214,6 +234,12 @@ version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "base64ct" version = "1.6.0" @@ -429,6 +455,17 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + [[package]] name = "core-foundation-sys" version = "0.8.6" @@ -493,6 +530,16 @@ dependencies = [ "typenum", ] +[[package]] +name = "deranged" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +dependencies = [ + "powerfmt", + "serde", +] + [[package]] name = "digest" version = "0.10.7" @@ -963,6 +1010,7 @@ checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" dependencies = [ "autocfg", "scopeguard", + "serde", ] [[package]] @@ -977,6 +1025,7 @@ version = "0.1.0" dependencies = [ "argon2", "axum", + "axum-login", "bincode", "chrono", "clap", @@ -993,6 +1042,8 @@ dependencies = [ "tokio", "tower-http", "tower-livereload", + "tower-sessions", + "tower-sessions-sled-store", "tracing", "tracing-subscriber", "uuid", @@ -1124,6 +1175,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + [[package]] name = "num-traits" version = "0.2.19" @@ -1239,6 +1296,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pathdiff" version = "0.2.1" @@ -1328,6 +1391,18 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" + [[package]] name = "proc-macro-error" version = "1.0.4" @@ -1369,6 +1444,27 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + [[package]] name = "rand_core" version = "0.6.4" @@ -1458,13 +1554,35 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adad44e29e4c806119491a7f06f03de4d1af22c3a680dd47f1e6e179439d1f56" +[[package]] +name = "rmp" +version = "0.8.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "228ed7c16fa39782c3b3468e974aec2795e9089153cd08ee2e9aefb3613334c4" +dependencies = [ + "byteorder", + "num-traits", + "paste", +] + +[[package]] +name = "rmp-serde" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52e599a477cf9840e92f2cde9a7189e67b42c57532749bf90aea6ec10facd4db" +dependencies = [ + "byteorder", + "rmp", + "serde", +] + [[package]] name = "ron" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94" dependencies = [ - "base64", + "base64 0.21.7", "bitflags 2.5.0", "serde", "serde_derive", @@ -1710,6 +1828,37 @@ dependencies = [ "once_cell", ] +[[package]] +name = "time" +version = "0.3.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" + +[[package]] +name = "time-macros" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tiny-keccak" version = "2.0.2" @@ -1812,6 +1961,23 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-cookies" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fd0118512cf0b3768f7fcccf0bef1ae41d68f2b45edc1e77432b36c97c56c6d" +dependencies = [ + "async-trait", + "axum-core", + "cookie", + "futures-util", + "http", + "parking_lot 0.12.2", + "pin-project-lite", + "tower-layer", + "tower-service", +] + [[package]] name = "tower-http" version = "0.5.2" @@ -1863,6 +2029,69 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" +[[package]] +name = "tower-sessions" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d9b6f0c4938eed0eefd9cce19319b4bdad10e11ca9d8c3be373ce734bbfd63" +dependencies = [ + "async-trait", + "http", + "time", + "tokio", + "tower-cookies", + "tower-layer", + "tower-service", + "tower-sessions-core", + "tower-sessions-memory-store", + "tracing", +] + +[[package]] +name = "tower-sessions-core" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38767064990c327ec1d92bba2576dce0944750e9c9ae021f12ebc72de77ac406" +dependencies = [ + "async-trait", + "axum-core", + "base64 0.22.1", + "futures", + "http", + "parking_lot 0.12.2", + "rand", + "serde", + "serde_json", + "thiserror", + "time", + "tokio", + "tracing", +] + +[[package]] +name = "tower-sessions-memory-store" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8b09bbe2c138a9b0ebf307dc6e6a4f7723c59545e0f4fe5e329a89868164ae3" +dependencies = [ + "async-trait", + "time", + "tokio", + "tower-sessions-core", +] + +[[package]] +name = "tower-sessions-sled-store" +version = "0.1.0" +source = "git+https://github.com/lytedev/tower-sessions-sled-store.git?branch=tower-sessions-0.12#95e9ffd89c971f0199003ed4c3b5cba2e2dba254" +dependencies = [ + "async-trait", + "rmp-serde", + "sled", + "tokio", + "tower-sessions", +] + [[package]] name = "tracing" version = "0.1.40" @@ -1968,6 +2197,12 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf8parse" version = "0.2.1" @@ -1982,6 +2217,7 @@ checksum = "a183cf7feeba97b4dd1c0d46788634f6221d87fa961b305bed08c851829efcc0" dependencies = [ "atomic", "getrandom", + "serde", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 0145fe5..c13b63d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ panic = "abort" [dependencies] argon2 = { version = "0.5.3", features = ["std"] } +axum-login = "0.15.1" bincode = "1.3.3" chrono = { version = "0.4.38", features = ["serde"] } clap = { version = "4.5.4", features = ["derive", "env"] } @@ -36,9 +37,11 @@ thiserror = "1.0.60" tokio = { version = "1.37.0", features = ["full"] } tower-http = { version = "0.5.2", features = ["fs", "trace"] } tower-livereload = "0.9.2" +tower-sessions = "0.12.2" +tower-sessions-sled-store = { git = "https://github.com/lytedev/tower-sessions-sled-store.git", branch = "tower-sessions-0.12" } tracing = "0.1.40" tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } -uuid = { version = "1.8.0", features = ["v7"] } +uuid = { version = "1.8.0", features = ["v7", "serde"] } walkdir = "2.5.0" [dependencies.axum] diff --git a/src/router.rs b/src/router.rs index 5092fe6..a2ed58a 100644 --- a/src/router.rs +++ b/src/router.rs @@ -14,6 +14,7 @@ use axum::{ routing::get, Router, }; +use axum_login::login_required; use maud::html; use sled::IVec; use thiserror::Error; @@ -80,6 +81,8 @@ pub async fn router( .route("/", get(index)) .route("/about", get(about)) .route("/users", get(users)) + .route("/dashboard", get(dashboard)) + .route_layer(login_required!(AppState, login_url = "/auth/login")) .nest_service("/auth", auth_service) .nest_service("/static", static_file_service) .layer(TraceLayer::new_for_http()) @@ -99,7 +102,11 @@ async fn index() -> ReqResult> { } async fn about() -> ReqResult> { - page("index", html! { "About" }) + page("about", html! { "About" }) +} + +async fn dashboard() -> ReqResult> { + page("dashboard", html! { "Dashboard" }) } async fn users(State(state): State) -> ReqResult { diff --git a/src/service/auth.rs b/src/service/auth.rs index 709e803..f0caa26 100644 --- a/src/service/auth.rs +++ b/src/service/auth.rs @@ -1,8 +1,9 @@ use crate::router::ReqResult; -use crate::state::State as AppState; +use crate::state::{Creds, State as AppState}; use crate::{partials::*, user::User}; use axum::extract::State; -use axum::response::Html; +use axum::http::StatusCode; +use axum::response::{Html, IntoResponse, Redirect}; use axum::Form; use maud::html; use redact::Secret; @@ -63,40 +64,24 @@ async fn register() -> ReqResult> { page("login", center_hero_form("Register", form, subaction)) } -#[derive(Deserialize, Debug)] -struct Creds { - username: String, - password: Secret, -} +type AuthSession = axum_login::AuthSession; -#[instrument(skip(state))] +#[instrument(skip(auth_session))] async fn authenticate( - State(state): State, + mut auth_session: AuthSession, Form(creds): Form, -) -> ReqResult> { - let existing_user: Option = state.db.get(User::tree(), &creds.username)?; +) -> impl IntoResponse { + let user = match auth_session.authenticate(creds.clone()).await { + Ok(Some(user)) => user, + Ok(None) => return StatusCode::UNAUTHORIZED.into_response(), + Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), + }; - if existing_user.is_none() { - // timing/enumeration attacks or something - return Err(user::Error::UsernameNotFound(Box::new(creds.username)).into()); + if auth_session.login(&user).await.is_err() { + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); } - let existing_user = existing_user.unwrap(); - if let Err(err) = existing_user.verify(creds.password.expose_secret()) { - Ok(Html( - html! { - "failed to login: " ({err}) - } - .into_string(), - )) - } else { - Ok(Html( - html! { - "logged in" - } - .into_string(), - )) - } + Redirect::to("/dashboard").into_response() } #[instrument(skip(state))] diff --git a/src/state.rs b/src/state.rs index baea631..24d8c09 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,7 +1,12 @@ use crate::{ db::{self, Data}, prelude::*, + user::{self, User}, }; +use axum::async_trait; +use axum_login::{AuthnBackend, UserId}; +use redact::Secret; +use serde::Deserialize; use thiserror::Error; #[derive(Clone)] @@ -22,3 +27,50 @@ pub enum NewStateError { #[error("database error: {0}")] Database(#[from] db::Error), } + +#[derive(Deserialize, Debug, Clone)] +pub struct Creds { + pub username: String, + pub password: Secret, +} + +#[derive(Error, Debug)] +pub enum AuthError { + #[error("user error: {0}")] + User(#[from] user::Error), + + #[error("data error: {0}")] + Db(#[from] db::Error), + + #[error("data error: {0}")] + Argon2(#[from] argon2::password_hash::Error), +} + +#[async_trait] +impl AuthnBackend for State { + type User = User; + type Credentials = Creds; + type Error = AuthError; + + async fn authenticate( + &self, + Creds { username, password }: Self::Credentials, + ) -> Result, Self::Error> { + if let Some(user) = self.db.get::(User::tree(), username)? { + if let Err(err) = user.verify(password.expose_secret()) { + Err(err.into()) + } else { + Ok(Some(user)) + } + } else { + Ok(None) + } + } + + async fn get_user(&self, username: &UserId) -> Result, Self::Error> { + match self.db.get::<&str, User>(User::tree(), username)? { + Some(user) => Ok(Some(user)), + None => Ok(None), + } + } +} diff --git a/src/user.rs b/src/user.rs index f2011ab..3175935 100644 --- a/src/user.rs +++ b/src/user.rs @@ -1,17 +1,20 @@ use crate::prelude::*; +use axum_login::AuthUser; use chrono::Utc; use redact::{expose_secret, Secret}; use serde::{Deserialize, Serialize}; use thiserror::Error; +use uuid::{NoContext, Uuid}; pub const USER_TREE: &str = "user"; -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct User { + pub id: Uuid, pub username: String, #[serde(serialize_with = "expose_secret")] - password_digest: Secret>, + password_digest: Secret, pub registered_at: chrono::DateTime, } @@ -31,9 +34,16 @@ impl User { } pub fn try_new(username: &str, password: &str) -> Result { + let now = Utc::now(); + let ts = uuid::Timestamp::from_unix( + NoContext, + u64::from_ne_bytes(now.timestamp().to_ne_bytes()), + now.timestamp_subsec_micros(), + ); Ok(Self { + id: Uuid::new_v7(ts), username: username.to_owned(), - registered_at: Utc::now(), + registered_at: now, password_digest: Secret::new(crate::auth::password_digest(password)?.into()), }) } @@ -49,3 +59,15 @@ impl TryFrom for User { bincode::deserialize(&value) } } + +impl AuthUser for User { + type Id = String; + + fn id(&self) -> Self::Id { + self.username.clone() + } + + fn session_auth_hash(&self) -> &[u8] { + self.password_digest.expose_secret().as_bytes() + } +}