use base64::Engine;
use serde::{Deserialize, Serialize};
use subtle::ConstantTimeEq;
pub use crate::session::SessionSecret as AdminSessionSecret;
use crate::session::{sign, PasswordFingerprint};
tokio::task_local! {
pub(crate) static CURRENT_SESSION: AdminSession;
}
#[must_use]
pub fn current() -> Option<AdminSession> {
CURRENT_SESSION.try_with(|s| s.clone()).ok()
}
#[must_use]
pub fn from_extensions(extensions: &axum::http::Extensions) -> Option<AdminSession> {
extensions.get::<AdminSession>().cloned().or_else(current)
}
tokio::task_local! {
pub(crate) static CURRENT_CSRF_TOKEN: String;
}
#[must_use]
pub fn current_csrf_token() -> Option<String> {
CURRENT_CSRF_TOKEN.try_with(Clone::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: PasswordFingerprint,
#[serde(default)]
iat: i64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct CookieAuth {
pub(crate) auth_hash: PasswordFingerprint,
pub(crate) iat: i64,
}
impl CookiePayload {
fn is_expired(&self) -> bool {
chrono::Utc::now().timestamp() >= self.exp
}
}
#[must_use]
pub(crate) fn encode(
secret: &AdminSessionSecret,
session: AdminSession,
auth_hash: &PasswordFingerprint,
sessions_revoked_at: Option<chrono::DateTime<chrono::Utc>>,
) -> 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.clone(),
iat: crate::session::issued_at(sessions_revoked_at),
};
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_full(
secret: &AdminSessionSecret,
value: &str,
) -> Option<(AdminSession, CookieAuth)> {
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,
},
CookieAuth {
auth_hash: payload.auth_hash,
iat: payload.iat,
},
))
}
#[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 = PasswordFingerprint::of(&secret, "$argon2id$fake-hash");
let cookie = encode(&secret, session(7, "alice", true), &fp, None);
let (s, auth) = decode_full(&secret, &cookie).expect("valid cookie verifies");
assert_eq!(s.user_id, 7);
assert!(s.is_superuser);
assert_eq!(auth.auth_hash, fp);
assert!(auth.iat > 0);
}
#[test]
fn auth_hash_changes_with_password_hash() {
let secret = AdminSessionSecret::from_bytes(vec![9u8; 32]);
let before = PasswordFingerprint::of(&secret, "$argon2id$old");
let after = PasswordFingerprint::of(&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),
&PasswordFingerprint::default(),
None,
);
let (body, _sig) = cookie.split_once('.').unwrap();
let bad = format!("{body}.AAAA");
assert!(decode_full(&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),
&PasswordFingerprint::default(),
None,
);
assert!(decode_full(&secret_b, &cookie).is_none());
}
}