use base64::Engine;
use serde::{Deserialize, Serialize};
use subtle::ConstantTimeEq;
use crate::session::sign;
pub use crate::session::SessionSecret as AdminSessionSecret;
tokio::task_local! {
pub(crate) static CURRENT_SESSION: AdminSession;
}
#[must_use]
pub fn current() -> Option<AdminSession> {
CURRENT_SESSION.try_with(|s| s.clone()).ok()
}
const DEFAULT_TTL_SECS: i64 = 8 * 60 * 60;
pub(crate) const SESSION_COOKIE: &str = "rustango_admin_session";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdminSession {
pub user_id: i64,
pub username: String,
pub is_superuser: bool,
}
#[derive(Serialize, Deserialize)]
struct CookiePayload {
user_id: i64,
username: String,
is_superuser: bool,
exp: i64,
#[serde(default)]
auth_hash: String,
}
impl CookiePayload {
fn is_expired(&self) -> bool {
chrono::Utc::now().timestamp() >= self.exp
}
}
#[must_use]
pub(crate) fn password_fingerprint(secret: &AdminSessionSecret, password_hash: &str) -> String {
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(sign(secret, password_hash.as_bytes()))
}
#[must_use]
pub(crate) fn encode(
secret: &AdminSessionSecret,
session: AdminSession,
auth_hash: &str,
) -> String {
let payload = CookiePayload {
user_id: session.user_id,
username: session.username,
is_superuser: session.is_superuser,
exp: chrono::Utc::now().timestamp() + DEFAULT_TTL_SECS,
auth_hash: auth_hash.to_owned(),
};
let json = serde_json::to_vec(&payload).expect("payload serializes");
let body = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&json);
let sig = sign(secret, body.as_bytes());
let sig_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(sig);
format!("{body}.{sig_b64}")
}
#[must_use]
pub(crate) fn decode(secret: &AdminSessionSecret, value: &str) -> Option<AdminSession> {
decode_full(secret, value).map(|(session, _auth_hash)| session)
}
#[must_use]
pub(crate) fn decode_full(
secret: &AdminSessionSecret,
value: &str,
) -> Option<(AdminSession, String)> {
let (body, sig_b64) = value.split_once('.')?;
let expected = sign(secret, body.as_bytes());
let provided = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(sig_b64)
.ok()?;
if expected.ct_eq(&provided[..]).unwrap_u8() == 0 {
return None;
}
let json = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(body)
.ok()?;
let payload: CookiePayload = serde_json::from_slice(&json).ok()?;
if payload.is_expired() {
return None;
}
Some((
AdminSession {
user_id: payload.user_id,
username: payload.username,
is_superuser: payload.is_superuser,
},
payload.auth_hash,
))
}
#[cfg(test)]
mod tests {
use super::*;
fn session(user_id: i64, username: &str, is_superuser: bool) -> AdminSession {
AdminSession {
user_id,
username: username.into(),
is_superuser,
}
}
#[test]
fn round_trip_recovers_session_fields_and_auth_hash() {
let secret = AdminSessionSecret::from_bytes(vec![42u8; 32]);
let fp = password_fingerprint(&secret, "$argon2id$fake-hash");
let cookie = encode(&secret, session(7, "alice", true), &fp);
let (s, auth_hash) = decode_full(&secret, &cookie).expect("valid cookie verifies");
assert_eq!(s.user_id, 7);
assert!(s.is_superuser);
assert_eq!(auth_hash, fp);
}
#[test]
fn auth_hash_changes_with_password_hash() {
let secret = AdminSessionSecret::from_bytes(vec![9u8; 32]);
let before = password_fingerprint(&secret, "$argon2id$old");
let after = password_fingerprint(&secret, "$argon2id$new");
assert_ne!(before, after);
}
#[test]
fn tampered_signature_rejected() {
let secret = AdminSessionSecret::from_bytes(vec![1u8; 32]);
let cookie = encode(&secret, session(1, "bob", false), "fp");
let (body, _sig) = cookie.split_once('.').unwrap();
let bad = format!("{body}.AAAA");
assert!(decode(&secret, &bad).is_none());
}
#[test]
fn wrong_secret_rejected() {
let secret_a = AdminSessionSecret::from_bytes(vec![1u8; 32]);
let secret_b = AdminSessionSecret::from_bytes(vec![2u8; 32]);
let cookie = encode(&secret_a, session(1, "bob", false), "fp");
assert!(decode(&secret_b, &cookie).is_none());
}
}