use crate::auth::{base_exception_to_response, extract_route_uri, is_route_allowed};
use axum::extract::{Request, State};
use axum::middleware::Next;
use axum::response::IntoResponse;
use parking_lot::Mutex;
use sha2::{Digest, Sha256};
use std::sync::Arc;
use std::time::Duration;
use sz_rust_cache_facade::{Cache, MemoryCacheDriver};
use sz_rust_http_facade::BaseException;
pub const DEFAULT_KEY_PREFIX: &str = "sanctum:token:";
pub const DEFAULT_ID_COUNTER_KEY: &str = "sanctum:id_counter";
pub const DEFAULT_USER_INDEX_PREFIX: &str = "sanctum:user:";
pub const DEFAULT_TOKEN_TTL_SECS: u64 = 3600 * 24 * 30;
pub const TOKEN_DELIMITER: &str = "|";
pub const RANDOM_BYTES_LEN: usize = 32;
#[derive(Debug, Clone)]
pub struct SanctumConfig {
pub key_prefix: String,
pub id_counter_key: String,
pub user_index_prefix: String,
pub default_ttl: Option<Duration>,
pub allow_all_action: Vec<String>,
}
impl Default for SanctumConfig {
fn default() -> Self {
Self {
key_prefix: DEFAULT_KEY_PREFIX.to_string(),
id_counter_key: DEFAULT_ID_COUNTER_KEY.to_string(),
user_index_prefix: DEFAULT_USER_INDEX_PREFIX.to_string(),
default_ttl: Some(Duration::from_secs(DEFAULT_TOKEN_TTL_SECS)),
allow_all_action: Vec::new(),
}
}
}
impl SanctumConfig {
pub fn with_allow_all_action(mut self, allow: Vec<String>) -> Self {
self.allow_all_action = allow;
self
}
pub fn with_default_ttl(mut self, ttl: Option<Duration>) -> Self {
self.default_ttl = ttl;
self
}
pub fn with_key_prefix(mut self, prefix: impl Into<String>) -> Self {
self.key_prefix = prefix.into();
self
}
}
pub type Ability = String;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct PersonalAccessToken {
pub id: i64,
pub user_id: i64,
pub name: String,
pub abilities: Vec<Ability>,
pub created_at: i64,
pub last_used_at: Option<i64>,
pub expires_at: Option<i64>,
}
impl PersonalAccessToken {
pub fn can(&self, ability: &str) -> bool {
self.abilities.iter().any(|a| a == "*" || a == ability)
}
pub fn cannot(&self, ability: &str) -> bool {
!self.can(ability)
}
}
#[derive(Debug, Clone)]
pub struct SanctumUser {
pub user_id: i64,
pub token_id: i64,
pub token_name: String,
pub abilities: Vec<Ability>,
}
impl SanctumUser {
pub fn token_can(&self, ability: &str) -> bool {
self.abilities.iter().any(|a| a == "*" || a == ability)
}
pub fn token_cannot(&self, ability: &str) -> bool {
!self.token_can(ability)
}
}
#[derive(Debug, thiserror::Error)]
pub enum SanctumError {
#[error("cache error: {0}")]
Cache(String),
#[error("invalid token format")]
InvalidFormat,
#[error("token not found or revoked")]
NotFound,
#[error("token expired")]
Expired,
#[error("rng error: {0}")]
Rng(String),
}
#[derive(Clone)]
pub struct Sanctum {
cache: Arc<Cache>,
config: SanctumConfig,
lock: Arc<Mutex<()>>,
}
impl Sanctum {
pub fn new(cache: Arc<Cache>, config: SanctumConfig) -> Self {
Self {
cache,
config,
lock: Arc::new(Mutex::new(())),
}
}
pub fn with_default_cache(config: SanctumConfig) -> Self {
let cache = Arc::new(Cache::new());
cache.register_default(MemoryCacheDriver::new());
Self::new(cache, config)
}
pub fn config(&self) -> &SanctumConfig {
&self.config
}
pub fn create_token(
&self,
user_id: i64,
name: impl Into<String>,
abilities: Vec<Ability>,
ttl: Option<Duration>,
) -> Result<String, SanctumError> {
let _guard = self.lock.lock();
let token_id = self
.cache
.inc(&self.config.id_counter_key, 1)
.map_err(|e| SanctumError::Cache(e.to_string()))?;
let random_hex = generate_random_hex().map_err(SanctumError::Rng)?;
let plain_token = format!("{}{}{}", token_id, TOKEN_DELIMITER, random_hex);
let token_hash = hash_token(&plain_token);
let cache_key = format!("{}{}", self.config.key_prefix, token_hash);
let now = chrono::Utc::now().timestamp();
let effective_ttl = ttl.or(self.config.default_ttl);
let expires_at = effective_ttl.map(|d| now + d.as_secs() as i64);
let token = PersonalAccessToken {
id: token_id,
user_id,
name: name.into(),
abilities,
created_at: now,
last_used_at: None,
expires_at,
};
self.cache
.set(&cache_key, &token, None) .map_err(|e| SanctumError::Cache(e.to_string()))?;
let user_index_key = format!("{}{}:tokens", self.config.user_index_prefix, user_id);
let mut user_tokens: Vec<i64> = self
.cache
.get(&user_index_key)
.map_err(|e| SanctumError::Cache(e.to_string()))?
.unwrap_or_default();
user_tokens.push(token_id);
self.cache
.set(&user_index_key, &user_tokens, None)
.map_err(|e| SanctumError::Cache(e.to_string()))?;
let id_to_hash_key = format!("{}id:{}:hash", self.config.key_prefix, token_id);
self.cache
.set(&id_to_hash_key, &token_hash, None) .map_err(|e| SanctumError::Cache(e.to_string()))?;
Ok(plain_token)
}
pub fn validate(&self, plain_token: &str) -> Result<SanctumUser, SanctumError> {
let (_id, _random) = parse_token(plain_token)?;
let token_hash = hash_token(plain_token);
let cache_key = format!("{}{}", self.config.key_prefix, token_hash);
let mut token: PersonalAccessToken = self
.cache
.get(&cache_key)
.map_err(|e| SanctumError::Cache(e.to_string()))?
.ok_or(SanctumError::NotFound)?;
let now = chrono::Utc::now().timestamp();
if let Some(exp) = token.expires_at {
if now >= exp {
return Err(SanctumError::Expired);
}
}
token.last_used_at = Some(now);
let _ = self.cache.set(&cache_key, &token, None);
Ok(SanctumUser {
user_id: token.user_id,
token_id: token.id,
token_name: token.name,
abilities: token.abilities,
})
}
pub fn revoke(&self, plain_token: &str) -> Result<bool, SanctumError> {
let _guard = self.lock.lock();
let token_hash = hash_token(plain_token);
let cache_key = format!("{}{}", self.config.key_prefix, token_hash);
let token: Option<PersonalAccessToken> = self
.cache
.get(&cache_key)
.map_err(|e| SanctumError::Cache(e.to_string()))?;
let existed = token.is_some();
if let Some(t) = token {
let id_to_hash_key = format!("{}id:{}:hash", self.config.key_prefix, t.id);
let _ = self.cache.delete(&id_to_hash_key);
self.remove_token_from_user_index(t.user_id, t.id)?;
}
self.cache
.delete(&cache_key)
.map_err(|e| SanctumError::Cache(e.to_string()))?;
Ok(existed)
}
pub fn revoke_by_id(&self, token_id: i64) -> Result<bool, SanctumError> {
let _guard = self.lock.lock();
let id_to_hash_key = format!("{}id:{}:hash", self.config.key_prefix, token_id);
let token_hash: Option<String> = self
.cache
.get(&id_to_hash_key)
.map_err(|e| SanctumError::Cache(e.to_string()))?;
let Some(token_hash) = token_hash else {
return Ok(false);
};
let cache_key = format!("{}{}", self.config.key_prefix, token_hash);
let token: Option<PersonalAccessToken> = self
.cache
.get(&cache_key)
.map_err(|e| SanctumError::Cache(e.to_string()))?;
let existed = token.is_some();
if let Some(t) = token {
self.remove_token_from_user_index(t.user_id, t.id)?;
}
let _ = self.cache.delete(&id_to_hash_key);
self.cache
.delete(&cache_key)
.map_err(|e| SanctumError::Cache(e.to_string()))?;
Ok(existed)
}
pub fn revoke_all_for_user(&self, user_id: i64) -> Result<usize, SanctumError> {
let _guard = self.lock.lock();
let user_index_key = format!("{}{}:tokens", self.config.user_index_prefix, user_id);
let token_ids: Vec<i64> = self
.cache
.get(&user_index_key)
.map_err(|e| SanctumError::Cache(e.to_string()))?
.unwrap_or_default();
let mut revoked = 0usize;
for token_id in &token_ids {
let id_to_hash_key = format!("{}id:{}:hash", self.config.key_prefix, token_id);
let token_hash: Option<String> = self
.cache
.get(&id_to_hash_key)
.map_err(|e| SanctumError::Cache(e.to_string()))?;
if let Some(hash) = token_hash {
let cache_key = format!("{}{}", self.config.key_prefix, hash);
let _ = self.cache.delete(&id_to_hash_key);
let _ = self.cache.delete(&cache_key);
revoked += 1;
}
}
self.cache
.delete(&user_index_key)
.map_err(|e| SanctumError::Cache(e.to_string()))?;
Ok(revoked)
}
pub fn tokens_for_user(&self, user_id: i64) -> Result<Vec<PersonalAccessToken>, SanctumError> {
let user_index_key = format!("{}{}:tokens", self.config.user_index_prefix, user_id);
let token_ids: Vec<i64> = self
.cache
.get(&user_index_key)
.map_err(|e| SanctumError::Cache(e.to_string()))?
.unwrap_or_default();
let mut tokens = Vec::with_capacity(token_ids.len());
for token_id in &token_ids {
let id_to_hash_key = format!("{}id:{}:hash", self.config.key_prefix, token_id);
let token_hash: Option<String> = self
.cache
.get(&id_to_hash_key)
.map_err(|e| SanctumError::Cache(e.to_string()))?;
if let Some(hash) = token_hash {
let cache_key = format!("{}{}", self.config.key_prefix, hash);
if let Some(token) = self
.cache
.get::<PersonalAccessToken>(&cache_key)
.map_err(|e| SanctumError::Cache(e.to_string()))?
{
tokens.push(token);
}
}
}
Ok(tokens)
}
fn remove_token_from_user_index(
&self,
user_id: i64,
token_id: i64,
) -> Result<(), SanctumError> {
let user_index_key = format!("{}{}:tokens", self.config.user_index_prefix, user_id);
let mut user_tokens: Vec<i64> = self
.cache
.get(&user_index_key)
.map_err(|e| SanctumError::Cache(e.to_string()))?
.unwrap_or_default();
user_tokens.retain(|id| *id != token_id);
self.cache
.set(&user_index_key, &user_tokens, None)
.map_err(|e| SanctumError::Cache(e.to_string()))?;
Ok(())
}
}
impl std::fmt::Debug for Sanctum {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Sanctum")
.field("config", &self.config)
.finish_non_exhaustive()
}
}
fn hash_token(plain_token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(plain_token.as_bytes());
let result = hasher.finalize();
hex_encode(&result)
}
fn parse_token(plain_token: &str) -> Result<(i64, &str), SanctumError> {
let trimmed = plain_token.trim();
let Some((id_str, random)) = trimmed.split_once(TOKEN_DELIMITER) else {
return Err(SanctumError::InvalidFormat);
};
let id: i64 = id_str.parse().map_err(|_| SanctumError::InvalidFormat)?;
if random.is_empty() {
return Err(SanctumError::InvalidFormat);
}
Ok((id, random))
}
fn generate_random_hex() -> Result<String, String> {
use rand::RngCore;
let mut bytes = [0u8; RANDOM_BYTES_LEN];
rand::rngs::OsRng.fill_bytes(&mut bytes);
Ok(hex_encode(&bytes))
}
fn hex_encode(bytes: &[u8]) -> String {
const HEX_CHARS: &[u8; 16] = b"0123456789abcdef";
let mut s = String::with_capacity(bytes.len() * 2);
for &b in bytes {
s.push(HEX_CHARS[(b >> 4) as usize] as char);
s.push(HEX_CHARS[(b & 0x0f) as usize] as char);
}
s
}
pub async fn sanctum_middleware(
State(sanctum): State<Sanctum>,
req: Request,
next: Next,
) -> axum::response::Response {
let config = sanctum.config();
let route_uri = extract_route_uri(&req);
if is_route_allowed(&route_uri, &config.allow_all_action) {
return next.run(req).await.into_response();
}
let auth_header = req.headers().get(axum::http::header::AUTHORIZATION);
let token = match auth_header {
Some(value) => {
let raw = value.to_str().unwrap_or("");
crate::auth::extract_token_from_header(raw)
}
None => None,
};
let token = match token {
Some(t) if !t.is_empty() => t,
_ => {
return base_exception_to_response(BaseException::not_login(
"缺少必要的参数,请重新登陆!",
));
}
};
match sanctum.validate(&token) {
Ok(user) => {
let mut req = req;
req.extensions_mut().insert(user);
next.run(req).await.into_response()
}
Err(SanctumError::Expired) => {
base_exception_to_response(BaseException::not_login("token 已过期,请重新登陆!"))
}
Err(_) => {
base_exception_to_response(BaseException::not_login("缺少必要的参数,请重新登陆!"))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use sz_rust_cache_facade::{Cache, MemoryCacheDriver};
fn make_sanctum() -> Sanctum {
let cache = Arc::new(Cache::new());
cache.register_default(MemoryCacheDriver::new());
Sanctum::new(cache, SanctumConfig::default())
}
#[test]
fn test_create_and_validate_token() {
let sanctum = make_sanctum();
let plain = sanctum
.create_token(1, "web", vec!["read".into(), "write".into()], None)
.expect("签发 token 应成功");
assert!(plain.contains(TOKEN_DELIMITER));
let user = sanctum.validate(&plain).expect("校验应成功");
assert_eq!(user.user_id, 1);
assert_eq!(user.token_name, "web");
assert!(user.token_can("read"));
assert!(user.token_can("write"));
assert!(!user.token_can("delete"));
}
#[test]
fn test_wildcard_ability() {
let sanctum = make_sanctum();
let plain = sanctum
.create_token(2, "admin", vec!["*".into()], None)
.expect("签发 token 应成功");
let user = sanctum.validate(&plain).expect("校验应成功");
assert!(user.token_can("read"));
assert!(user.token_can("write"));
assert!(user.token_can("anything"));
}
#[test]
fn test_revoke_token() {
let sanctum = make_sanctum();
let plain = sanctum
.create_token(3, "web", vec!["read".into()], None)
.expect("签发 token 应成功");
assert!(sanctum.validate(&plain).is_ok());
let revoked = sanctum.revoke(&plain).expect("撤销应成功");
assert!(revoked);
assert!(matches!(
sanctum.validate(&plain),
Err(SanctumError::NotFound)
));
let revoked_again = sanctum.revoke(&plain).expect("撤销应成功");
assert!(!revoked_again);
}
#[test]
fn test_revoke_by_id() {
let sanctum = make_sanctum();
let plain = sanctum
.create_token(4, "mobile", vec!["read".into()], None)
.expect("签发 token 应成功");
let user = sanctum.validate(&plain).expect("校验应成功");
let token_id = user.token_id;
let revoked = sanctum.revoke_by_id(token_id).expect("撤销应成功");
assert!(revoked);
assert!(matches!(
sanctum.validate(&plain),
Err(SanctumError::NotFound)
));
}
#[test]
fn test_revoke_all_for_user() {
let sanctum = make_sanctum();
let t1 = sanctum
.create_token(5, "web", vec!["*".into()], None)
.unwrap();
let t2 = sanctum
.create_token(5, "mobile", vec!["*".into()], None)
.unwrap();
let t3 = sanctum
.create_token(5, "tablet", vec!["*".into()], None)
.unwrap();
let other = sanctum
.create_token(6, "web", vec!["*".into()], None)
.unwrap();
let count = sanctum.revoke_all_for_user(5).expect("撤销应成功");
assert_eq!(count, 3);
assert!(matches!(sanctum.validate(&t1), Err(SanctumError::NotFound)));
assert!(matches!(sanctum.validate(&t2), Err(SanctumError::NotFound)));
assert!(matches!(sanctum.validate(&t3), Err(SanctumError::NotFound)));
assert!(sanctum.validate(&other).is_ok());
}
#[test]
fn test_tokens_for_user() {
let sanctum = make_sanctum();
sanctum
.create_token(7, "web", vec!["read".into()], None)
.unwrap();
sanctum
.create_token(7, "mobile", vec!["write".into()], None)
.unwrap();
sanctum
.create_token(8, "web", vec!["*".into()], None)
.unwrap();
let tokens = sanctum.tokens_for_user(7).expect("查询应成功");
assert_eq!(tokens.len(), 2);
let tokens_other = sanctum.tokens_for_user(8).expect("查询应成功");
assert_eq!(tokens_other.len(), 1);
}
#[test]
fn test_token_expiry() {
let sanctum = make_sanctum();
let plain = sanctum
.create_token(9, "web", vec!["*".into()], Some(Duration::from_secs(1)))
.expect("签发应成功");
assert!(sanctum.validate(&plain).is_ok());
std::thread::sleep(Duration::from_millis(1100));
assert!(matches!(
sanctum.validate(&plain),
Err(SanctumError::Expired)
));
}
#[test]
fn test_invalid_token_format() {
let sanctum = make_sanctum();
assert!(matches!(
sanctum.validate("invalidtoken"),
Err(SanctumError::InvalidFormat)
));
assert!(matches!(
sanctum.validate("abc|def"),
Err(SanctumError::InvalidFormat)
));
assert!(matches!(
sanctum.validate("1|"),
Err(SanctumError::InvalidFormat)
));
}
#[test]
fn test_personal_access_token_can() {
let token = PersonalAccessToken {
id: 1,
user_id: 1,
name: "test".into(),
abilities: vec!["read".into(), "write".into()],
created_at: 0,
last_used_at: None,
expires_at: None,
};
assert!(token.can("read"));
assert!(token.can("write"));
assert!(!token.can("delete"));
assert!(token.cannot("delete"));
let wildcard = PersonalAccessToken {
abilities: vec!["*".into()],
..token
};
assert!(wildcard.can("anything"));
}
#[test]
fn test_parse_token_format() {
let (id, random) = parse_token("123|abcdef").unwrap();
assert_eq!(id, 123);
assert_eq!(random, "abcdef");
let (id, _) = parse_token(" 456|xyz ").unwrap();
assert_eq!(id, 456);
assert!(parse_token("no-delimiter").is_err());
assert!(parse_token("|nonumber").is_err());
assert!(parse_token("1|").is_err());
}
#[test]
fn test_hash_token_deterministic() {
let h1 = hash_token("test_token");
let h2 = hash_token("test_token");
assert_eq!(h1, h2);
assert_eq!(h1.len(), 64);
let h3 = hash_token("other_token");
assert_ne!(h1, h3);
}
#[test]
fn test_last_used_at_updated() {
let sanctum = make_sanctum();
let plain = sanctum
.create_token(10, "web", vec!["*".into()], None)
.unwrap();
let before = chrono::Utc::now().timestamp();
let _ = sanctum.validate(&plain).unwrap();
let after = chrono::Utc::now().timestamp();
let tokens = sanctum.tokens_for_user(10).unwrap();
assert_eq!(tokens.len(), 1);
let last_used = tokens[0].last_used_at.expect("last_used_at 应已更新");
assert!(last_used >= before - 1 && last_used <= after + 1);
}
#[test]
fn test_sanctum_config_builder() {
let config = SanctumConfig::default()
.with_allow_all_action(vec!["/login".into()])
.with_default_ttl(Some(Duration::from_secs(3600)))
.with_key_prefix("custom:");
assert_eq!(config.allow_all_action, vec!["/login".to_string()]);
assert_eq!(config.default_ttl, Some(Duration::from_secs(3600)));
assert_eq!(config.key_prefix, "custom:");
}
#[test]
fn test_create_multiple_tokens_unique() {
let sanctum = make_sanctum();
let t1 = sanctum.create_token(1, "a", vec![], None).unwrap();
let t2 = sanctum.create_token(1, "b", vec![], None).unwrap();
let t3 = sanctum.create_token(1, "c", vec![], None).unwrap();
assert_ne!(t1, t2);
assert_ne!(t1, t3);
assert_ne!(t2, t3);
assert!(sanctum.validate(&t1).is_ok());
assert!(sanctum.validate(&t2).is_ok());
assert!(sanctum.validate(&t3).is_ok());
}
#[test]
fn test_generate_random_hex_format() {
let hex = generate_random_hex().expect("generate_random_hex 不应失败");
assert_eq!(hex.len(), 64, "random_hex 应为 64 字符(32 字节 * 2)");
assert!(
hex.chars().all(|c| c.is_ascii_hexdigit()),
"random_hex 应全为十六进制字符: {hex}"
);
}
#[test]
fn test_generate_random_hex_uniqueness() {
let values: Vec<String> = (0..10).map(|_| generate_random_hex().unwrap()).collect();
for (i, v1) in values.iter().enumerate() {
for v2 in values.iter().skip(i + 1) {
assert_ne!(
v1, v2,
"OsRng 生成的随机值出现重复(索引 {i}),可能存在 RNG 安全问题"
);
}
}
}
#[test]
fn test_token_random_part_uniqueness() {
let sanctum = make_sanctum();
let random_parts: Vec<String> = (0..5)
.map(|_| {
let plain = sanctum.create_token(42, "api", vec![], None).unwrap();
let (_, random) = parse_token(&plain).unwrap();
random.to_string()
})
.collect();
for (i, r1) in random_parts.iter().enumerate() {
for r2 in random_parts.iter().skip(i + 1) {
assert_ne!(
r1, r2,
"同一用户的 token 随机部分出现重复(索引 {i}),攻击者可能伪造令牌"
);
}
}
}
}