diff --git a/Cargo.lock b/Cargo.lock index 272f479..79748b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "allocator-api2" version = "0.2.21" @@ -150,7 +156,9 @@ dependencies = [ "axum-extra", "chrono", "dotenv", + "hex", "jsonwebtoken", + "rand", "serde", "serde_json", "sqlx", @@ -226,12 +234,24 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "bytemuck" +version = "1.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424" + [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.1" @@ -354,6 +374,15 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-queue" version = "0.3.12" @@ -585,6 +614,15 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "ff" version = "0.13.1" @@ -607,6 +645,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "flume" version = "0.11.1" @@ -658,11 +706,14 @@ dependencies = [ name = "frontend" version = "0.1.0" dependencies = [ + "base64", "chrono", "chrono-tz", + "dotenv", "gloo 0.12.0", "gloo-net 0.7.0", "gloo-storage 0.4.0", + "qrcode-generator", "serde", "serde_json", "theme", @@ -1504,6 +1555,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "html-escape" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c1ff2d1cbf39efe5af0900ced8a069b5e61557a17544eb0c4a50239937389e" + [[package]] name = "http" version = "0.2.12" @@ -1728,6 +1785,19 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", +] + [[package]] name = "implicit-clone" version = "0.4.9" @@ -1915,6 +1985,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.0" @@ -1926,6 +2006,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "native-tls" version = "0.2.18" @@ -2242,6 +2332,19 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -2354,6 +2457,29 @@ dependencies = [ "wasm-bindgen-futures", ] +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "qrcode-generator" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0051849b5465059b75f59d388c7318aad6554701b74ecf02afc2573b0306c" +dependencies = [ + "html-escape", + "image", + "qrcodegen", +] + +[[package]] +name = "qrcodegen" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4339fc7a1021c9c1621d87f5e3505f2805c8c105420ba2f2a4df86814590c142" + [[package]] name = "quote" version = "1.0.45" @@ -2684,6 +2810,12 @@ dependencies = [ "rand_core", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "simple_asn1" version = "0.6.4" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index f43c09e..4d3bd5f 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -17,3 +17,5 @@ argon2 = "0.5.3" time = "0.3.47" tower = "0.5.3" tower-http = { version = "0.6.8", features = ["cors"] } +rand = '0.8' +hex = '0.4' diff --git a/backend/migrations/20260716135845_qr_token.down.sql b/backend/migrations/20260716135845_qr_token.down.sql new file mode 100644 index 0000000..76905ea --- /dev/null +++ b/backend/migrations/20260716135845_qr_token.down.sql @@ -0,0 +1 @@ +DROP TABLE qr_codes; diff --git a/backend/migrations/20260716135845_qr_token.sql b/backend/migrations/20260716135845_qr_token.sql new file mode 100644 index 0000000..c3f8e56 --- /dev/null +++ b/backend/migrations/20260716135845_qr_token.sql @@ -0,0 +1,8 @@ +CREATE TABLE qr_codes ( + id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + user_id SMALLINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash VARCHAR(255) NOT NULL UNIQUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_qr_token_hash ON qr_codes(token_hash); diff --git a/backend/src/handlers/mod.rs b/backend/src/handlers/mod.rs index 4a03e5e..85bfba5 100644 --- a/backend/src/handlers/mod.rs +++ b/backend/src/handlers/mod.rs @@ -3,4 +3,5 @@ //! It serves as a central point for managing the logic that responds to various //! HTTP requests, categorizing handlers by their domain (e.g., authentication, tickets). pub mod auth; +pub mod qr; pub mod ticket; diff --git a/backend/src/handlers/qr.rs b/backend/src/handlers/qr.rs new file mode 100644 index 0000000..95a5c67 --- /dev/null +++ b/backend/src/handlers/qr.rs @@ -0,0 +1,91 @@ +use std::sync::Arc; + +use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier, password_hash::SaltString}; +use axum::{ + Json, + extract::{Query, State}, + http::StatusCode, + response::{IntoResponse, Redirect}, +}; +use rand::{RngCore, thread_rng}; +use serde::{Deserialize, Serialize}; +use sqlx::Row; + +use crate::AppState; + +#[derive(Serialize)] +pub struct QrCodeResponse { + pub raw_token: String, +} + +#[derive(Serialize, Deserialize)] +pub struct AuthQuery { + pub token: String, +} + +#[derive(Deserialize)] +pub struct GenerateQrRequest { + pub user_id: i16, +} + +pub async fn generate_qr_token( + State(data): State>, + Json(payload): Json, +) -> Result { + let mut token_bytes = [0u8; 32]; + thread_rng().fill_bytes(&mut token_bytes); + let raw_token = hex::encode(token_bytes); + + let salt = SaltString::generate(&mut thread_rng()); + + let argon2 = Argon2::default(); + let token_hash = argon2 + .hash_password(raw_token.as_bytes(), &salt) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .to_string(); + + sqlx::query("INSERT INTO qr_codes (user_id, token_hash) VALUES ($1, $2)") + .bind(payload.user_id) + .bind(token_hash) + .execute(&data.db) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok((StatusCode::CREATED, Json(QrCodeResponse { raw_token }))) +} + +pub async fn validate_qr_token( + State(data): State>, + Query(params): Query, +) -> Result { + println!("Incoming token: {}", params.token); + let active_tokens = sqlx::query("SELECT user_id, token_hash FROM qr_codes") + .fetch_all(&data.db) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let mut authenticated_user_id: Option = None; + let argon2 = Argon2::default(); + + for record in active_tokens { + let user_id: i16 = record.get("user_id"); + let token_hash: String = record.get("token_hash"); + + if let Ok(parsed_hash) = PasswordHash::new(&token_hash) { + if argon2 + .verify_password(params.token.as_bytes(), &parsed_hash) + .is_ok() + { + authenticated_user_id = Some(user_id); + break; + } + } + } + + if let Some(_user_id) = authenticated_user_id { + let redirect_url = format!("{}/", data.env.origin.trim_end_matches('/')); + Ok(Redirect::to(&redirect_url)) + } else { + Err(StatusCode::UNAUTHORIZED) + } +} diff --git a/backend/src/router.rs b/backend/src/router.rs index 0c37299..2d84671 100644 --- a/backend/src/router.rs +++ b/backend/src/router.rs @@ -10,9 +10,10 @@ use crate::{ cookie::validation::{validate_admin, validate_token}, handlers::{ auth::{ - check_admin_exists, create_user, delete_user, get_current_user, get_user_by_id, get_users, login, logout, - setup_initial_admin, update_user, + check_admin_exists, create_user, delete_user, get_current_user, get_user_by_id, + get_users, login, logout, setup_initial_admin, update_user, }, + qr::{generate_qr_token, validate_qr_token}, ticket::{create_ticket, delete_ticket, edit_ticket, get_ticket_by_id, get_tickets}, }, }; @@ -63,6 +64,7 @@ pub fn create_router(state: Arc) -> Router { "/api/users/{id}", get(get_user_by_id).delete(delete_user).patch(update_user), ) + .route("/api/generate_qr", post(generate_qr_token)) .layer(middleware::from_fn_with_state( state.clone(), validate_admin, @@ -79,10 +81,14 @@ pub fn create_router(state: Arc) -> Router { validate_token, )); - Router::new() - .merge(protected_routes) + let public_routes = Router::new() .route("/api/login", post(login)) .route("/api/check-admin", get(check_admin_exists)) .route("/api/setup-admin", post(setup_initial_admin)) + .route("/api/qr_login", get(validate_qr_token)); + + Router::new() + .merge(protected_routes) + .merge(public_routes) .with_state(state) } diff --git a/frontend/Cargo.toml b/frontend/Cargo.toml index e4c6c43..dcd5875 100644 --- a/frontend/Cargo.toml +++ b/frontend/Cargo.toml @@ -27,3 +27,6 @@ chrono = { workspace = true } chrono-tz = "0.10.4" wasm-bindgen = "0.2.120" theme = { version = "0.0.3", features = ["yew"] } +qrcode-generator = "5.0.0" +base64 = '0.22' +dotenv = '0.15.0' diff --git a/frontend/Trunk.toml b/frontend/Trunk.toml index 873b4a7..f218f3a 100644 --- a/frontend/Trunk.toml +++ b/frontend/Trunk.toml @@ -1,6 +1,6 @@ [serve] # The address to serve on LAN. -addresses = ["127.0.0.1","192.168.2.157"] #,"10.150.9.7"] +addresses = ["127.0.0.1"] #,"10.150.9.7"] # The address to serve on WAN. # address = "0.0.0.0" # The port to serve on. diff --git a/frontend/src/lib.rs b/frontend/src/lib.rs index f1d663a..0b53a5e 100644 --- a/frontend/src/lib.rs +++ b/frontend/src/lib.rs @@ -138,7 +138,6 @@ fn sidebar_shell(props: &SidebarShellProps) -> Html { } } - /// Props for the AdminCheckWrapper component. #[derive(Properties, PartialEq)] pub struct AdminCheckWrapperProps { diff --git a/frontend/src/pages/mod.rs b/frontend/src/pages/mod.rs index 33a2bea..3e50df9 100644 --- a/frontend/src/pages/mod.rs +++ b/frontend/src/pages/mod.rs @@ -6,6 +6,7 @@ //! By re-exporting them here, other parts of the application can import page //! components more conveniently using `crate::pages::*`. pub mod basic_pages; +pub mod qr; pub mod setup; pub mod sidebar; pub mod ticket; diff --git a/frontend/src/pages/qr.rs b/frontend/src/pages/qr.rs new file mode 100644 index 0000000..9d55b25 --- /dev/null +++ b/frontend/src/pages/qr.rs @@ -0,0 +1,118 @@ +use base64::{Engine, engine::general_purpose::STANDARD}; +use gloo_net::http::Request; +use serde::{Deserialize, Serialize}; +use yew::prelude::*; + +#[derive(Deserialize, Clone)] +struct QrResponse { + raw_token: String, +} +#[derive(Properties, PartialEq)] +pub struct QrManagerProps { + pub user_id: i16, +} + +#[derive(Serialize)] +pub struct QrPayload { + user_id: i16, +} + +#[function_component(QrManager)] +pub fn qr_manager(props: &QrManagerProps) -> Html { + let qr_image_data = use_state(|| None::); + let error_message = use_state(|| None::); + let user_id = props.user_id; + + let on_generate_click = { + let qr_image_data = qr_image_data.clone(); + let error_message = error_message.clone(); + + Callback::from(move |_| { + let qr_image_data = qr_image_data.clone(); + let error_message = error_message.clone(); + + wasm_bindgen_futures::spawn_local(async move { + let payload = QrPayload { user_id }; + + match Request::post("/api/generate_qr") + .json(&payload) + .unwrap() + .send() + .await + { + Ok(response) if response.ok() => { + if let Ok(data) = response.json::().await { + // Point scanned link to the public login endpoint with the token + let target_url = format!( + "{}/api/qr_login?token={}", + std::env::var("ORIGIN") + .ok() + .unwrap_or("http://localhost:8000".to_string()), + data.raw_token + ); + + match qrcode_generator::to_png_to_vec( + target_url, + qrcode_generator::QrCodeEcc::Medium, + 250, + ) { + Ok(png_bytes) => { + let base64_png = format!( + "data:image/png;base64,{}", + STANDARD.encode(&png_bytes) + ); + qr_image_data.set(Some(base64_png)); + error_message.set(None); + } + Err(_) => { + error_message + .set(Some("Failed to render QR graphic.".to_string())); + } + } + } + } + _ => { + error_message.set(Some( + "Failed to generate token. Are you logged in as an admin?".to_string(), + )); + } + } + }); + }) + }; + + html! { +
+

{"Permanent Login Access"}

+

+ { format!("Generate a permanent bypass login QR code for User #{}", user_id) } +

+ + + + { if let Some(ref err) = *error_message { + html! {

{err}

} + } else { + html! {} + }} + + { if let Some(ref qr_src) = *qr_image_data { + html! { +
+ Login QR Code +

+ {"Warn the user to keep this QR code secure!"} +

+
+ } + } else { + html! {} + }} +
+ } +} diff --git a/frontend/src/pages/user.rs b/frontend/src/pages/user.rs index 87b7f08..d660980 100644 --- a/frontend/src/pages/user.rs +++ b/frontend/src/pages/user.rs @@ -185,7 +185,7 @@ pub fn register_component() -> Html { Ok(response) => { let text = response.text().await.unwrap_or_else(|_| "Unbekannt".into()); status.set(Some(text)); - }, + } Err(err) => status.set(Some(format!("Netzwerkfehler: {}", err))), } }); @@ -688,7 +688,8 @@ pub fn user_by_id_component(props: &UserProps) -> Html {

{ "Ist Admin: " }{ u.is_admin }

-

{ format!("User #{}", u.id) }

+ +