122 lines
4.3 KiB
Rust
122 lines
4.3 KiB
Rust
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()))
|
|
}
|