hiver-security 0.1.0-alpha.6

Security framework for Hiver Framework. Hiver框架的安全框架。 Equivalent to: Spring Security (@PreAuthorize, @Secured, @RolesAllowed)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
//! Remember-Me authentication — persistent login via cookie tokens.
//! 记住我认证 —— 通过 Cookie 令牌实现持久登录。
//!
//! Equivalent to Spring Security's `RememberMeServices` + `TokenBasedRememberMeServices`.

use std::collections::HashMap;
use std::sync::RwLock;

use chrono::{DateTime, Utc};
use hmac::{Hmac, Mac};
use rand::RngCore;
use sha2::Sha256;

type HmacSha256 = Hmac<Sha256>;

/// A remember-me token stored in the repository.
/// 存储在仓库中的记住我令牌。
#[derive(Debug, Clone)]
pub struct RememberMeToken {
    /// Random series identifier — ties all tokens for a login session.
    pub series: String,
    /// Random token value — rotated on each successful authentication.
    pub value: String,
    /// The username this token belongs to.
    pub username: String,
    /// Last time this token was used.
    pub last_used: DateTime<Utc>,
}

impl RememberMeToken {
    /// Encode token as `series:token` for a cookie value.
    /// 将令牌编码为 series:token 格式的 Cookie 值。
    pub fn to_cookie_value(&self) -> String {
        format!("{}:{}", self.series, self.value)
    }

    /// Parse a cookie value back into (series, value).
    /// 从 Cookie 值解析出 (series, value)。
    pub fn from_cookie_value(cookie: &str) -> Option<(String, String)> {
        let parts: Vec<&str> = cookie.splitn(2, ':').collect();
        if parts.len() == 2 && !parts[0].is_empty() && !parts[1].is_empty() {
            Some((parts[0].to_string(), parts[1].to_string()))
        } else {
            None
        }
    }

    /// Refresh with a new random value, updating last_used.
    /// 用新的随机值刷新令牌,更新 last_used。
    pub fn refresh(&mut self) {
        self.value = random_hex(16);
        self.last_used = Utc::now();
    }
}

/// In-memory token repository.
/// 内存令牌仓库。
pub struct InMemoryTokenRepository {
    tokens: RwLock<HashMap<String, RememberMeToken>>,
}

impl InMemoryTokenRepository {
    /// Create an empty repository.
    /// 创建空仓库。
    pub fn new() -> Self {
        Self {
            tokens: RwLock::new(HashMap::new()),
        }
    }

    /// Save a new token.
    /// 保存新令牌。
    pub fn save(&self, token: RememberMeToken) {
        self.tokens
            .write()
            .unwrap()
            .insert(token.series.clone(), token);
    }

    /// Find a token by its series identifier.
    /// 通过 series 标识查找令牌。
    pub fn find_by_series(&self, series: &str) -> Option<RememberMeToken> {
        self.tokens.read().unwrap().get(series).cloned()
    }

    /// Update an existing token.
    /// 更新现有令牌。
    pub fn update(&self, token: &RememberMeToken) {
        self.tokens
            .write()
            .unwrap()
            .insert(token.series.clone(), token.clone());
    }

    /// Remove a token by series.
    /// 通过 series 移除令牌。
    pub fn remove(&self, series: &str) {
        self.tokens.write().unwrap().remove(series);
    }

    /// Remove all tokens for a given user.
    /// 移除指定用户的所有令牌。
    pub fn remove_user_tokens(&self, username: &str) {
        self.tokens
            .write()
            .unwrap()
            .retain(|_, t| t.username != username);
    }
}

impl Default for InMemoryTokenRepository {
    fn default() -> Self {
        Self::new()
    }
}

/// Configuration for Remember-Me services.
/// 记住我服务的配置。
pub struct RememberMeConfig {
    /// Cookie name (default: "remember-me").
    pub cookie_name: String,
    /// How long the token is valid, in seconds.
    pub token_validity_secs: u64,
    /// Whether to set the Secure flag on the cookie.
    pub secure_cookie: bool,
    /// HMAC key for token-based signatures.
    pub key: String,
}

impl Default for RememberMeConfig {
    fn default() -> Self {
        Self {
            cookie_name: "remember-me".to_string(),
            token_validity_secs: 14 * 24 * 3600, // 2 weeks
            secure_cookie: true,
            key: "hiver-remember-me-key".to_string(),
        }
    }
}

