From 3b16cce62a9fb2f8fceb0fdceb7aa569ee5e6b21 Mon Sep 17 00:00:00 2001 From: Chris Jean-Marie Date: Sun, 28 Sep 2025 04:02:02 +0000 Subject: [PATCH] Initial device fingerprinting and auto login --- backend/Cargo.lock | 16 +- backend/Cargo.toml | 4 + backend/KioskNotes.txt | 190 ++++++++++ .../20250828172009_fingerprint.down.sql | 2 + .../20250828172009_fingerprint.up.sql | 33 ++ .../migrations/20250924025437_apikey.down.sql | 1 + .../migrations/20250924025437_apikey.up.sql | 8 + backend/src/fingerprint.rs | 338 ++++++++++++++++++ backend/src/google_oauth.rs | 7 +- backend/src/kiosk.rs | 100 ++++++ backend/src/main.rs | 104 +++++- backend/src/routes.rs | 2 +- backend/templates/base copy.html | 154 ++++++++ backend/templates/debug.html | 5 + backend/templates/device.html | 2 + backend/templates/deviceadmin.html | 5 + backend/templates/orig_device.html | 207 +++++++++++ backend/templates/orig_deviceadmin.html | 201 +++++++++++ 18 files changed, 1357 insertions(+), 22 deletions(-) create mode 100644 backend/KioskNotes.txt create mode 100644 backend/migrations/20250828172009_fingerprint.down.sql create mode 100644 backend/migrations/20250828172009_fingerprint.up.sql create mode 100644 backend/migrations/20250924025437_apikey.down.sql create mode 100644 backend/migrations/20250924025437_apikey.up.sql create mode 100644 backend/src/fingerprint.rs create mode 100644 backend/src/kiosk.rs create mode 100644 backend/templates/base copy.html create mode 100644 backend/templates/debug.html create mode 100644 backend/templates/device.html create mode 100644 backend/templates/deviceadmin.html create mode 100644 backend/templates/orig_device.html create mode 100644 backend/templates/orig_deviceadmin.html diff --git a/backend/Cargo.lock b/backend/Cargo.lock index c480353..f84f3c4 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "addr2line" @@ -182,9 +182,9 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.88" +version = "0.1.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", @@ -781,6 +781,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "filters" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ea2a3582a3bb84139554126bc608bf3e2a1ced8354dbdef38c41140ca10b3e3" + [[package]] name = "flate2" version = "1.1.1" @@ -1529,6 +1535,7 @@ version = "0.1.1" dependencies = [ "askama", "askama_axum", + "async-trait", "axum", "axum-extra", "axum-server", @@ -1536,13 +1543,16 @@ dependencies = [ "chrono", "constant_time_eq", "dotenvy", + "filters", "headers", + "hex", "http 1.3.1", "lettre", "oauth2", "reqwest 0.12.19", "serde", "serde_json", + "sha2", "sqlx", "tokio", "tower-http", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index c54366c..48ebfcd 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -29,3 +29,7 @@ dotenvy = "0.15" constant_time_eq = "0.3" reqwest = "0.12" lettre = "0.11.10" +sha2 = "0.10.9" +hex = "0.4.3" +async-trait = "0.1.89" +filters = "0.4.0" diff --git a/backend/KioskNotes.txt b/backend/KioskNotes.txt new file mode 100644 index 0000000..b04a5f0 --- /dev/null +++ b/backend/KioskNotes.txt @@ -0,0 +1,190 @@ +For a kiosk machine that needs to always auto-login, here's the most practical approach for your Rust/Axum stack: + +## Recommended Solution: Device-Specific Token Authentication + +### 1. Database Schema +```sql +-- Add to your PostgreSQL database +CREATE TABLE kiosk_devices ( + id SERIAL PRIMARY KEY, + device_name VARCHAR(255) NOT NULL, + device_token VARCHAR(255) UNIQUE NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_accessed TIMESTAMP, + is_active BOOLEAN DEFAULT true +); +``` + +### 2. Axum Route Handler +```rust +use axum::{ + extract::{Query, State}, + response::{Html, Redirect}, + http::StatusCode, +}; +use serde::Deserialize; + +#[derive(Deserialize)] +struct AutoLoginQuery { + token: String, +} + +async fn auto_login( + Query(params): Query, + State(app_state): State, // Your app state with DB pool +) -> Result { + // Validate the device token + let device = sqlx::query!( + "SELECT id, device_name FROM kiosk_devices + WHERE device_token = $1 AND is_active = true", + params.token + ) + .fetch_optional(&app_state.db) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + match device { + Some(device) => { + // Update last accessed + sqlx::query!( + "UPDATE kiosk_devices SET last_accessed = CURRENT_TIMESTAMP WHERE id = $1", + device.id + ) + .execute(&app_state.db) + .await + .ok(); + + // Set session/cookie and redirect to dashboard + Ok(Redirect::to("/kiosk-dashboard")) + } + None => Err(StatusCode::UNAUTHORIZED), + } +} +``` + +### 3. Generate Device Token +```rust +use uuid::Uuid; +use sha2::{Sha256, Digest}; + +fn generate_device_token() -> String { + let uuid = Uuid::new_v4(); + let mut hasher = Sha256::new(); + hasher.update(uuid.as_bytes()); + format!("{:x}", hasher.finalize())[..32].to_string() +} + +// Store this in your database for the kiosk +async fn register_kiosk_device(db: &PgPool, device_name: &str) -> Result { + let token = generate_device_token(); + + sqlx::query!( + "INSERT INTO kiosk_devices (device_name, device_token) VALUES ($1, $2)", + device_name, + token + ) + .execute(db) + .await?; + + Ok(token) +} +``` + +### 4. Kiosk Setup +Configure the kiosk machine to: + +**Option A: Browser Homepage** +Set browser homepage to: `https://yoursite.com/auto-login?token=YOUR_GENERATED_TOKEN` + +**Option B: Desktop Shortcut** +Create a desktop shortcut with the auto-login URL + +**Option C: Startup Script** +```bash +#!/bin/bash +# startup.sh - runs on boot +sleep 10 # wait for network +chromium-browser --kiosk --no-sandbox "https://yoursite.com/auto-login?token=YOUR_TOKEN" +``` + +### 5. Enhanced Security (Optional) +If you want additional security: + +```rust +#[derive(Deserialize)] +struct AutoLoginQuery { + token: String, + timestamp: Option, + signature: Option, +} + +async fn auto_login_with_signature( + Query(params): Query, + // ... other params +) -> Result { + // Verify timestamp (within 5 minutes) + if let (Some(timestamp), Some(signature)) = (params.timestamp, params.signature) { + let now = chrono::Utc::now().timestamp(); + if (now - timestamp).abs() > 300 { // 5 minutes + return Err(StatusCode::UNAUTHORIZED); + } + + // Verify signature (HMAC with shared secret) + let expected_sig = create_signature(¶ms.token, timestamp); + if signature != expected_sig { + return Err(StatusCode::UNAUTHORIZED); + } + } + + // ... rest of validation +} +``` + +### 6. Session Management +After successful auto-login, set a session cookie: + +```rust +use tower_cookies::{Cookie, Cookies}; + +async fn auto_login( + Query(params): Query, + cookies: Cookies, + // ... other params +) -> Result { + // ... validation logic ... + + if let Some(device) = device { + // Set session cookie + let session_cookie = Cookie::build(("kiosk_session", device.id.to_string())) + .path("/") + .http_only(true) + .max_age(time::Duration::hours(24)) + .build(); + + cookies.add(session_cookie); + + Ok(Redirect::to("/kiosk-dashboard")) + } else { + Err(StatusCode::UNAUTHORIZED) + } +} +``` + +### 7. Route Configuration +```rust +let app = Router::new() + .route("/auto-login", get(auto_login)) + .route("/kiosk-dashboard", get(kiosk_dashboard)) + // ... other routes + .layer(CookieManagerLayer::new()) + .with_state(app_state); +``` + +This approach gives you: +- Secure device authentication +- Easy kiosk setup +- Audit trail of access +- Ability to revoke access +- No user interaction required + +The kiosk will automatically authenticate and access your application whenever it starts up or navigates to the URL. \ No newline at end of file diff --git a/backend/migrations/20250828172009_fingerprint.down.sql b/backend/migrations/20250828172009_fingerprint.down.sql new file mode 100644 index 0000000..b559c90 --- /dev/null +++ b/backend/migrations/20250828172009_fingerprint.down.sql @@ -0,0 +1,2 @@ +-- Delete all fingerprint items +drop table if exists devices; \ No newline at end of file diff --git a/backend/migrations/20250828172009_fingerprint.up.sql b/backend/migrations/20250828172009_fingerprint.up.sql new file mode 100644 index 0000000..27b8156 --- /dev/null +++ b/backend/migrations/20250828172009_fingerprint.up.sql @@ -0,0 +1,33 @@ +-- Create devices table +CREATE TABLE IF NOT EXISTS devices ( + id uuid PRIMARY KEY default gen_random_uuid(), + created_at timestamp NOT NULL default now(), + created_by uuid NOT NULL, + updated_at timestamp NOT NULL default now(), + updated_by uuid NOT NULL, + fingerprint_hash VARCHAR(64) UNIQUE NOT NULL, + device_name VARCHAR(255), + first_seen TIMESTAMP NOT NULL DEFAULT NOW(), + last_seen TIMESTAMP NOT NULL DEFAULT NOW(), + user_agent TEXT +); + +-- Create indexes for better performance +CREATE INDEX IF NOT EXISTS idx_devices_fingerprint_hash ON devices(fingerprint_hash); +CREATE INDEX IF NOT EXISTS idx_devices_last_seen ON devices(last_seen); + +-- Create function to automatically update updated_at timestamp +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ language 'plpgsql'; + +-- Create trigger to automatically update updated_at +DROP TRIGGER IF EXISTS update_devices_updated_at ON devices; +CREATE TRIGGER update_devices_updated_at + BEFORE UPDATE ON devices + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); diff --git a/backend/migrations/20250924025437_apikey.down.sql b/backend/migrations/20250924025437_apikey.down.sql new file mode 100644 index 0000000..1769fc9 --- /dev/null +++ b/backend/migrations/20250924025437_apikey.down.sql @@ -0,0 +1 @@ +DROP TABLE if exists kiosk_devices; diff --git a/backend/migrations/20250924025437_apikey.up.sql b/backend/migrations/20250924025437_apikey.up.sql new file mode 100644 index 0000000..543d3d0 --- /dev/null +++ b/backend/migrations/20250924025437_apikey.up.sql @@ -0,0 +1,8 @@ +CREATE TABLE kiosk_devices ( + id SERIAL PRIMARY KEY, + device_name VARCHAR(255) NOT NULL, + device_token VARCHAR(255) UNIQUE NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_accessed TIMESTAMP, + is_active BOOLEAN DEFAULT true +); diff --git a/backend/src/fingerprint.rs b/backend/src/fingerprint.rs new file mode 100644 index 0000000..b3cb80a --- /dev/null +++ b/backend/src/fingerprint.rs @@ -0,0 +1,338 @@ +use askama::Template; +use axum::{ + extract::{FromRequestParts, State}, + http::{request::Parts, StatusCode}, + response::{IntoResponse, Json, Redirect}, Extension, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use sqlx::{postgres::PgPool, FromRow}; + +use crate::{user::AccountData, AppState, routes::HtmlTemplate}; + +// Database models +#[derive(Debug, FromRow, Serialize, Deserialize, Clone)] +pub struct Device { + id: uuid::Uuid, + fingerprint_hash: String, + device_name: Option, + first_seen: chrono::DateTime, + last_seen: chrono::DateTime, + user_agent: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct FingerprintData { + user_agent: String, + screen_width: i32, + screen_height: i32, + timezone: String, + language: String, + platform: String, + canvas_fingerprint: String, + fonts: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +struct UpdateDeviceRequest { + device_name: String, +} + +// Fingerprint service +#[derive(Debug, Clone)] +pub struct FingerprintService { + db: PgPool, +} + +impl FingerprintService { + pub fn new(db: PgPool) -> Self { + Self { db } + } + + pub async fn get_device(&self, fingerprint_hash: &str) -> Option { + sqlx::query_as( + r#" + SELECT * FROM devices + WHERE fingerprint_hash = $1 + "#, + ) + .bind(fingerprint_hash) + .fetch_optional(&self.db) + .await + .ok() + .flatten() + } +} + +// Computer identity extractor +#[derive(Debug, Clone)] +pub struct ComputerIdentity { + pub fingerprint_hash: String, + pub device: Option, +} + +impl FromRequestParts for ComputerIdentity +where + S: Send + Sync, + AppState: FromRequestParts, +{ + type Rejection = (StatusCode, &'static str); + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + // Get app state + let app_state = AppState::from_request_parts(parts, state) + .await + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Failed to get app state"))?; + + // Extract fingerprint from cookie or header + let fingerprint_hash = parts + .headers + .get("x-fingerprint") + .and_then(|h| h.to_str().ok()) + .unwrap_or_default() + .to_string(); + + if fingerprint_hash.is_empty() { + return Ok(ComputerIdentity { + fingerprint_hash: String::new(), + device: None, + }); + } + + // Try to get device from database + let device = get_device_by_fingerprint(&app_state.db, &fingerprint_hash) + .await + .ok(); + + Ok(ComputerIdentity { + fingerprint_hash, + device, + }) + } +} + +// Helper functions +fn generate_fingerprint_hash(data: &FingerprintData) -> String { + let mut hasher = Sha256::new(); + hasher.update(format!( + "{}:{}x{}:{}:{}:{}:{}:{}", + data.user_agent, + data.screen_width, + data.screen_height, + data.timezone, + data.language, + data.platform, + data.canvas_fingerprint, + data.fonts.join(",") + )); + hex::encode(hasher.finalize()) +} + +async fn get_device_by_fingerprint( + db: &PgPool, + fingerprint_hash: &str, +) -> Result { + let result = sqlx::query_as( + r#"SELECT id, fingerprint_hash, device_name, first_seen, last_seen, user_agent + FROM devices WHERE fingerprint_hash = $1"# + ) + .bind(fingerprint_hash) + .fetch_one(db) + .await; + + result +} + +async fn create_or_update_device( + db: &PgPool, + fingerprint_hash: &str, + user_agent: &str, +) -> Result { + // Try to update existing device + let result = sqlx::query( + r#"UPDATE devices SET last_seen = NOW(), user_agent = $2 + WHERE fingerprint_hash = $1 RETURNING id"# + ) + .bind(fingerprint_hash.to_string()) + .bind(user_agent.to_string()) + .fetch_optional(db) + .await?; + + if result.is_some() { + // Device exists, return it + return get_device_by_fingerprint(db, fingerprint_hash).await; + } + + // Create new device + let device_id = uuid::Uuid::new_v4(); + sqlx::query( + "INSERT INTO devices (id, fingerprint_hash, first_seen, last_seen, user_agent) + VALUES ($1, $2, NOW(), NOW(), $3)") + .bind(device_id) + .bind(fingerprint_hash.to_string()) + .bind(user_agent.to_string()) + .execute(db) + .await?; + + get_device_by_fingerprint(db, fingerprint_hash).await +} + +// Template structs +#[derive(Template)] +#[template(path = "device.html")] +struct DeviceTemplate { + device_status: String, + fingerprint_hash: String, + device: Option +} + +#[derive(Template)] +#[template(path = "deviceadmin.html")] +struct DeviceAdminTemplate { + logged_in: bool, + user: AccountData, + devices: Vec, + named_devices_count: usize, + recent_devices_count: usize, + weekly_devices_count: usize, + fingerprint_hash: String, +} + +// Route handlers +pub async fn device( + State(db_pool): State, + Extension(user_data): Extension>, + //identity: ComputerIdentity, +) -> impl IntoResponse { + // Is the user logged in? + //let logged_in = user_data.is_some(); + let logged_in = true; + + // Set empty ComputerIdentity + let identity = ComputerIdentity { + fingerprint_hash: "somehash#&".to_string(), + device: Some(Device {id: uuid::Uuid::new_v4(), fingerprint_hash: String::new(), device_name: Some("Test Device".to_string()), first_seen: chrono::Utc::now(), last_seen: chrono::Utc::now(), user_agent: None,}), + }; + + if logged_in { + // Extract the user data. + //let user = user_data.as_ref().unwrap().clone(); + //let userid = user_data.as_ref().map(|s| s.id.clone()).unwrap_or_default(); + + let device_status = if let Some(device) = &identity.device { + format!( + "Welcome back, {}! (Last seen: {})", + device.device_name.as_deref().unwrap_or("Unknown Device"), + device.last_seen.format("%Y-%m-%d %H:%M:%S") + ) + } else if identity.fingerprint_hash.is_empty() { + "Detecting your device...".to_string() + } else { + "New device detected!".to_string() + }; + + + return HtmlTemplate(DeviceTemplate { + device_status, + fingerprint_hash: identity.fingerprint_hash, + device: identity.device, + }).into_response(); + } else { + Redirect::to("/").into_response() + } +} + +pub async fn handle_fingerprint( + db_pool: &PgPool, + Json(fingerprint_data): Json, +) -> Result, StatusCode> { + let fingerprint_hash = generate_fingerprint_hash(&fingerprint_data); + + match create_or_update_device(&db_pool, &fingerprint_hash, &fingerprint_data.user_agent).await { + Ok(_) => Ok(Json(serde_json::json!({ + "fingerprint_hash": fingerprint_hash, + "status": "success" + }))), + Err(e) => { + tracing::error!("Database error: {}", e); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } +} + +pub async fn update_device_name( + db_pool: &PgPool, + identity: ComputerIdentity, + Json(request): Json, +) -> impl IntoResponse { + if identity.fingerprint_hash.is_empty() { + return Err(StatusCode::BAD_REQUEST); + } + + match sqlx::query( + r#"UPDATE devices SET device_name = $1 WHERE fingerprint_hash = $2"#) + .bind( + if request.device_name.is_empty() { + None + } else { + Some(&request.device_name) + } + ) + .bind(identity.fingerprint_hash) + .execute(db_pool) + .await + { + Ok(_) => Ok("status: success".to_string().into_response()), + Err(e) => { + tracing::error!("Database error: {}", e); + Ok(format!("Database error: {}", e).to_string().into_response()) + } + } +} + +pub async fn admin_devices( + State(db_pool): State, + _identity: ComputerIdentity, +) -> Result { + let devices: Vec = sqlx::query_as( + r#"SELECT id, fingerprint_hash, device_name, first_seen, last_seen, user_agent + FROM devices ORDER BY last_seen DESC"# + ) + .fetch_all(&db_pool) + .await + .map_err(|e| { + tracing::error!("Database error: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + // User variables + let logged_in = true; + let user = AccountData::default(); + + // Calculate statistics + let named_devices_count = devices.iter().filter(|d| d.device_name.is_some()).count(); + + let now = chrono::Utc::now(); + let one_day_ago = now - chrono::Duration::days(1); + let one_week_ago = now - chrono::Duration::weeks(1); + + let recent_devices_count = devices.iter() + .filter(|d| d.last_seen > one_day_ago) + .count(); + + let weekly_devices_count = devices.iter() + .filter(|d| d.last_seen > one_week_ago) + .count(); + + let fingerprint_hash = String::new(); + + Ok(DeviceAdminTemplate { + logged_in, + user, + devices, + named_devices_count, + recent_devices_count, + weekly_devices_count, + fingerprint_hash, + }) +} diff --git a/backend/src/google_oauth.rs b/backend/src/google_oauth.rs index 55573cd..e578443 100644 --- a/backend/src/google_oauth.rs +++ b/backend/src/google_oauth.rs @@ -88,9 +88,12 @@ pub async fn login( .set_pkce_challenge(pkce_code_challenge) .url(); - sqlx::query!( - "INSERT INTO oauth2_state_storage (csrf_state, pkce_code_verifier, return_url) VALUES ($1, $2, $3);",csrf_state.secret(), pkce_code_verifier.secret(), return_url + sqlx::query( + "INSERT INTO oauth2_state_storage (csrf_state, pkce_code_verifier, return_url) VALUES ($1, $2, $3);" ) + .bind(csrf_state.secret()) + .bind(pkce_code_verifier.secret()) + .bind(return_url) .execute(&db_pool) .await .map_err(|e| { diff --git a/backend/src/kiosk.rs b/backend/src/kiosk.rs new file mode 100644 index 0000000..3fe06d0 --- /dev/null +++ b/backend/src/kiosk.rs @@ -0,0 +1,100 @@ +use axum::{ + extract::{Query, State}, + http::StatusCode, + response::{IntoResponse, Redirect}, +}; +use axum_extra::TypedHeader; +use headers::Cookie; +use http::{HeaderMap, HeaderValue}; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use sqlx::PgPool; +use uuid::Uuid; + +#[derive(Deserialize)] +pub struct AutoLoginQuery { + token: String, +} + +fn generate_device_token() -> String { + let uuid = Uuid::new_v4(); + let mut hasher = Sha256::new(); + hasher.update(uuid.as_bytes()); + format!("{:x}", hasher.finalize())[..32].to_string() +} + +// Store this in your database for the kiosk +async fn register_kiosk_device(db: &PgPool, device_name: &str) -> Result { + let token = generate_device_token(); + + sqlx::query!( + "INSERT INTO kiosk_devices (device_name, device_token) VALUES ($1, $2)", + device_name, + token + ) + .execute(db) + .await?; + + Ok(token) +} + +pub(crate) async fn auto_login( + cookie: Option>, + Query(params): Query, + State(db_pool): State, // Your app state with DB pool +) -> Result { + println!("Auto login token: {}", params.token); + + // Validate the device token + let device = sqlx::query!( + "SELECT id, device_name FROM kiosk_devices + WHERE device_token = $1 AND is_active = true", + params.token + ) + .fetch_optional(&db_pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let mut returnpath = "/"; + let mut headers = HeaderMap::new(); + + match device { + Some(device) => { + println!("Device name: {}", device.device_name); + + // Update last accessed + sqlx::query!( + "UPDATE kiosk_devices SET last_accessed = CURRENT_TIMESTAMP WHERE id = $1", + device.id + ) + .execute(&db_pool) + .await + .ok(); + + // Log in to kiosk account + // Set session/cookie and redirect to dashboard + returnpath = "/kiosk-dashboard" + } + None => { + // Log out and return to the dashboard + println!("Invalid device token"); + if let Some(cookie) = cookie { + if let Some(session_token) = cookie.get("session_token") { + let session_token: Vec<&str> = session_token.split('_').collect(); + let _ = sqlx::query("DELETE FROM user_sessions WHERE session_token_1 = $1") + .bind(session_token[0]) + .execute(&db_pool) + .await; + } + } + headers.insert( + axum::http::header::SET_COOKIE, + HeaderValue::from_static( + "session_token=deleted; path=/; httponly; secure; samesite=strict", + ), + ); + returnpath = "/"; + } + }; + Ok((headers, Redirect::to(returnpath))) +} diff --git a/backend/src/main.rs b/backend/src/main.rs index 8b20959..711274f 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -1,15 +1,15 @@ +use askama_axum::Template; use axum::{ - middleware, - routing::{get, get_service, post}, - Extension, Router, + extract::{Path, State}, middleware, response::{Html, IntoResponse, Redirect}, routing::{get, get_service, post}, Extension, Router }; use dotenvy::var; use secret_gift_exchange::{giftexchange, giftexchange_save, giftexchanges}; use sqlx::{migrate::Migrator, sqlite::SqlitePoolOptions, Row, SqlitePool}; use sqlx::{postgres::PgPoolOptions, PgPool}; -use std::net::SocketAddr; -use tower_http::services::ServeDir; +use std::{f32::consts::E, net::SocketAddr}; +use tower_http::{services::ServeDir, trace::TraceLayer}; +mod fingerprint; mod calendar; mod email; mod error_handling; @@ -20,7 +20,9 @@ mod secret_gift_exchange; mod user; mod wishlist; mod rbac; +mod kiosk; +use fingerprint::{FingerprintService, handle_fingerprint, update_device_name, admin_devices}; use rbac::RbacService; use calendar::{calendar, get_events, create_event, new_event, new_request}; use error_handling::AppError; @@ -33,14 +35,16 @@ use wishlist::{ user_wishlist_delete_item, user_wishlist_edit_item, user_wishlist_received_item, user_wishlist_returned_item, user_wishlist_save_item, wishlists, }; +use kiosk::auto_login; -use crate::calendar::{calendar_update_event_state, update_event}; +use crate::{calendar::{calendar_update_event_state, update_event}, fingerprint::{device, ComputerIdentity, Device}, routes::HtmlTemplate}; //use email::send_emails; +// Application state #[derive(Clone)] -pub struct AppState { - pub db_pool: PgPool, +struct AppState { + db: PgPool, } #[tokio::main] @@ -52,12 +56,12 @@ async fn main() { let database_url = var("DATABASE_URL").expect("DATABASE_URL not set"); let db_pool = PgPoolOptions::new().connect(&database_url).await.unwrap(); - let app_state = AppState { db_pool: db_pool }; + let app_state = AppState { db: db_pool }; static MIGRATOR: Migrator = sqlx::migrate!(); - MIGRATOR - .run(&app_state.db_pool) + MIGRATOR + .run(&app_state.db) .await .expect("Failed to run migrations"); @@ -69,18 +73,31 @@ async fn main() { .await .unwrap(); - copy_database(&sdb_pool, &app_state.db_pool).await; + copy_database(&sdb_pool, &app_state.db).await; } else { println!("SOURCE_DB_URL not set"); } - let rbac = RbacService::new(app_state.db_pool.clone()); + let rbac = RbacService::new(app_state.db.clone()); + let fingerprint = FingerprintService::new(app_state.db.clone()); let user_data: Option = None; // build our application with some routes let app = Router::new() + .route("/debug", get(debug)) .route("/dashboard", get(dashboard)) + + // Kiosk + .route("/auto-login", get(auto_login)) + //.route("/kiosk-dashboard", get(kiosk_dashboard)) + + //Fingerprint + //.route("/api/fingerprint", post(handle_fingerprint)) + //.route("/api/device/name", post(update_device_name)) + //.route("/admin/devices", get(admin_devices)) + .route("/device", get(device)) + // User .route("/profile", get(profile)) .route("/useradmin", get(useradmin)) @@ -91,6 +108,7 @@ async fn main() { "/roles/{user_id}/{user_role_id}/delete", get(delete_user_role), ) + // Calendar .route("/calendar", get(calendar)) .route("/calendar/getevents", get(get_events)) @@ -99,6 +117,7 @@ async fn main() { .route("/calendar/newrequest", post(new_request)) .route("/calendar/updaterequest", post(update_event)) .route("/calendar/updateeventstate", post(calendar_update_event_state)) + // Wishlist .route("/wishlists", get(wishlists)) .route("/userwishlist/{user_id}", get(user_wishlist)) @@ -126,6 +145,7 @@ async fn main() { "/userwishlist/returned/{item_id}", get(user_wishlist_returned_item), ) + // Secret Gift Exchange - Not ready for public use yet .route("/giftexchanges", get(giftexchanges)) .route( @@ -144,12 +164,15 @@ async fn main() { .route("/logout", get(logout)) .route("/google_auth_return", get(google_auth_return)) .route_layer(middleware::from_fn_with_state( - app_state.db_pool.clone(), + app_state.db.clone(), inject_user_data, )) - .with_state(app_state.db_pool.clone()) + .with_state(app_state.db.clone()) .layer(Extension(user_data)) - .layer(Extension(rbac)); + .layer(Extension(rbac)) + .layer(Extension(fingerprint)) + .layer(TraceLayer::new_for_http()) + ; // Send email indicating server has started //let recipients = get_useremails_by_role("admin".to_string(), &app_state.db_pool).await; @@ -370,3 +393,52 @@ async fn copy_database(sdb_pool: &SqlitePool, db_pool: &PgPool) { println!("Error: {}", e); } } + +#[derive(Template)] +#[template(path = "debug.html")] +struct DebugTemplate { + user: AccountData, + access: bool, + connected: String, + identity: ComputerIdentity, +} + +async fn debug( + Extension(user_data): Extension>, + Extension(fingerprint): Extension, + Extension(rbac): Extension, + State(db_pool): State, +) -> impl IntoResponse { + let logged_in = user_data.is_some(); + + // Set empty values + let mut user = AccountData::default(); + let mut connected = "false".to_string(); + let mut access = false; + let mut identity = ComputerIdentity { + fingerprint_hash: String::new(), + device: None, + }; + + if logged_in { + // Extract the user data. + user = user_data.as_ref().unwrap().clone(); + access = rbac.has_permission(user_data.as_ref().unwrap().id.clone(), "calendar:read:*").await; + } else { + user.name = "Anonymous".to_string(); + } + + if db_pool.is_closed() { + connected = "false".to_string(); + } else { + connected = "true".to_string(); + } + + let template = DebugTemplate { + user, + access, + connected, + identity, + }; + HtmlTemplate(template) +} \ No newline at end of file diff --git a/backend/src/routes.rs b/backend/src/routes.rs index 369a91d..d59fdc3 100644 --- a/backend/src/routes.rs +++ b/backend/src/routes.rs @@ -34,7 +34,7 @@ struct UserProfileTemplate { non_profile_roles: Vec, } -struct HtmlTemplate(T); +pub struct HtmlTemplate(pub T); impl IntoResponse for HtmlTemplate where diff --git a/backend/templates/base copy.html b/backend/templates/base copy.html new file mode 100644 index 0000000..f4fd1af --- /dev/null +++ b/backend/templates/base copy.html @@ -0,0 +1,154 @@ + + + + + + + Jean-Marie Family + + + + + + + + + + + {% block links %}{% endblock links %} + + + + + + +
+
+ + +
+ +
+ + +
+ {% block content %}{% endblock content %} +
+ + +
+
+
+

© 2025 Jean-Marie family

+
+
+
+
+ +
+ + + + + + + + {% block scripts %}{% endblock scripts %} + + + {% block script %}{% endblock script %} + + + \ No newline at end of file diff --git a/backend/templates/debug.html b/backend/templates/debug.html new file mode 100644 index 0000000..401cfde --- /dev/null +++ b/backend/templates/debug.html @@ -0,0 +1,5 @@ +User name: {{ user.name }}

+Has access: {{ access}}

+Database connection: {{ connected }}

+Client fingerprint: {{ identity.fingerprint_hash }}

+ diff --git a/backend/templates/device.html b/backend/templates/device.html new file mode 100644 index 0000000..8ff5e64 --- /dev/null +++ b/backend/templates/device.html @@ -0,0 +1,2 @@ +{{ device_status }}

+{{ fingerprint_hash }} diff --git a/backend/templates/deviceadmin.html b/backend/templates/deviceadmin.html new file mode 100644 index 0000000..e5a2b8f --- /dev/null +++ b/backend/templates/deviceadmin.html @@ -0,0 +1,5 @@ +{% extends "base.html" %} +{% block title %}Device Administration{% endblock %} +{% block content %} +

Here are the devices

+{% endblock %} \ No newline at end of file diff --git a/backend/templates/orig_device.html b/backend/templates/orig_device.html new file mode 100644 index 0000000..a69da7e --- /dev/null +++ b/backend/templates/orig_device.html @@ -0,0 +1,207 @@ +{% extends "base.html" %} + +{% block title %}Device Fingerprinting Demo{% endblock %} + +{% block content %} +
+
+
+
+

Device Fingerprinting Demo

+
+
+
+ {{ device_status }} +
+ +
+
Device Information:
+

Fingerprint: {{ fingerprint_hash }}

+ + {% if device %} +
+

Device Name: {{ device.device_name }}

+

First Seen: {{ device.first_seen }}

+

Last Seen: {{ device.last_seen }}

+ {% if device.user_agent %} +

User Agent: + {{ device.user_agent }} +

+ {% endif %} +
+ {% endif %} +
+ +
+ + + {% if fingerprint_hash %} + + {% endif %} +
+
+
+ + {% if fingerprint_hash %} +
+
+
+ Loading... +
+

Generating device fingerprint...

+
+
+ {% endif %} +
+
+ + + +{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/backend/templates/orig_deviceadmin.html b/backend/templates/orig_deviceadmin.html new file mode 100644 index 0000000..35c3901 --- /dev/null +++ b/backend/templates/orig_deviceadmin.html @@ -0,0 +1,201 @@ +{% extends "base.html" %} + +{% block title %}Admin - Registered Devices{% endblock %} + +{% block content %} +
+
+
+

Registered Devices

+
+ Total: {{ devices }} +
+
+ + {% if devices %} +
+
+
+ + + + + + + + + + + + + {% for device in devices %} + + + + + + + + + {% endfor %} + +
Device NameFingerprintFirst SeenLast SeenUser AgentActions
+ {% if device.device_name %} + {{ device.device_name }} + {% else %} + Unnamed Device + {% endif %} + + {{ device.fingerprint_hash }}... + + {{ device.first_seen }}{{ device.last_seen }} + {% if device.user_agent %} +
+ {{ device.user_agent }} +
+ {% else %} + Unknown + {% endif %} +
+
+ +
+
+
+
+
+ +
+

Statistics

+
+
+
+
+
Total Devices
+

{{ devices }}

+
+
+
+
+
+
+
Named Devices
+

{{ named_devices_count }}

+
+
+
+
+
+
+
Recent (24h)
+

{{ recent_devices_count }}

+
+
+
+
+
+
+
This Week
+

{{ weekly_devices_count }}

+
+
+
+
+
+ + {% else %} +
+

No Devices Registered

+

Visit the home page to register your first device.

+
+ {% endif %} +
+
+ + + +{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file