Skip to main content

yggdrasil/auth/
session.rs

1//! 会话 token 生成、哈希与 Cookie 处理。
2//!
3//! token 使用 UUID,存储时使用 SHA-256 哈希,
4//! Cookie 包含 HttpOnly、SameSite=Lax 与可选 Secure 标志。
5//! 服务端上下文解析函数仅在 `feature = "server"` 时可用。
6
7#[cfg(feature = "server")]
8use chrono::{DateTime, Duration, Utc};
9#[cfg(feature = "server")]
10use uuid::Uuid;
11
12#[cfg(feature = "server")]
13/// 生成新的随机会话 token(UUID 格式)。
14pub fn generate_token() -> String {
15    Uuid::new_v4().to_string()
16}
17
18#[cfg(feature = "server")]
19/// 使用 SHA-256 对 token 进行哈希,用于数据库存储。
20pub fn hash_token(token: &str) -> String {
21    crate::utils::server::sha256_hex(token)
22}
23
24#[cfg(feature = "server")]
25/// 返回默认会话过期时间(当前时间 + 30 天)。
26pub fn default_expiry() -> DateTime<Utc> {
27    Utc::now() + Duration::days(30)
28}
29
30#[cfg(feature = "server")]
31/// 读取环境变量 `COOKIE_SECURE`,决定是否给 Cookie 添加 Secure 标志。
32pub fn cookie_secure() -> bool {
33    std::env::var("COOKIE_SECURE")
34        .ok()
35        .map(|v| matches!(v.as_str(), "1" | "true" | "yes"))
36        .unwrap_or(false)
37}
38
39#[cfg(feature = "server")]
40/// 构造会话 Cookie 字符串,包含 HttpOnly、Path、Max-Age 与 SameSite。
41pub fn session_cookie(token: &str, max_age_seconds: i32, secure: bool) -> String {
42    let secure_flag = if secure { "; Secure" } else { "" };
43    format!(
44        "session={token}; HttpOnly; Path=/; Max-Age={max_age_seconds}; SameSite=Lax{secure_flag}"
45    )
46}
47
48#[cfg(feature = "server")]
49/// 从 `Cookie` 头中解析名为 `session` 的 token 值。
50pub fn parse_session_token(cookie_header: &str) -> Option<&str> {
51    cookie_header.split(';').map(|s| s.trim()).find_map(|pair| {
52        let mut parts = pair.splitn(2, '=');
53        let name = parts.next()?.trim();
54        let value = parts.next()?.trim();
55        if name == "session" {
56            Some(value)
57        } else {
58            None
59        }
60    })
61}
62
63#[cfg(feature = "server")]
64/// 从 Dioxus `FullstackContext` 中读取 Cookie 并返回会话 token。
65pub fn get_session_from_ctx() -> Option<String> {
66    use dioxus::fullstack::FullstackContext;
67
68    FullstackContext::current().and_then(|ctx| {
69        let parts = ctx.parts_mut();
70        parts
71            .headers
72            .get("cookie")
73            .and_then(|h| h.to_str().ok())
74            .and_then(parse_session_token)
75            .map(|s| s.to_string())
76    })
77}
78
79#[cfg(all(test, feature = "server"))]
80mod tests {
81    use super::*;
82    use sha2::Digest; // hash_token_known_value 用 Sha256::digest 校验已知值
83
84    #[test]
85    fn parse_session_found() {
86        let header = "session=abc123; path=/";
87        assert_eq!(parse_session_token(header), Some("abc123"));
88    }
89
90    #[test]
91    fn parse_session_single_cookie() {
92        assert_eq!(parse_session_token("session=token456"), Some("token456"));
93    }
94
95    #[test]
96    fn parse_session_not_found() {
97        assert_eq!(parse_session_token("other=value"), None);
98    }
99
100    #[test]
101    fn parse_session_empty_string() {
102        assert_eq!(parse_session_token(""), None);
103    }
104
105    #[test]
106    fn parse_session_multiple_cookies() {
107        let header = "theme=dark; session=my-secret; lang=en";
108        assert_eq!(parse_session_token(header), Some("my-secret"));
109    }
110
111    #[test]
112    fn parse_session_empty_value() {
113        assert_eq!(parse_session_token("session="), Some(""));
114    }
115
116    #[test]
117    fn parse_session_trailing_semicolon() {
118        assert_eq!(parse_session_token("session=abc;"), Some("abc"));
119    }
120
121    #[test]
122    fn generate_token_is_uuid() {
123        let token = generate_token();
124        assert!(uuid::Uuid::parse_str(&token).is_ok());
125    }
126
127    #[test]
128    fn default_expiry_is_future() {
129        let expiry = default_expiry();
130        assert!(expiry > chrono::Utc::now());
131    }
132
133    #[test]
134    fn default_expiry_is_about_30_days() {
135        let expiry = default_expiry();
136        let diff = expiry - chrono::Utc::now();
137        assert!(diff.num_days() >= 29 && diff.num_days() <= 31);
138    }
139
140    #[test]
141    fn hash_token_is_deterministic() {
142        let token = "test-token-123";
143        assert_eq!(hash_token(token), hash_token(token));
144    }
145
146    #[test]
147    fn hash_token_is_64_chars() {
148        let hash = hash_token("any-token");
149        assert_eq!(hash.len(), 64);
150    }
151
152    #[test]
153    fn hash_token_differs_from_input() {
154        let token = "my-secret-token";
155        assert_ne!(hash_token(token), token);
156    }
157
158    #[test]
159    fn hash_token_known_value() {
160        let hash = hash_token("hello");
161        let expected = sha2::Sha256::digest(b"hello");
162        assert_eq!(hash, hex::encode(expected));
163    }
164
165    #[test]
166    fn session_cookie_without_secure() {
167        let cookie = session_cookie("abc", 3600, false);
168        assert!(cookie.contains("session=abc"));
169        assert!(cookie.contains("HttpOnly"));
170        assert!(cookie.contains("SameSite=Lax"));
171        assert!(!cookie.contains("Secure"));
172    }
173
174    #[test]
175    fn session_cookie_with_secure() {
176        let cookie = session_cookie("abc", 3600, true);
177        assert!(cookie.contains("Secure"));
178    }
179
180    #[test]
181    fn session_cookie_logout_has_zero_max_age() {
182        let cookie = session_cookie("", 0, false);
183        assert!(cookie.contains("Max-Age=0"));
184    }
185}