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()
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct PermsScoped(pub(crate) bool);
pub(crate) fn acting_superuser(perms_scoped: bool) -> bool {
!perms_scoped && current().is_none_or(|s| s.is_superuser)
}
#[must_use]
pub fn is_superuser(parts: &axum::http::request::Parts) -> bool {
parts
.extensions
.get::<PermsScoped>()
.is_some_and(|p| acting_superuser(p.0))
}
pub(crate) fn superuser_row_needs_superuser(
parts: &axum::http::request::Parts,
row: Option<&serde_json::Value>,
) -> bool {
let row_is_superuser = row.and_then(|r| r.get("is_superuser")).is_some_and(|v| {
v.as_bool()
.unwrap_or_else(|| v.as_i64().is_some_and(|n| n != 0))
});
!row_is_superuser || is_superuser(parts)
}
#[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,
#[serde(default)]
pub impersonated_by: Option<i64>,
}
impl AdminSession {
#[must_use]
pub fn new(user_id: i64, username: impl Into<String>, is_superuser: bool) -> Self {
Self {
user_id,
username: username.into(),
is_superuser,
impersonated_by: None,
}
}
#[must_use]
pub fn impersonation(operator_id: i64) -> Self {
Self {
user_id: 0,
username: String::new(),
is_superuser: true,
impersonated_by: Some(operator_id),
}
}
#[must_use]
pub fn actor(&self) -> crate::audit::AuditSource {
match self.impersonated_by {
Some(id) => crate::audit::AuditSource::Custom(format!("operator:{id}:impersonating")),
None => crate::audit::AuditSource::User {
id: self.user_id.to_string(),
},
}
}
}
#[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::new(payload.user_id, payload.username, 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::new(user_id, username, is_superuser)
}
fn parts(scoped: Option<bool>) -> axum::http::request::Parts {
let (mut p, ()) = axum::http::Request::new(()).into_parts();
if let Some(b) = scoped {
p.extensions.insert(PermsScoped(b));
}
p
}
#[tokio::test]
async fn acting_superuser_needs_no_perm_set_and_a_superuser_session() {
assert!(acting_superuser(false), "no login is full access");
assert!(!acting_superuser(true));
let under = |su: bool, scoped: bool| {
CURRENT_SESSION.scope(session(1, "u", su), async move { acting_superuser(scoped) })
};
assert!(under(true, false).await);
assert!(!under(false, false).await, "a non-superuser session");
assert!(!under(true, true).await, "with_user_perms wins");
assert!(!is_superuser(&parts(None)));
assert!(is_superuser(&parts(Some(false))));
assert!(!is_superuser(&parts(Some(true))));
}
#[test]
fn a_superuser_row_needs_a_superuser() {
let su = serde_json::json!({"is_superuser": true});
let su_int = serde_json::json!({"is_superuser": 1});
let member = serde_json::json!({"is_superuser": false});
let scoped = parts(Some(true));
assert!(!superuser_row_needs_superuser(&scoped, Some(&su)));
assert!(!superuser_row_needs_superuser(&scoped, Some(&su_int)));
assert!(superuser_row_needs_superuser(&scoped, Some(&member)));
assert!(superuser_row_needs_superuser(&scoped, None));
assert!(superuser_row_needs_superuser(
&parts(Some(false)),
Some(&su)
));
}
#[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());
}
}