diff --git a/Cargo.lock b/Cargo.lock index 5ba9d9e..7fea9cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -59,6 +59,18 @@ version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" +[[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 = "assert-json-diff" version = "2.0.2" @@ -155,6 +167,26 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-login" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0da8e8e4cf127a9b71b578e9a8fa9833e70f893e428ed5453c85e44bf0fd8eb" +dependencies = [ + "async-trait", + "axum", + "form_urlencoded", + "serde", + "subtle", + "thiserror", + "tower-cookies", + "tower-layer", + "tower-service", + "tower-sessions", + "tracing", + "urlencoding", +] + [[package]] name = "axum-test" version = "17.3.0" @@ -236,6 +268,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" @@ -671,8 +712,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.11.0+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -1224,6 +1267,16 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + [[package]] name = "num-bigint-dig" version = "0.8.4" @@ -1292,6 +1345,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + [[package]] name = "parking" version = "2.2.1" @@ -1321,6 +1380,35 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "password-auth" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2a4764cc1f8d961d802af27193c6f4f0124bd0e76e8393cf818e18880f0524" +dependencies = [ + "argon2", + "getrandom 0.2.15", + "password-hash", + "rand_core 0.6.4", +] + +[[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 = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -1557,6 +1645,28 @@ dependencies = [ "windows-sys 0.52.0", ] +[[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 = "rsa" version = "0.9.8" @@ -1830,6 +1940,15 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" @@ -2103,17 +2222,23 @@ checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" name = "stationslauf" version = "0.1.0" dependencies = [ + "async-trait", "axum", + "axum-login", "axum-test", "chrono", "dotenv", "maud", + "password-auth", "rust-i18n", "serde", "sqlx", + "thiserror", "tokio", "tower-sessions", + "tower-sessions-sqlx-store-chrono", "tracing", + "tracing-subscriber", ] [[package]] @@ -2194,6 +2319,16 @@ dependencies = [ "syn", ] +[[package]] +name = "thread_local" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +dependencies = [ + "cfg-if", + "once_cell", +] + [[package]] name = "time" version = "0.3.41" @@ -2417,6 +2552,22 @@ dependencies = [ "tower-sessions-core", ] +[[package]] +name = "tower-sessions-sqlx-store-chrono" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b295c8fc08db03246e92773c5e10119b72db6bc4240112135bebb0e49670804f" +dependencies = [ + "async-trait", + "axum", + "chrono", + "rmp-serde", + "sqlx", + "thiserror", + "time", + "tower-sessions-core", +] + [[package]] name = "tracing" version = "0.1.41" @@ -2447,6 +2598,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", ] [[package]] @@ -2522,6 +2699,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf16_iter" version = "1.0.5" @@ -2534,6 +2717,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "vcpkg" version = "0.2.15" @@ -2663,6 +2852,22 @@ dependencies = [ "wasite", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.9" @@ -2672,6 +2877,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.61.0" diff --git a/Cargo.toml b/Cargo.toml index 49d885a..906f47d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ edition = "2024" [dependencies] axum = "0.8" +axum-login = "0.17" chrono = { version = "0.4", features = ["serde"]} dotenv = "0.15" maud = { version = "0.27", features = ["axum"] } @@ -14,6 +15,12 @@ tokio = { version = "1.44", features = ["macros", "rt-multi-thread"] } tower-sessions = "0.14" tracing = "0.1" rust-i18n = "3" +thiserror = "2.0" +async-trait = "0.1" +password-auth = "1.0" +tower-sessions-sqlx-store-chrono = { version = "0.14", features = ["sqlite"] } +tracing-subscriber = "0.3" + [dev-dependencies] axum-test = "17.3" diff --git a/migration.sql b/migration.sql index f102961..25ca245 100644 --- a/migration.sql +++ b/migration.sql @@ -47,4 +47,15 @@ CREATE TABLE IF NOT EXISTS rating ( FOREIGN KEY (station_id) REFERENCES station(id) ); +CREATE TABLE IF NOT EXISTS user ( + id INTEGER PRIMARY KEY NOT NULL, + name TEXT NOT NULL UNIQUE, + pw TEXT NOT NULL +); + +create table if not exists "tower_sessions" ( + id text primary key not null, + data blob not null, + expiry_date integer not null +); diff --git a/seeds.sql b/seeds.sql new file mode 100644 index 0000000..c0d3260 --- /dev/null +++ b/seeds.sql @@ -0,0 +1 @@ +insert into user(name, pw) values('a', '$argon2i$v=19$m=16,t=2,p=1$b2lmaG9pMzJvNDk$vXbHg45vkuMrQaP0XY184Q'); // pw = 123 diff --git a/src/admin/mod.rs b/src/admin/mod.rs index 674951b..4cade01 100644 --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -1,6 +1,7 @@ -use crate::{AppState, page}; -use axum::{Router, routing::get}; -use maud::{Markup, html}; +use crate::{auth::Backend, page, AppState}; +use axum::{routing::get, Router}; +use axum_login::login_required; +use maud::{html, Markup}; use tower_sessions::Session; pub(crate) mod route; @@ -10,6 +11,9 @@ pub(crate) mod team; async fn index(session: Session) -> Markup { let content = html! { h1 { (t!("app_name")) } + a href="/auth/logout" { + "Ausloggen" + } nav { ul { li { @@ -39,4 +43,5 @@ pub(super) fn routes() -> Router { .nest("/station", station::routes()) .nest("/route", route::routes()) .nest("/team", team::routes()) + .layer(login_required!(Backend, login_url = "/auth/login")) } diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..7ff9729 --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,150 @@ +use crate::{err, page, succ, AppState}; +use async_trait::async_trait; +use axum::{ + http::StatusCode, + response::{IntoResponse, Redirect}, + routing::{get, post}, + Form, Router, +}; +use axum_login::{AuthUser, AuthnBackend}; +use maud::{html, Markup}; +use password_auth::verify_password; +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, SqlitePool}; +use tower_sessions::Session; + +pub type UserId = <::User as AuthUser>::Id; + +#[derive(Clone, Serialize, Deserialize, FromRow, Debug)] +pub struct User { + id: i64, + name: String, + pw: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Credentials { + pub name: String, + pub password: String, +} + +impl AuthUser for User { + type Id = i64; + + fn id(&self) -> Self::Id { + self.id + } + + fn session_auth_hash(&self) -> &[u8] { + &self.pw.as_bytes() + } +} + +#[derive(Debug, Clone)] +pub struct Backend { + db: SqlitePool, +} + +impl Backend { + pub(crate) fn new(db: SqlitePool) -> Self { + Self { db } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error(transparent)] + Sqlx(#[from] sqlx::Error), +} + +#[async_trait] +impl AuthnBackend for Backend { + type User = User; + type Credentials = Credentials; + type Error = Error; + + async fn authenticate( + &self, + creds: Self::Credentials, + ) -> Result, Self::Error> { + let user: Option = + sqlx::query_as("SELECT id, name, pw FROM user WHERE name = ? ") + .bind(creds.name) + .fetch_optional(&self.db) + .await?; + + // We're using password-based authentication--this works by comparing our form + // input with an argon2 password hash. + Ok(user.filter(|user| verify_password(creds.password, &user.pw).is_ok())) + } + + async fn get_user(&self, user_id: &UserId) -> Result, Self::Error> { + let user = sqlx::query_as("SELECT id, name, pw FROM user WHERE id = ?") + .bind(user_id) + .fetch_optional(&self.db) + .await?; + + Ok(user) + } +} + +pub type AuthSession = axum_login::AuthSession; + +pub fn routes() -> Router { + Router::new() + .route("/login", get(self::login)) + .route("/login", post(self::login_post)) + .route("/logout", get(self::logout)) +} + +async fn login(session: Session) -> Markup { + let content = html! { + h1 { "Login" } + form action="/auth/login" method="post" { + label { + "Name" + input type="text" name="name"; + } + label { + "Passwort" + input type="password" name="password"; + } + input type="submit" value="Einloggen"; + } + }; + + // TODO: generate okayish looking login page + + page(content, session, false).await +} + +pub async fn login_post( + mut auth_session: AuthSession, + session: Session, + Form(creds): Form, +) -> impl IntoResponse { + let user = match auth_session.authenticate(creds.clone()).await { + Ok(Some(user)) => user, + Ok(None) => { + err!(session, "Invalid credentials"); + + return Redirect::to("/auth/login").into_response(); + } + Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), + }; + + if auth_session.login(&user).await.is_err() { + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + + succ!(session, "Successfully logged in as {}", user.name); + + Redirect::to("/admin").into_response() +} + +pub async fn logout(mut auth_session: AuthSession) -> impl IntoResponse { + match auth_session.logout().await { + Ok(_) => Redirect::to("/auth/login").into_response(), + Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } +} diff --git a/src/lib.rs b/src/lib.rs index aa33404..6c55205 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -17,14 +17,18 @@ macro_rules! testdb { i18n!("locales", fallback = "de-AT"); use admin::station::Station; -use axum::{Router, body::Body, extract::FromRef, response::Response, routing::get}; +use auth::Backend; +use axum::{body::Body, extract::FromRef, response::Response, routing::get, Router}; +use axum_login::AuthManagerLayerBuilder; use partials::page; use sqlx::SqlitePool; use std::sync::Arc; use tokio::net::TcpListener; -use tower_sessions::{MemoryStore, SessionManagerLayer}; +use tower_sessions::{cookie::time::Duration, Expiry, SessionManagerLayer}; +use tower_sessions_sqlx_store_chrono::SqliteStore; pub(crate) mod admin; +mod auth; pub(crate) mod models; mod partials; pub(crate) mod station; @@ -128,21 +132,28 @@ impl FromRef for Arc { } fn router(db: SqlitePool) -> Router { - let session_store = MemoryStore::default(); - let session_layer = SessionManagerLayer::new(session_store); + let session_store = SqliteStore::new(db.clone()); + + let session_layer = SessionManagerLayer::new(session_store) + .with_secure(false) + .with_expiry(Expiry::OnInactivity(Duration::weeks(2))); + + let backend = Backend::new(db.clone()); + let auth_layer = AuthManagerLayerBuilder::new(backend, session_layer).build(); let state = AppState { db: Arc::new(db) }; Router::new() .nest("/s/{id}/{code}", station::routes()) // TODO: maybe switch to "/" .nest("/admin", admin::routes()) + .nest("/auth", auth::routes()) .route("/pico.css", get(serve_pico_css)) .route("/style.css", get(serve_my_css)) .route("/leaflet.css", get(serve_leaflet_css)) .route("/leaflet.js", get(serve_leaflet_js)) .route("/marker.png", get(serve_marker_png)) .with_state(state) - .layer(session_layer) + .layer(auth_layer) } /// Starts the main application. diff --git a/src/main.rs b/src/main.rs index 90e2894..ee2fd9d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,17 @@ use dotenv::dotenv; -use sqlx::{SqlitePool, pool::PoolOptions}; +use sqlx::{pool::PoolOptions, SqlitePool}; use std::env; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; #[tokio::main] async fn main() { dotenv().ok(); // load .env variables + // Logging + tracing_subscriber::registry() + .with(tracing_subscriber::fmt::layer()) + .init(); + // DB let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set"); let db: SqlitePool = PoolOptions::new().connect(&database_url).await.unwrap(); diff --git a/test_db.sh b/test_db.sh index 4237272..f348f15 100755 --- a/test_db.sh +++ b/test_db.sh @@ -3,4 +3,5 @@ rm -f db.sqlite touch db.sqlite sqlite3 db.sqlite < migration.sql +sqlite3 db.sqlite < seeds.sql