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 天。同时驱动 DB `expires_at`(见 [`default_expiry`])
26/// 与登录时下发的 Cookie `Max-Age`(`api/auth.rs::login`),避免两处独立硬编码导致漂移。
27pub const SESSION_MAX_AGE_SECS: i32 = 30 * 24 * 60 * 60;
28
29#[cfg(feature = "server")]
30/// 返回默认会话过期时间(当前时间 + [`SESSION_MAX_AGE_SECS`])。
31pub fn default_expiry() -> DateTime<Utc> {
32    Utc::now() + Duration::seconds(SESSION_MAX_AGE_SECS as i64)
33}
34
35#[cfg(feature = "server")]
36/// 构造会话 Cookie 字符串,包含 HttpOnly、Path、Max-Age 与 SameSite。
37pub fn session_cookie(token: &str, max_age_seconds: i32, secure: bool) -> String {
38    let secure_flag = if secure { "; Secure" } else { "" };
39    format!(
40        "session={token}; HttpOnly; Path=/; Max-Age={max_age_seconds}; SameSite=Lax{secure_flag}"
41    )
42}
43
44#[cfg(feature = "server")]
45/// 从 `Cookie` 头中解析名为 `session` 的 token 值。
46pub fn parse_session_token(cookie_header: &str) -> Option<&str> {
47    cookie_header.split(';').map(|s| s.trim()).find_map(|pair| {
48        let mut parts = pair.splitn(2, '=');
49        let name = parts.next()?.trim();
50        let value = parts.next()?.trim();
51        if name == "session" {
52            Some(value)
53        } else {
54            None
55        }
56    })
57}
58
59#[cfg(feature = "server")]
60/// 从 Dioxus `FullstackContext` 中读取 Cookie 并返回会话 token。
61pub fn get_session_from_ctx() -> Option<String> {
62    use dioxus::fullstack::FullstackContext;
63
64    FullstackContext::current().and_then(|ctx| {
65        let parts = ctx.parts_mut();
66        parts
67            .headers
68            .get("cookie")
69            .and_then(|h| h.to_str().ok())
70            .and_then(parse_session_token)
71            .map(|s| s.to_string())
72    })
73}
74
75#[cfg(all(test, feature = "server"))]
76mod tests {
77    use super::*;
78    use sha2::Digest; // hash_token_known_value 用 Sha256::digest 校验已知值
79
80    #[test]
81    fn parse_session_found() {
82        let header = "session=abc123; path=/";
83        assert_eq!(parse_session_token(header), Some("abc123"));
84    }
85
86    #[test]
87    fn parse_session_single_cookie() {
88        assert_eq!(parse_session_token("session=token456"), Some("token456"));
89    }
90
91    #[test]
92    fn parse_session_not_found() {
93        assert_eq!(parse_session_token("other=value"), None);
94    }
95
96    #[test]
97    fn parse_session_empty_string() {
98        assert_eq!(parse_session_token(""), None);
99    }
100
101    #[test]
102    fn parse_session_multiple_cookies() {
103        let header = "theme=dark; session=my-secret; lang=en";
104        assert_eq!(parse_session_token(header), Some("my-secret"));
105    }
106
107    #[test]
108    fn parse_session_empty_value() {
109        assert_eq!(parse_session_token("session="), Some(""));
110    }
111
112    #[test]
113    fn parse_session_trailing_semicolon() {
114        assert_eq!(parse_session_token("session=abc;"), Some("abc"));
115    }
116
117    #[test]
118    fn generate_token_is_uuid() {
119        let token = generate_token();
120        assert!(uuid::Uuid::parse_str(&token).is_ok());
121    }
122
123    #[test]
124    fn default_expiry_is_future() {
125        let expiry = default_expiry();
126        assert!(expiry > chrono::Utc::now());
127    }
128
129    #[test]
130    fn default_expiry_is_about_30_days() {
131        let expiry = default_expiry();
132        let diff = expiry - chrono::Utc::now();
133        assert!(diff.num_days() >= 29 && diff.num_days() <= 31);
134    }
135
136    #[test]
137    fn hash_token_is_deterministic() {
138        let token = "test-token-123";
139        assert_eq!(hash_token(token), hash_token(token));
140    }
141
142    #[test]
143    fn hash_token_is_64_chars() {
144        let hash = hash_token("any-token");
145        assert_eq!(hash.len(), 64);
146    }
147
148    #[test]
149    fn hash_token_differs_from_input() {
150        let token = "my-secret-token";
151        assert_ne!(hash_token(token), token);
152    }
153
154    #[test]
155    fn hash_token_known_value() {
156        let hash = hash_token("hello");
157        let expected = sha2::Sha256::digest(b"hello");
158        assert_eq!(hash, hex::encode(expected));
159    }
160
161    #[test]
162    fn session_cookie_without_secure() {
163        let cookie = session_cookie("abc", 3600, false);
164        assert!(cookie.contains("session=abc"));
165        assert!(cookie.contains("HttpOnly"));
166        assert!(cookie.contains("SameSite=Lax"));
167        assert!(!cookie.contains("Secure"));
168    }
169
170    #[test]
171    fn session_cookie_with_secure() {
172        let cookie = session_cookie("abc", 3600, true);
173        assert!(cookie.contains("Secure"));
174    }
175
176    #[test]
177    fn session_cookie_logout_has_zero_max_age() {
178        let cookie = session_cookie("", 0, false);
179        assert!(cookie.contains("Max-Age=0"));
180    }
181}