use std::sync::Arc; use axum::extract::FromRequestParts; use axum::http::request::Parts; use axum::http::header; use rand::Rng; use crate::state::AppState; pub struct Session { pub token: String, pub display_name: Option, pub is_new: bool, pub cookie_max_age: u64, } impl Session { pub fn cookie_header(&self) -> String { let secure = std::env::var("SECURE_COOKIES").as_deref() == Ok("true"); let same_site = if secure { "Strict" } else { "Lax" }; let secure_flag = if secure { "; Secure" } else { "" }; if self.cookie_max_age == 0 { format!("cruciverb_session={}; Path=/; HttpOnly; SameSite={}{}", self.token, same_site, secure_flag) } else { format!( "cruciverb_session={}; Path=/; HttpOnly; SameSite={}; Max-Age={}{}", self.token, same_site, self.cookie_max_age, secure_flag ) } } } impl FromRequestParts> for Session { type Rejection = std::convert::Infallible; async fn from_request_parts( parts: &mut Parts, state: &Arc, ) -> Result { let token = parts .headers .get(header::COOKIE) .and_then(|v| v.to_str().ok()) .and_then(|cookies| { cookies.split(';') .map(|c| c.trim()) .find(|c| c.starts_with("cruciverb_session=")) .map(|c| c["cruciverb_session=".len()..].to_string()) }); let user_agent_hash = parts .headers .get(header::USER_AGENT) .and_then(|v| v.to_str().ok()) .map(ua_hash) .unwrap_or_default(); let dbi = state.db.lock().await; let (mut token, mut name, mut is_new) = match token { Some(t) if !t.is_empty() => { let (token, name) = crate::db::get_or_create_session(&dbi.db, &t); (token, name, false) } _ => { let new_token = generate_token(); let (token, name) = crate::db::get_or_create_session(&dbi.db, &new_token); (token, name, true) } }; // session binding: when enabled (and a passkey exists), the cookie only // works from the user agent it was bound to; a mismatch rotates to a // fresh session instead of letting a stolen cookie ride along let mut settings_json = crate::db::get_settings(&dbi.db, &token); { let settings_value: serde_json::Value = serde_json::from_str(&settings_json).unwrap_or_default(); let binding_enabled = settings_value .get("sessionBinding") .and_then(|v| v.as_bool()) .unwrap_or(false) && crate::db::has_passkey(&dbi.db, &token); if binding_enabled && !user_agent_hash.is_empty() { let stored = crate::db::get_user_agent_hash(&dbi.db, &token); match stored { None => crate::db::set_user_agent_hash(&dbi.db, &token, &user_agent_hash), Some(s) if s != user_agent_hash => { let new_token = generate_token(); let (t2, n2) = crate::db::get_or_create_session(&dbi.db, &new_token); crate::db::set_user_agent_hash(&dbi.db, &t2, &user_agent_hash); token = t2; name = n2; is_new = true; settings_json = crate::db::get_settings(&dbi.db, &token); } _ => {} } } } // read cookie lifetime from user settings (default 30 days) let cookie_max_age = serde_json::from_str::(&settings_json) .ok() .and_then(|v| v.get("cookieLifetime")?.as_u64()) .unwrap_or(2_592_000); Ok(Session { token, display_name: name, is_new, cookie_max_age }) } } fn generate_token() -> String { let mut rng = rand::rng(); let bytes: Vec = (0..32).map(|_| rng.random::()).collect(); bytes.iter().map(|b| format!("{:02x}", b)).collect() } fn ua_hash(ua: &str) -> String { use sha2::{Digest, Sha256}; hex::encode(Sha256::digest(ua.as_bytes())) }