Initial device fingerprinting and auto login

This commit is contained in:
Chris Jean-Marie 2025-09-28 04:02:02 +00:00
parent 50bc1f4eb5
commit 3b16cce62a
18 changed files with 1357 additions and 22 deletions

16
backend/Cargo.lock generated
View File

@ -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",

View File

@ -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"

190
backend/KioskNotes.txt Normal file
View File

@ -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<AutoLoginQuery>,
State(app_state): State<AppState>, // Your app state with DB pool
) -> Result<Redirect, StatusCode> {
// 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<String, sqlx::Error> {
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<i64>,
signature: Option<String>,
}
async fn auto_login_with_signature(
Query(params): Query<AutoLoginQuery>,
// ... other params
) -> Result<Redirect, StatusCode> {
// 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(&params.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<AutoLoginQuery>,
cookies: Cookies,
// ... other params
) -> Result<Redirect, StatusCode> {
// ... 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.

View File

@ -0,0 +1,2 @@
-- Delete all fingerprint items
drop table if exists devices;

View File

@ -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();

View File

@ -0,0 +1 @@
DROP TABLE if exists kiosk_devices;

View File

@ -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
);

338
backend/src/fingerprint.rs Normal file
View File

@ -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<String>,
first_seen: chrono::DateTime<chrono::Utc>,
last_seen: chrono::DateTime<chrono::Utc>,
user_agent: Option<String>,
}
#[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<String>,
}
#[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<Device> {
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<Device>,
}
impl<S> FromRequestParts<S> for ComputerIdentity
where
S: Send + Sync,
AppState: FromRequestParts<S>,
{
type Rejection = (StatusCode, &'static str);
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
// 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<Device, sqlx::Error> {
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<Device, sqlx::Error> {
// 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<Device>
}
#[derive(Template)]
#[template(path = "deviceadmin.html")]
struct DeviceAdminTemplate {
logged_in: bool,
user: AccountData,
devices: Vec<Device>,
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<PgPool>,
Extension(user_data): Extension<Option<AccountData>>,
//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<FingerprintData>,
) -> Result<Json<serde_json::Value>, 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<UpdateDeviceRequest>,
) -> 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<PgPool>,
_identity: ComputerIdentity,
) -> Result<DeviceAdminTemplate, StatusCode> {
let devices: Vec<Device> = 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,
})
}

View File

@ -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| {

100
backend/src/kiosk.rs Normal file
View File

@ -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<String, sqlx::Error> {
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<TypedHeader<Cookie>>,
Query(params): Query<AutoLoginQuery>,
State(db_pool): State<PgPool>, // Your app state with DB pool
) -> Result<impl IntoResponse, StatusCode> {
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)))
}

View File

@ -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<AccountData> = 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<Option<AccountData>>,
Extension(fingerprint): Extension<FingerprintService>,
Extension(rbac): Extension<RbacService>,
State(db_pool): State<PgPool>,
) -> 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)
}

View File

@ -34,7 +34,7 @@ struct UserProfileTemplate {
non_profile_roles: Vec<crate::user::UserRolesDisplay>,
}
struct HtmlTemplate<T>(T);
pub struct HtmlTemplate<T>(pub T);
impl<T> IntoResponse for HtmlTemplate<T>
where

View File