impl RememberMeConfig {
    /// Create config with a custom key.
    /// 用自定义密钥创建配置。
    pub fn with_key(mut self, key: impl Into<String>) -> Self {
        self.key = key.into();
        self
    }
}

/// Remember-Me authentication services.
/// 记住我认证服务。
///
/// Equivalent to Spring Security's `TokenBasedRememberMeServices` when using
/// the token-based strategy, or `PersistentTokenBasedRememberMeServices` when
/// using the persistent repository.
pub struct RememberMeServices {
    config: RememberMeConfig,
    repository: InMemoryTokenRepository,
}

impl RememberMeServices {
    /// Create new services with default config.
    /// 用默认配置创建服务。
    pub fn new(config: RememberMeConfig) -> Self {
        Self {
            config,
            repository: InMemoryTokenRepository::new(),
        }
    }

    /// Called after a successful interactive login — creates a new persistent token.
    /// 交互式登录成功后调用 — 创建新的持久令牌。
    pub fn login_success(&self, username: &str) -> RememberMeToken {
        let token = RememberMeToken {
            series: random_hex(16),
            value: random_hex(16),
            username: username.to_string(),
            last_used: Utc::now(),
        };
        self.repository.save(token.clone());
        token
    }

    /// Attempt auto-login from a cookie value.
    /// 尝试从 Cookie 值自动登录。
    ///
    /// Returns the username if valid, or `None` if the token is missing,
    /// expired, or stolen (series found but value mismatch → all tokens removed).
    pub fn auto_login(&self, cookie_value: &str) -> Option<String> {
        let (series, value) = RememberMeToken::from_cookie_value(cookie_value)?;

        let mut token = self.repository.find_by_series(&series)?;

        // Token value mismatch → potential theft; invalidate all user tokens
        // 令牌值不匹配 → 可能被盗;清除该用户所有令牌
        if token.value != value {
            let username = token.username.clone();
            self.repository.remove_user_tokens(&username);
            tracing::warn!(
                "Remember-me token mismatch for user={}, series={}. Possible token theft.",
                username,
                series
            );
            return None;
        }

        // Check expiry
        // 检查过期
        let elapsed = Utc::now()
            .signed_duration_since(token.last_used)
            .num_seconds();
        if elapsed < 0 || elapsed as u64 > self.config.token_validity_secs {
            self.repository.remove(&series);
            return None;
        }

        // Rotate token value
        // 轮换令牌值
        let username = token.username.clone();
        token.refresh();
        self.repository.update(&token);

        Some(username)
    }

    /// Logout — remove the token identified by the cookie.
    /// 登出 — 移除 Cookie 对应的令牌。
    pub fn logout(&self, cookie_value: &str) {
        if let Some((series, _)) = RememberMeToken::from_cookie_value(cookie_value) {
            self.repository.remove(&series);
        }
    }

    /// Generate a token-based signature (HMAC-SHA256).
    /// 生成基于令牌的签名 (HMAC-SHA256)。
    pub fn hash_token(&self, token: &RememberMeToken) -> String {
        let data =
            format!("{}:{}:{}:{}", token.username, token.series, token.value, self.config.key);
        let mut mac = HmacSha256::new_from_slice(self.config.key.as_bytes())
            .expect("HMAC key length is valid");
        mac.update(data.as_bytes());
        let result = mac.finalize();
        hex::encode(result.into_bytes())
    }
}

