diff --git a/cruciverb-server/src/session.rs b/cruciverb-server/src/session.rs new file mode 100644 index 0000000..f52b4c0 --- /dev/null +++ b/cruciverb-server/src/session.rs @@ -0,0 +1,121 @@ +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())) +}