@ -0,0 +1,154 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
<meta charset="utf-8">
<title>Jean-Marie Family</title>
<link rel="icon" type="image/x-icon" href="/assets/favicon.png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="">
<meta name="author" content="Chris Jean-Marie">
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<link rel='stylesheet' href='https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css'>
<style>
@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;600;700&display=swap');
body {
font-family: "Montserrat", sans-serif;
height: 100vh;
margin: 0;
}
</style>
{% block links %}{% endblock links %}
</head>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-table@1.23.5/dist/bootstrap-table.min.css">
</head>
<body>
<div class="container-fluid" height="100vh">
<div class="row vh-100">
<!-- HEADER -->
<div class="row fixed-top sticky-top">
<nav class="navbar navbar-expand-lg bg-body-tertiary">
<div class="container-fluid">
<a class="navbar-brand" href="#">Jean-Marie Family</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<a class="nav-link" href="/">Home</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/about">About</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/contactus">Contact Us</a>
</li>
{% if logged_in %}
<li class="nav-item"><a class="nav-link" href="/logout">Logout</a></li>
<li class="nav-item"><a class="nav-link" href="/profile">{{ user.name }}</a></li>
{% else %}
<li class="nav-item"><a class="nav-link" href="/login">Login</a></li>
{% endif %}
</ul>
</div>
</div>
</nav>
</div>
<!-- CONTENT -->
<div class="row flex-1">
{% block content %}{% endblock content %}
</div>
<!-- FOOTER -->
<div class="row fixed-bottom sticky-bottom">
<div class="container-fluid text-center bg-light">
<footer>
<p>© 2025 Jean-Marie family</p>
</footer>
</div><!-- /.container -->
</div>
</div>
</div>
<!-- Bootstrap JS Bundle with Popper -->
<script src="https://cdn.jsdelivr.net/npm/jquery/dist/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
crossorigin="anonymous"></script>
<!-- Fingerprinting scripts -->
<script>
let currentFingerprint = '{{ fingerprint_hash }}';
// Generate fingerprint
async function generateFingerprint() {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
ctx.textBaseline = 'top';
ctx.font = '14px Arial';
ctx.textBaseline = 'alphabetic';
ctx.fillStyle = '#f60';
ctx.fillRect(125, 1, 62, 20);
ctx.fillStyle = '#069';
ctx.fillText('Device fingerprint', 2, 15);
const canvasFingerprint = canvas.toDataURL();
// Get available fonts (simplified)
const fonts = ['Arial', 'Helvetica', 'Times New Roman', 'Courier', 'Verdana', 'Georgia', 'Palatino', 'Garamond', 'Bookman', 'Tahoma'];
const fingerprintData = {
user_agent: navigator.userAgent,
screen_width: screen.width,
screen_height: screen.height,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
language: navigator.language,
platform: navigator.platform,
canvas_fingerprint: canvasFingerprint,
fonts: fonts
};
try {
const response = await fetch('/api/fingerprint', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(fingerprintData)
});
if (response.ok) {
const result = await response.json();
currentFingerprint = result.fingerprint_hash;
// Reload page to show updated info
window.location.reload();
} else {
console.error('Failed to generate fingerprint:', response.statusText);
}
} catch (error) {
console.error('Error generating fingerprint:', error);
}
}
</script>
{% block scripts %}{% endblock scripts %}
<script src="https://cdn.jsdelivr.net/npm/bootstrap-table@1.23.5/dist/bootstrap-table.min.js"></script>
<script src="https://unpkg.com/htmx.org@2.0.0"></script>
{% block script %}{% endblock script %}
</body>
</html>

View File

@ -0,0 +1,5 @@
User name: {{ user.name }}</p>
Has access: {{ access}}</p>
Database connection: {{ connected }}</p>
Client fingerprint: {{ identity.fingerprint_hash }}</p>

View File

@ -0,0 +1,2 @@
{{ device_status }}</p>
{{ fingerprint_hash }}

View File

@ -0,0 +1,5 @@
{% extends "base.html" %}
{% block title %}Device Administration{% endblock %}
{% block content %}
<h1>Here are the devices</h1>
{% endblock %}

View File