/// Generate `n` random bytes as a hex string.
/// 生成 n 个随机字节的十六进制字符串。
fn random_hex(n: usize) -> String {
    let mut buf = vec![0u8; n];
    rand::rng().fill_bytes(&mut buf);
    hex::encode(&buf)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_token_roundtrip() {
        let token = RememberMeToken {
            series: "abc123".to_string(),
            value: "def456".to_string(),
            username: "alice".to_string(),
            last_used: Utc::now(),
        };
        let cookie = token.to_cookie_value();
        let (series, value) = RememberMeToken::from_cookie_value(&cookie).unwrap();
        assert_eq!(series, "abc123");
        assert_eq!(value, "def456");
    }

    #[test]
    fn test_invalid_cookie_values() {
        assert!(RememberMeToken::from_cookie_value("").is_none());
        assert!(RememberMeToken::from_cookie_value(":").is_none());
        assert!(RememberMeToken::from_cookie_value("onlyseries:").is_none());
        assert!(RememberMeToken::from_cookie_value(":onlyvalue").is_none());
    }

    #[test]
    fn test_token_refresh() {
        let mut token = RememberMeToken {
            series: "s".to_string(),
            value: "v".to_string(),
            username: "bob".to_string(),
            last_used: Utc::now(),
        };
        let old_value = token.value.clone();
        token.refresh();
        assert_ne!(token.value, old_value);
        assert_eq!(token.series, "s");
    }

    #[test]
    fn test_repository_crud() {
        let repo = InMemoryTokenRepository::new();
        let token = RememberMeToken {
            series: "s1".to_string(),
            value: "v1".to_string(),
            username: "alice".to_string(),
            last_used: Utc::now(),
        };
        repo.save(token.clone());
        assert!(repo.find_by_series("s1").is_some());
        assert!(repo.find_by_series("s2").is_none());

        let mut found = repo.find_by_series("s1").unwrap();
        found.value = "v2".to_string();
        repo.update(&found);
        assert_eq!(repo.find_by_series("s1").unwrap().value, "v2");

        repo.remove("s1");
        assert!(repo.find_by_series("s1").is_none());
    }

    #[test]
    fn test_remove_user_tokens() {
        let repo = InMemoryTokenRepository::new();
        repo.save(RememberMeToken {
            series: "s1".to_string(),
            value: "v1".to_string(),
            username: "alice".to_string(),
            last_used: Utc::now(),
        });
        repo.save(RememberMeToken {
            series: "s2".to_string(),
            value: "v2".to_string(),
            username: "alice".to_string(),
            last_used: Utc::now(),
        });
        repo.save(RememberMeToken {
            series: "s3".to_string(),
            value: "v3".to_string(),
            username: "bob".to_string(),
            last_used: Utc::now(),
        });
        repo.remove_user_tokens("alice");
        assert!(repo.find_by_series("s1").is_none());
        assert!(repo.find_by_series("s2").is_none());
        assert!(repo.find_by_series("s3").is_some());
    }

    #[test]
    fn test_login_auto_logout_cycle() {
        let services = RememberMeServices::new(RememberMeConfig::default());

        // Login
        let token = services.login_success("alice");
        let cookie = token.to_cookie_value();

        // Auto-login succeeds
        let username = services.auto_login(&cookie).unwrap();
        assert_eq!(username, "alice");

        // After auto-login, token was rotated; old cookie no longer works
        assert!(services.auto_login(&cookie).is_none());
    }

    #[test]
    fn test_token_theft_detection() {
        let services = RememberMeServices::new(RememberMeConfig::default());
        let token = services.login_success("alice");

        // Simulate auto-login (rotates value)
        let cookie1 = token.to_cookie_value();
        let _ = services.auto_login(&cookie1);

        // Get the new token value after rotation
        let new_token = services.repository.find_by_series(&token.series).unwrap();
        let cookie2 = new_token.to_cookie_value();

        // Old cookie value (pre-rotation) should fail and trigger theft detection
        let result = services.auto_login(&cookie1);
        assert!(result.is_none());

        // Even the valid new cookie should fail now — all user tokens removed
        let result2 = services.auto_login(&cookie2);
        assert!(result2.is_none());
    }

    #[test]
    fn test_logout() {
        let services = RememberMeServices::new(RememberMeConfig::default());
        let token = services.login_success("alice");
        let cookie = token.to_cookie_value();

        services.logout(&cookie);
        assert!(services.auto_login(&cookie).is_none());
    }

    #[test]
    fn test_hash_token() {
        let config = RememberMeConfig::default().with_key("test-key");
        let services = RememberMeServices::new(config);
        let token = RememberMeToken {
            series: "s".to_string(),
            value: "v".to_string(),
            username: "u".to_string(),
            last_used: Utc::now(),
        };
        let hash1 = services.hash_token(&token);
        let hash2 = services.hash_token(&token);
        assert_eq!(hash1, hash2);
        assert!(!hash1.is_empty());
    }
}