use serde::{Deserialize, Serialize};
use crate::session::PasswordFingerprint;
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()
}
#[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)
}
#[deprecated(
since = "0.62.0",
note = "use `rustango::object_permissions::is_superuser`"
)]
#[must_use]
pub fn is_superuser(parts: &axum::http::request::Parts) -> bool {
crate::object_permissions::is_superuser(parts)
}
#[must_use]
pub fn from_extensions(extensions: &axum::http::Extensions) -> Option<AdminSession> {
extensions.get::<AdminSession>().cloned().or_else(current)
}
pub(crate) async fn scope_request_session(
req: axum::extract::Request,
next: axum::middleware::Next,
) -> axum::response::Response {
match from_extensions(req.extensions()) {
Some(s) => CURRENT_SESSION.scope(s, next.run(req)).await,
None => next.run(req).await,
}
}
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()
}
pub(crate) 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,
pub(crate) exp: i64,
}
impl crate::session::Expiring for CookiePayload {
fn is_expired_at(&self, now: i64) -> bool {
now >= self.exp
}
}
const ADMIN_SESSION_TAG: &[u8] = b"rustango-admin-session-v1";
#[must_use]
pub(crate) fn encode(
secret: &AdminSessionSecret,
session: AdminSession,
auth_hash: &PasswordFingerprint,
sessions_revoked_at: Option<chrono::DateTime<chrono::Utc>>,
) -> String {
encode_for(
secret,
session,
auth_hash,
sessions_revoked_at,
DEFAULT_TTL_SECS,
)
}
pub(crate) fn encode_until(
secret: &AdminSessionSecret,
session: AdminSession,
auth_hash: &PasswordFingerprint,
sessions_revoked_at: Option<chrono::DateTime<chrono::Utc>>,
exp: i64,
) -> String {
let left = (exp - chrono::Utc::now().timestamp()).clamp(1, DEFAULT_TTL_SECS);
encode_for(secret, session, auth_hash, sessions_revoked_at, left)
}
pub(crate) fn encode_for(
secret: &AdminSessionSecret,
session: AdminSession,
auth_hash: &PasswordFingerprint,
sessions_revoked_at: Option<chrono::DateTime<chrono::Utc>>,
ttl_secs: i64,
) -> String {
let payload = CookiePayload {
user_id: session.user_id,
username: session.username,
is_superuser: session.is_superuser,
exp: chrono::Utc::now().timestamp() + ttl_secs,
auth_hash: auth_hash.clone(),
iat: crate::session::issued_at(sessions_revoked_at),
};
crate::session::SealedToken::seal(secret.key(), ADMIN_SESSION_TAG, &payload)
}
#[must_use]
pub(crate) fn decode_full(
secret: &AdminSessionSecret,
value: &str,
) -> Option<(AdminSession, CookieAuth)> {
let payload: CookiePayload =
crate::session::SealedToken::open(secret.key(), ADMIN_SESSION_TAG, value).ok()?;
Some((
AdminSession::new(payload.user_id, payload.username, payload.is_superuser),
CookieAuth {
auth_hash: payload.auth_hash,
iat: payload.iat,
exp: payload.exp,
},
))
}
#[cfg(test)]
mod tests {
use super::*;
fn session(user_id: i64, username: &str, is_superuser: bool) -> AdminSession {
AdminSession::new(user_id, username, is_superuser)
}
#[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");
}
#[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 a_reminted_cookie_keeps_its_expiry() {
let secret = AdminSessionSecret::from_bytes(vec![42u8; 32]);
let fp = PasswordFingerprint::of(&secret, "$argon2id$fake-hash");
let short = encode_for(&secret, session(7, "alice", true), &fp, None, 600);
let exp = decode_full(&secret, &short).unwrap().1.exp;
let again = encode_until(&secret, session(7, "alice", true), &fp, None, exp);
assert!((decode_full(&secret, &again).unwrap().1.exp - exp).abs() <= 1);
let far = chrono::Utc::now().timestamp() + 10 * DEFAULT_TTL_SECS;
let capped = encode_until(&secret, session(7, "alice", true), &fp, None, far);
let left = decode_full(&secret, &capped).unwrap().1.exp - chrono::Utc::now().timestamp();
assert!(left <= DEFAULT_TTL_SECS);
}
#[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());
}
}