@ -0,0 +1,207 @@
{% extends "base.html" %}
{% block title %}Device Fingerprinting Demo{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h1 class="h3 mb-0">Device Fingerprinting Demo</h1>
</div>
<div class="card-body">
<div id="device-status" class="alert alert-info">
{{ device_status }}
</div>
<div id="fingerprint-info" class="mt-4">
<h5>Device Information:</h5>
<p><strong>Fingerprint:</strong> <span id="fingerprint-hash">{{ fingerprint_hash }}</span></p>
{% if device %}
<div class="mt-3">
<p><strong>Device Name:</strong> {{ device.device_name }}</p>
<p><strong>First Seen:</strong> {{ device.first_seen }}</p>
<p><strong>Last Seen:</strong> {{ device.last_seen }}</p>
{% if device.user_agent %}
<p><strong>User Agent:</strong>
<small class="text-muted">{{ device.user_agent }}</small>
</p>
{% endif %}
</div>
{% endif %}
</div>
<div class="mt-4">
<button id="update-name-btn" class="btn btn-primary" onclick="showNameModal()">
{% if device %}
Update Device Name
{% else %}
Name This Device
{% endif %}
</button>
{% if fingerprint_hash %}
<button class="btn btn-outline-secondary ms-2" onclick="refreshFingerprint()">
Refresh Fingerprint
</button>
{% endif %}
</div>
</div>
</div>
{% if fingerprint_hash %}
<div class="card mt-4">
<div class="card-body text-center">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-2">Generating device fingerprint...</p>
</div>
</div>
{% endif %}
</div>
</div>
<!-- Modal for updating device name -->
<div class="modal fade" id="nameModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
{% if device %}
Update Device Name
{% else %}
Name This Device
{% endif %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="device-name-input" class="form-label">Device Name</label>
<input type="text"
id="device-name-input"
class="form-control"
placeholder="Enter a name for this device"
{% if device and device.device_name %}value="{{ device.device_name }}"{% endif %}>
</div>
<small class="text-muted">
This name will be displayed when you visit from this device.
</small>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="updateDeviceName()">Save</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
let currentFingerprint = '{{ fingerprint_hash }}';
// Generate fingerprint
async function generateFingerprint() {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
ctx.textBaseline = 'top';
ctx.font = '14px Arial';
ctx.textBaseline = 'alphabetic';
ctx.fillStyle = '#f60';
ctx.fillRect(125, 1, 62, 20);
ctx.fillStyle = '#069';
ctx.fillText('Device fingerprint', 2, 15);
const canvasFingerprint = canvas.toDataURL();
// Get available fonts (simplified)
const fonts = ['Arial', 'Helvetica', 'Times New Roman', 'Courier', 'Verdana', 'Georgia', 'Palatino', 'Garamond', 'Bookman', 'Tahoma'];
const fingerprintData = {
user_agent: navigator.userAgent,
screen_width: screen.width,
screen_height: screen.height,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
language: navigator.language,
platform: navigator.platform,
canvas_fingerprint: canvasFingerprint,
fonts: fonts
};
try {
const response = await fetch('/api/fingerprint', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(fingerprintData)
});
if (response.ok) {
const result = await response.json();
currentFingerprint = result.fingerprint_hash;
// Reload page to show updated info
window.location.reload();
} else {
console.error('Failed to generate fingerprint:', response.statusText);
}
} catch (error) {
console.error('Error generating fingerprint:', error);
}
}
function showNameModal() {
const modal = new bootstrap.Modal(document.getElementById('nameModal'));
modal.show();
}
async function updateDeviceName() {
const nameInput = document.getElementById('device-name-input');
const name = nameInput.value.trim();
if (!name) {
nameInput.classList.add('is-invalid');
return;
}
nameInput.classList.remove('is-invalid');
try {
const response = await fetch('/api/device/name', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-fingerprint': currentFingerprint
},
body: JSON.stringify({ device_name: name })
});
if (response.ok) {
const modal = bootstrap.Modal.getInstance(document.getElementById('nameModal'));
modal.hide();
window.location.reload();
} else {
console.error('Failed to update device name:', response.statusText);
alert('Failed to update device name. Please try again.');
}
} catch (error) {
console.error('Error updating device name:', error);
alert('Error updating device name. Please try again.');
}
}
function refreshFingerprint() {
if (confirm('This will generate a new fingerprint for your device. Continue?')) {
generateFingerprint();
}
}
// Generate fingerprint on page load if not already done
if (!currentFingerprint) {
generateFingerprint();
}
</script>
{% endblock %}

View File

