Initial device fingerprinting and auto login
This commit is contained in:
parent
50bc1f4eb5
commit
3b16cce62a
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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(¶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<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.
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
-- Delete all fingerprint items
|
||||
drop table if exists devices;
|
||||
|
|
@ -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();
|
||||
|
|
@ -0,0 +1 @@
|
|||
DROP TABLE if exists kiosk_devices;
|
||||
|
|
@ -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
|
||||
);
|
||||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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| {
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
}
|
||||
|
|
@ -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)
|
||||
.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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
User name: {{ user.name }}</p>
|
||||
Has access: {{ access}}</p>
|
||||
Database connection: {{ connected }}</p>
|
||||
Client fingerprint: {{ identity.fingerprint_hash }}</p>
|
||||
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
{{ device_status }}</p>
|
||||
{{ fingerprint_hash }}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Device Administration{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Here are the devices</h1>
|
||||
{% endblock %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
Loading…
Reference in New Issue