session handling

This commit is contained in:
2025-09-13 22:08:46 +03:00
parent 2bdce9553e
commit ef6de5c6d5
+121
View File
@@ -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<String>,
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<Arc<AppState>> for Session {
type Rejection = std::convert::Infallible;
async fn from_request_parts(
parts: &mut Parts,
state: &Arc<AppState>,
) -> Result<Self, Self::Rejection> {
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::<serde_json::Value>(&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<u8> = (0..32).map(|_| rng.random::<u8>()).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()))
}