@ -0,0 +1,201 @@
{% extends "base.html" %}
{% block title %}Admin - Registered Devices{% endblock %}
{% block content %}
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Registered Devices</h1>
<div>
<span class="badge bg-secondary">Total: {{ devices }}</span>
</div>
</div>
{% if devices %}
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead class="table-dark">
<tr>
<th>Device Name</th>
<th>Fingerprint</th>
<th>First Seen</th>
<th>Last Seen</th>
<th>User Agent</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for device in devices %}
<tr>
<td>
{% if device.device_name %}
<strong>{{ device.device_name }}</strong>
{% else %}
<em class="text-muted">Unnamed Device</em>
{% endif %}
</td>
<td>
<code class="small">{{ device.fingerprint_hash }}...</code>
<button class="btn btn-sm btn-outline-secondary ms-1"
onclick="copyToClipboard('{{ device.fingerprint_hash }}')"
title="Copy full fingerprint">
📋
</button>
</td>
<td class="small">{{ device.first_seen }}</td>
<td class="small">{{ device.last_seen }}</td>
<td class="small text-muted" style="max-width: 300px;">
{% if device.user_agent %}
<div class="text-truncate" title="{{ device.user_agent }}">
{{ device.user_agent }}
</div>
{% else %}
<em>Unknown</em>
{% endif %}
</td>
<td>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-info"
onclick="showDeviceDetails('{{ device.id }}', '{{ device.fingerprint_hash }}', '{{ device.device_name }}', '{{ device.first_seen }}', '{{ device.last_seen }}', '{{ device.user_agent }}')"
title="View Details">
👁️
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<div class="mt-4">
<h3>Statistics</h3>
<div class="row">
<div class="col-md-3">
<div class="card bg-primary text-white">
<div class="card-body">
<h5>Total Devices</h5>
<h2>{{ devices }}</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-success text-white">
<div class="card-body">
<h5>Named Devices</h5>
<h2>{{ named_devices_count }}</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-warning text-white">
<div class="card-body">
<h5>Recent (24h)</h5>
<h2>{{ recent_devices_count }}</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-info text-white">
<div class="card-body">
<h5>This Week</h5>
<h2>{{ weekly_devices_count }}</h2>
</div>
</div>
</div>
</div>
</div>
{% else %}
<div class="text-center py-5">
<h3 class="text-muted">No Devices Registered</h3>
<p class="text-muted">Visit the <a href="/">home page</a> to register your first device.</p>
</div>
{% endif %}
</div>
</div>
<!-- Device Details Modal -->
<div class="modal fade" id="deviceModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Device Details</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div id="device-details-content">
<!-- Content will be populated by JavaScript -->
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(function() {
// Could add a toast notification here
console.log('Fingerprint copied to clipboard');
});
}
function showDeviceDetails(id, fingerprint, name, firstSeen, lastSeen, userAgent) {
const content = document.getElementById('device-details-content');
content.innerHTML = `
<div class="row">
<div class="col-12">
<table class="table table-borderless">
<tbody>
<tr>
<td><strong>Device ID:</strong></td>
<td><code>${id}</code></td>
</tr>
<tr>
<td><strong>Device Name:</strong></td>
<td>${name || '<em>Unnamed</em>'}</td>
</tr>
<tr>
<td><strong>Fingerprint Hash:</strong></td>
<td>
<code style="word-break: break-all;">${fingerprint}</code>
<button class="btn btn-sm btn-outline-secondary ms-2"
onclick="copyToClipboard('${fingerprint}')"
title="Copy fingerprint">
📋 Copy
</button>
</td>
</tr>
<tr>
<td><strong>First Seen:</strong></td>
<td>${firstSeen}</td>
</tr>
<tr>
<td><strong>Last Seen:</strong></td>
<td>${lastSeen}</td>
</tr>
<tr>
<td><strong>User Agent:</strong></td>
<td style="word-break: break-word;">${userAgent || '<em>Unknown</em>'}</td>
</tr>
</tbody>
</table>
</div>
</div>
`;
const modal = new bootstrap.Modal(document.getElementById('deviceModal'));
modal.show();
}
</script>
{% endblock %}