1use super::geo;
4use super::store::{LoginPoint, SessionRecord, SessionStore};
5use super::{
6 RequestContext, SessionConfig, SessionError, SessionThreat, SessionVerdict, StoreError,
7};
8
9pub struct SessionGuard<S: SessionStore> {
10 store: S,
11 config: SessionConfig,
12}
13
14impl<S: SessionStore> SessionGuard<S> {
15 pub fn new(store: S, config: SessionConfig) -> Self {
16 Self { store, config }
17 }
18
19 pub fn config(&self) -> &SessionConfig {
20 &self.config
21 }
22
23 pub fn bind(&self, ctx: &RequestContext, now: u64) -> Result<SessionVerdict, SessionError> {
28 if ctx.token.is_empty() {
29 return Err(SessionError::EmptyToken);
30 }
31 if ctx.subject.is_empty() {
32 return Err(SessionError::EmptySubject);
33 }
34 if ctx.fingerprint.is_empty() {
35 return Err(SessionError::EmptyFingerprint);
36 }
37
38 let coords = geo::sanitize_coords(ctx.coords);
41
42 let point = LoginPoint {
43 location: ctx.location.map(str::to_string),
44 coords,
45 at: now,
46 };
47
48 let history = self.store.recent_logins(ctx.subject)?;
50
51 let mut threats = Vec::new();
52 if let Some(prev) = history.last() {
53 if geo::location_changed(prev.location.as_deref(), ctx.location) {
54 threats.push(SessionThreat::LocationChanged);
55 }
56 if let Some(kmh) =
57 geo::impossible_travel(prev, &point, self.config.impossible_travel_kmh)
58 {
59 threats.push(SessionThreat::ImpossibleTravel { kmh });
60 }
61 }
62
63 self.store.put(SessionRecord {
64 token: ctx.token.to_string(),
65 subject: ctx.subject.to_string(),
66 fingerprint: ctx.fingerprint.to_string(),
67 location: ctx.location.map(str::to_string),
68 coords,
69 signature: ctx.signature.map(str::to_string),
70 issued_at: now,
71 last_seen: now,
72 expires_at: now.saturating_add(self.config.ttl_secs),
73 revoked: false,
74 })?;
75 self.store.record_login(ctx.subject, point)?;
76
77 Ok(SessionVerdict::from_threats(threats))
78 }
79
80 pub fn verify(&self, ctx: &RequestContext, now: u64) -> SessionVerdict {
92 if ctx.token.is_empty() {
94 return SessionVerdict::single(SessionThreat::TokenUnknown);
95 }
96
97 let record = match self.store.get(ctx.token) {
98 Ok(Some(r)) => r,
99 Ok(None) => return SessionVerdict::single(SessionThreat::TokenUnknown),
100 Err(_) => return SessionVerdict::single(SessionThreat::StoreUnavailable),
102 };
103
104 if record.revoked {
105 return SessionVerdict::single(SessionThreat::TokenRevoked);
106 }
107 if record.expires_at <= now {
108 return SessionVerdict::single(SessionThreat::TokenExpired);
109 }
110
111 self.verify_binding(ctx, &record, now)
112 }
113
114 fn verify_binding(
116 &self,
117 ctx: &RequestContext,
118 record: &SessionRecord,
119 now: u64,
120 ) -> SessionVerdict {
121 let mut threats = Vec::new();
122
123 if !ct_eq(record.fingerprint.as_bytes(), ctx.fingerprint.as_bytes()) {
125 threats.push(SessionThreat::FingerprintMismatch);
126 }
127
128 match (record.signature.as_deref(), ctx.signature) {
130 (Some(stored), Some(current)) if !ct_eq(stored.as_bytes(), current.as_bytes()) => {
131 threats.push(SessionThreat::SignatureInvalid);
132 }
133 (Some(_), None) => threats.push(SessionThreat::SignatureMissing),
134 (None, Some(_)) => threats.push(SessionThreat::SignatureUnexpected),
136 _ => {}
137 }
138
139 if ctx
141 .at
142 .is_some_and(|at| now.abs_diff(at) > self.config.timestamp_skew_secs)
143 {
144 threats.push(SessionThreat::TimestampSkew);
145 }
146
147 if geo::location_changed(record.location.as_deref(), ctx.location) {
149 threats.push(SessionThreat::LocationChanged);
150 }
151
152 match self.store.recent_logins(&record.subject) {
154 Ok(history) => {
155 let current = LoginPoint {
156 location: ctx.location.map(str::to_string),
157 coords: geo::sanitize_coords(ctx.coords),
158 at: now,
159 };
160 if let Some(kmh) = history.last().and_then(|prev| {
161 geo::impossible_travel(prev, ¤t, self.config.impossible_travel_kmh)
162 }) {
163 threats.push(SessionThreat::ImpossibleTravel { kmh });
164 }
165 }
166 Err(_) => threats.push(SessionThreat::StoreUnavailable),
171 }
172
173 if threats.is_empty() {
174 let _ = self.store.touch(ctx.token, now);
178 return SessionVerdict::allow();
179 }
180
181 SessionVerdict::from_threats(threats)
182 }
183
184 pub fn revoke(&self, token: &str) -> Result<(), StoreError> {
186 self.store.revoke(token)
187 }
188
189 pub fn revoke_all(&self, subject: &str) -> Result<usize, StoreError> {
191 self.store.revoke_subject(subject)
192 }
193
194 pub fn rotate(
216 &self,
217 old: &str,
218 new: &str,
219 ctx: &RequestContext,
220 now: u64,
221 ) -> Result<(), SessionError> {
222 if old.is_empty() || new.is_empty() {
223 return Err(SessionError::EmptyToken);
224 }
225
226 let record = match self.store.get(old)? {
227 Some(r) if !r.revoked && r.expires_at > now => r,
228 _ => return Err(SessionError::UnknownSession),
229 };
230
231 if !ct_eq(record.fingerprint.as_bytes(), ctx.fingerprint.as_bytes()) {
232 return Err(SessionError::UnknownSession);
233 }
234
235 if let Some(target) = self.store.get(new)?
239 && !target.revoked
240 && target.expires_at > now
241 {
242 return Err(SessionError::TokenInUse);
243 }
244
245 self.store.put(SessionRecord {
246 token: new.to_string(),
247 issued_at: now,
248 last_seen: now,
249 expires_at: now.saturating_add(self.config.ttl_secs),
250 revoked: false,
251 ..record
252 })?;
253 self.store.revoke(old)?;
255
256 Ok(())
257 }
258
259 pub fn purge_expired(&self, now: u64) -> Result<usize, StoreError> {
265 self.store.purge_expired(now)
266 }
267}
268
269pub(crate) fn ct_eq(a: &[u8], b: &[u8]) -> bool {
277 if a.len() != b.len() {
278 return false;
279 }
280 let mut diff = 0u8;
281 for (x, y) in a.iter().zip(b.iter()) {
282 diff |= x ^ y;
283 }
284 std::hint::black_box(diff) == 0
285}
286
287#[cfg(test)]
288mod tests {
289 use super::super::Decision;
290 use super::super::store::{LOGIN_HISTORY_KEEP_SECS, MemoryStore};
291 use super::*;
292
293 const NOW: u64 = 1_000_000;
294 const FP: &str = "ip=1.2.3.4|ua=curl";
295
296 fn ctx<'a>(token: &'a str, subject: &'a str, fp: &'a str) -> RequestContext<'a> {
297 RequestContext {
298 token,
299 subject,
300 fingerprint: fp,
301 location: Some("CN-BJ"),
302 coords: Some((39.9042, 116.4074)),
303 signature: None,
304 at: None,
305 }
306 }
307
308 fn guard() -> SessionGuard<MemoryStore> {
309 SessionGuard::new(MemoryStore::new(), SessionConfig::default())
310 }
311
312 #[test]
316 fn ct_eq_equal() {
317 assert!(ct_eq(b"abc123", b"abc123"));
318 }
319
320 #[test]
321 fn ct_eq_empty_is_equal() {
322 assert!(ct_eq(b"", b""));
323 }
324
325 #[test]
326 fn ct_eq_single_byte_difference() {
327 assert!(!ct_eq(b"abc123", b"abc124"));
328 assert!(!ct_eq(b"abc123", b"zbc123"));
330 }
331
332 #[test]
333 fn ct_eq_long_common_prefix_still_differs() {
334 let a = vec![7u8; 4096];
335 let mut b = a.clone();
336 b[4095] = 8;
337 assert!(!ct_eq(&a, &b));
338 }
339
340 #[test]
341 fn ct_eq_length_mismatch() {
342 assert!(!ct_eq(b"abc", b"abcd"));
343 assert!(!ct_eq(b"", b"a"));
344 }
345
346 #[test]
347 fn ct_eq_non_ascii_bytes() {
348 assert!(ct_eq("签名".as_bytes(), "签名".as_bytes()));
349 assert!(!ct_eq("签名".as_bytes(), "签名!".as_bytes()));
350 }
351
352 #[test]
353 fn ct_eq_differs_only_in_last_byte_of_each_length() {
354 for len in [1usize, 2, 3, 16, 32, 256] {
356 let a = vec![0u8; len];
357 let mut b = a.clone();
358 b[len - 1] = 1;
359 assert!(!ct_eq(&a, &b), "len {len}");
360 }
361 }
362
363 #[test]
364 fn guard_is_generic_over_store() {
365 let g = SessionGuard::new(MemoryStore::new(), SessionConfig::default());
367 assert_eq!(g.config().ttl_secs, 3600);
368 assert_eq!(g.config().impossible_travel_kmh, 900.0);
369 assert_eq!(g.config().timestamp_skew_secs, 300);
370 }
371
372 #[test]
373 fn bind_creates_session_record() {
374 let g = guard();
375 let v = g.bind(&ctx("t1", "u1", FP), NOW).unwrap();
376 assert!(v.is_allowed(), "首登无历史,不该报异地: {:?}", v.threats);
377 let r = g.store.get("t1").unwrap().expect("record created");
378 assert_eq!(r.subject, "u1");
379 assert_eq!(r.fingerprint, FP);
380 assert_eq!(r.issued_at, NOW);
381 assert_eq!(r.last_seen, NOW);
382 assert_eq!(r.expires_at, NOW + 3600);
383 assert!(!r.revoked);
384 }
385
386 #[test]
387 fn bind_records_login_point() {
388 let g = guard();
389 g.bind(&ctx("t1", "u1", FP), NOW).unwrap();
390 let h = g.store.recent_logins("u1").unwrap();
391 assert_eq!(h.len(), 1);
392 assert_eq!(h[0].location.as_deref(), Some("CN-BJ"));
393 assert_eq!(h[0].at, NOW);
394 }
395
396 #[test]
397 fn bind_drops_non_finite_coords_at_trust_boundary() {
398 let g = guard();
400 let mut bad = ctx("t1", "u1", FP);
401 bad.coords = Some((f64::NAN, 116.4074));
402 g.bind(&bad, NOW).unwrap();
403 assert_eq!(g.store.get("t1").unwrap().unwrap().coords, None);
404 assert_eq!(g.store.recent_logins("u1").unwrap()[0].coords, None);
405
406 let mut good = ctx("t2", "u2", FP);
408 good.coords = Some((31.2304, 121.4737));
409 g.bind(&good, NOW).unwrap();
410 assert_eq!(
411 g.store.get("t2").unwrap().unwrap().coords,
412 Some((31.2304, 121.4737))
413 );
414 }
415
416 #[test]
417 fn bind_stores_signature_baseline() {
418 let g = guard();
419 let mut c = ctx("t1", "u1", FP);
420 c.signature = Some("mac-abc");
421 g.bind(&c, NOW).unwrap();
422 assert_eq!(
423 g.store.get("t1").unwrap().unwrap().signature.as_deref(),
424 Some("mac-abc")
425 );
426 }
427
428 #[test]
429 fn bind_honours_custom_ttl() {
430 let g = SessionGuard::new(
431 MemoryStore::new(),
432 SessionConfig {
433 ttl_secs: 60,
434 ..Default::default()
435 },
436 );
437 g.bind(&ctx("t1", "u1", FP), NOW).unwrap();
438 assert_eq!(g.store.get("t1").unwrap().unwrap().expires_at, NOW + 60);
439 }
440
441 #[test]
442 fn verify_refreshes_last_seen_only_when_allowed() {
443 let g = guard();
444 g.bind(&ctx("t1", "u1", FP), NOW).unwrap();
445 assert!(g.verify(&ctx("t1", "u1", FP), NOW + 10).is_allowed());
446 assert_eq!(g.store.get("t1").unwrap().unwrap().last_seen, NOW + 10);
447 }
448
449 #[test]
450 fn verify_does_not_refresh_last_seen_when_blocked() {
451 let g = guard();
452 g.bind(&ctx("t1", "u1", FP), NOW).unwrap();
453 let v = g.verify(&ctx("t1", "u1", "ATTACKER-FP"), NOW + 10);
454 assert_eq!(v.decision, Decision::Block);
455 assert_eq!(
456 g.store.get("t1").unwrap().unwrap().last_seen,
457 NOW,
458 "被拦的请求不该刷新 last_seen 观测(TTL 绝对,本就不受影响)"
459 );
460 }
461
462 #[test]
463 fn purge_expired_reaches_the_store() {
464 let g = guard();
465 g.bind(&ctx("t1", "u1", FP), NOW).unwrap();
466
467 assert_eq!(g.purge_expired(NOW).unwrap(), 0);
469 assert!(g.store.get("t1").unwrap().is_some());
470
471 let later = NOW + 3_600 + LOGIN_HISTORY_KEEP_SECS + 1;
473 assert_eq!(g.purge_expired(later).unwrap(), 1);
474 assert!(g.store.get("t1").unwrap().is_none());
475 assert!(g.store.recent_logins("u1").unwrap().is_empty());
476 }
477
478 #[test]
479 fn rotate_issues_new_token_and_kills_old() {
480 let g = guard();
481 g.bind(&ctx("old", "u1", FP), NOW).unwrap();
482 g.rotate("old", "new", &ctx("old", "u1", FP), NOW + 10)
483 .unwrap();
484
485 assert_eq!(
486 g.verify(&ctx("old", "u1", FP), NOW + 20).threats,
487 vec![SessionThreat::TokenRevoked]
488 );
489 let v = g.verify(&ctx("new", "u1", FP), NOW + 20);
491 assert!(v.is_allowed(), "got {:?}", v.threats);
492 let r = g.store.get("new").unwrap().unwrap();
493 assert_eq!(r.subject, "u1");
494 assert_eq!(r.fingerprint, FP);
495 assert_eq!(r.location.as_deref(), Some("CN-BJ"));
496 }
497
498 #[test]
499 fn rotate_refreshes_ttl() {
500 let g = guard();
501 g.bind(&ctx("old", "u1", FP), NOW).unwrap();
502 g.rotate("old", "new", &ctx("old", "u1", FP), NOW + 1_000)
503 .unwrap();
504 assert_eq!(
505 g.store.get("new").unwrap().unwrap().expires_at,
506 NOW + 1_000 + 3_600
507 );
508 }
509
510 #[test]
511 fn rotate_ignores_ctx_subject_and_uses_record_subject() {
512 let g = guard();
514 g.bind(&ctx("old", "u1", FP), NOW).unwrap();
515 g.rotate("old", "new", &ctx("old", "VICTIM", FP), NOW + 10)
516 .unwrap();
517 assert_eq!(g.store.get("new").unwrap().unwrap().subject, "u1");
518 }
519
520 #[test]
521 fn rotate_rejects_collision_with_live_token() {
522 const VFP: &str = "ip=5.6.7.8|ua=safari";
526 let g = guard();
527 g.bind(&ctx("victim-token", "victim", VFP), NOW).unwrap();
528 g.bind(&ctx("attacker-old", "attacker", FP), NOW).unwrap();
529
530 assert_eq!(
531 g.rotate(
532 "attacker-old",
533 "victim-token",
534 &ctx("attacker-old", "attacker", FP),
535 NOW + 10
536 )
537 .unwrap_err(),
538 SessionError::TokenInUse
539 );
540
541 let r = g.store.get("victim-token").unwrap().unwrap();
543 assert_eq!(r.subject, "victim");
544 assert_eq!(r.fingerprint, VFP);
545 assert!(!r.revoked);
546 assert!(
547 g.verify(&ctx("victim-token", "victim", VFP), NOW + 20)
548 .is_allowed()
549 );
550 assert!(
552 g.verify(&ctx("attacker-old", "attacker", FP), NOW + 20)
553 .is_allowed()
554 );
555 }
556
557 #[test]
558 fn rotate_may_reuse_a_revoked_token() {
559 let g = guard();
561 g.bind(&ctx("dead", "u1", FP), NOW).unwrap();
562 g.revoke("dead").unwrap();
563 g.bind(&ctx("old", "u2", FP), NOW).unwrap();
564
565 g.rotate("old", "dead", &ctx("old", "u2", FP), NOW + 10)
566 .unwrap();
567 let r = g.store.get("dead").unwrap().unwrap();
568 assert_eq!(r.subject, "u2");
569 assert!(!r.revoked, "覆盖后是全新记录,revoked 不继承");
570 assert_eq!(r.expires_at, NOW + 10 + 3_600);
571 }
572
573 #[test]
574 fn rotate_may_reuse_an_expired_token() {
575 let g = guard();
578 g.bind(&ctx("stale", "u1", FP), NOW).unwrap();
579 g.bind(&ctx("old", "u2", FP), NOW + 1).unwrap();
580
581 assert_eq!(
583 g.rotate("old", "stale", &ctx("old", "u2", FP), NOW + 3_599)
584 .unwrap_err(),
585 SessionError::TokenInUse
586 );
587
588 g.rotate("old", "stale", &ctx("old", "u2", FP), NOW + 3_600)
590 .unwrap();
591 let r = g.store.get("stale").unwrap().unwrap();
592 assert_eq!(r.subject, "u2");
593 assert_eq!(r.expires_at, NOW + 3_600 + 3_600);
594 }
595
596 #[test]
597 fn rotate_to_the_same_token_is_rejected_and_session_survives() {
598 let g = guard();
602 g.bind(&ctx("t", "u1", FP), NOW).unwrap();
603
604 assert_eq!(
605 g.rotate("t", "t", &ctx("t", "u1", FP), NOW + 10)
606 .unwrap_err(),
607 SessionError::TokenInUse
608 );
609
610 let r = g.store.get("t").unwrap().unwrap();
611 assert!(!r.revoked, "失败的 rotate 不能有副作用");
612 assert_eq!(r.expires_at, NOW + 3_600, "TTL 也不该被续期");
613 assert_eq!(r.last_seen, NOW);
614 assert!(g.verify(&ctx("t", "u1", FP), NOW + 20).is_allowed());
615 }
616}