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 {
85 if ctx.token.is_empty() {
87 return SessionVerdict::single(SessionThreat::TokenUnknown);
88 }
89
90 let record = match self.store.get(ctx.token) {
91 Ok(Some(r)) => r,
92 Ok(None) => return SessionVerdict::single(SessionThreat::TokenUnknown),
93 Err(_) => return SessionVerdict::single(SessionThreat::StoreUnavailable),
95 };
96
97 if record.revoked {
98 return SessionVerdict::single(SessionThreat::TokenRevoked);
99 }
100 if record.expires_at <= now {
101 return SessionVerdict::single(SessionThreat::TokenExpired);
102 }
103
104 self.verify_binding(ctx, &record, now)
105 }
106
107 fn verify_binding(
109 &self,
110 ctx: &RequestContext,
111 record: &SessionRecord,
112 now: u64,
113 ) -> SessionVerdict {
114 let mut threats = Vec::new();
115
116 if !ct_eq(record.fingerprint.as_bytes(), ctx.fingerprint.as_bytes()) {
118 threats.push(SessionThreat::FingerprintMismatch);
119 }
120
121 match (record.signature.as_deref(), ctx.signature) {
123 (Some(stored), Some(current)) if !ct_eq(stored.as_bytes(), current.as_bytes()) => {
124 threats.push(SessionThreat::SignatureInvalid);
125 }
126 (Some(_), None) => threats.push(SessionThreat::SignatureMissing),
127 (None, Some(_)) => threats.push(SessionThreat::SignatureUnexpected),
129 _ => {}
130 }
131
132 if ctx
134 .at
135 .is_some_and(|at| now.abs_diff(at) > self.config.timestamp_skew_secs)
136 {
137 threats.push(SessionThreat::TimestampSkew);
138 }
139
140 if geo::location_changed(record.location.as_deref(), ctx.location) {
142 threats.push(SessionThreat::LocationChanged);
143 }
144
145 match self.store.recent_logins(&record.subject) {
147 Ok(history) => {
148 let current = LoginPoint {
149 location: ctx.location.map(str::to_string),
150 coords: geo::sanitize_coords(ctx.coords),
151 at: now,
152 };
153 if let Some(kmh) = history.last().and_then(|prev| {
154 geo::impossible_travel(prev, ¤t, self.config.impossible_travel_kmh)
155 }) {
156 threats.push(SessionThreat::ImpossibleTravel { kmh });
157 }
158 }
159 Err(_) => threats.push(SessionThreat::StoreUnavailable),
164 }
165
166 if threats.is_empty() {
167 let _ = self.store.touch(ctx.token, now);
171 return SessionVerdict::allow();
172 }
173
174 SessionVerdict::from_threats(threats)
175 }
176
177 pub fn revoke(&self, token: &str) -> Result<(), StoreError> {
179 self.store.revoke(token)
180 }
181
182 pub fn revoke_all(&self, subject: &str) -> Result<usize, StoreError> {
184 self.store.revoke_subject(subject)
185 }
186
187 pub fn rotate(
195 &self,
196 old: &str,
197 new: &str,
198 ctx: &RequestContext,
199 now: u64,
200 ) -> Result<(), SessionError> {
201 if old.is_empty() || new.is_empty() {
202 return Err(SessionError::EmptyToken);
203 }
204
205 let record = match self.store.get(old)? {
206 Some(r) if !r.revoked && r.expires_at > now => r,
207 _ => return Err(SessionError::UnknownSession),
208 };
209
210 if !ct_eq(record.fingerprint.as_bytes(), ctx.fingerprint.as_bytes()) {
211 return Err(SessionError::UnknownSession);
212 }
213
214 self.store.put(SessionRecord {
215 token: new.to_string(),
216 issued_at: now,
217 last_seen: now,
218 expires_at: now.saturating_add(self.config.ttl_secs),
219 revoked: false,
220 ..record
221 })?;
222 self.store.revoke(old)?;
224
225 Ok(())
226 }
227
228 pub fn purge_expired(&self, now: u64) -> Result<usize, StoreError> {
234 self.store.purge_expired(now)
235 }
236}
237
238pub(crate) fn ct_eq(a: &[u8], b: &[u8]) -> bool {
246 if a.len() != b.len() {
247 return false;
248 }
249 let mut diff = 0u8;
250 for (x, y) in a.iter().zip(b.iter()) {
251 diff |= x ^ y;
252 }
253 std::hint::black_box(diff) == 0
254}
255
256#[cfg(test)]
257mod tests {
258 use super::super::Decision;
259 use super::super::store::{LOGIN_HISTORY_KEEP_SECS, MemoryStore};
260 use super::*;
261
262 const NOW: u64 = 1_000_000;
263 const FP: &str = "ip=1.2.3.4|ua=curl";
264
265 fn ctx<'a>(token: &'a str, subject: &'a str, fp: &'a str) -> RequestContext<'a> {
266 RequestContext {
267 token,
268 subject,
269 fingerprint: fp,
270 location: Some("CN-BJ"),
271 coords: Some((39.9042, 116.4074)),
272 signature: None,
273 at: None,
274 }
275 }
276
277 fn guard() -> SessionGuard<MemoryStore> {
278 SessionGuard::new(MemoryStore::new(), SessionConfig::default())
279 }
280
281 #[test]
285 fn ct_eq_equal() {
286 assert!(ct_eq(b"abc123", b"abc123"));
287 }
288
289 #[test]
290 fn ct_eq_empty_is_equal() {
291 assert!(ct_eq(b"", b""));
292 }
293
294 #[test]
295 fn ct_eq_single_byte_difference() {
296 assert!(!ct_eq(b"abc123", b"abc124"));
297 assert!(!ct_eq(b"abc123", b"zbc123"));
299 }
300
301 #[test]
302 fn ct_eq_long_common_prefix_still_differs() {
303 let a = vec![7u8; 4096];
304 let mut b = a.clone();
305 b[4095] = 8;
306 assert!(!ct_eq(&a, &b));
307 }
308
309 #[test]
310 fn ct_eq_length_mismatch() {
311 assert!(!ct_eq(b"abc", b"abcd"));
312 assert!(!ct_eq(b"", b"a"));
313 }
314
315 #[test]
316 fn ct_eq_non_ascii_bytes() {
317 assert!(ct_eq("签名".as_bytes(), "签名".as_bytes()));
318 assert!(!ct_eq("签名".as_bytes(), "签名!".as_bytes()));
319 }
320
321 #[test]
322 fn ct_eq_differs_only_in_last_byte_of_each_length() {
323 for len in [1usize, 2, 3, 16, 32, 256] {
325 let a = vec![0u8; len];
326 let mut b = a.clone();
327 b[len - 1] = 1;
328 assert!(!ct_eq(&a, &b), "len {len}");
329 }
330 }
331
332 #[test]
333 fn guard_is_generic_over_store() {
334 let g = SessionGuard::new(MemoryStore::new(), SessionConfig::default());
336 assert_eq!(g.config().ttl_secs, 3600);
337 assert_eq!(g.config().impossible_travel_kmh, 900.0);
338 assert_eq!(g.config().timestamp_skew_secs, 300);
339 }
340
341 #[test]
342 fn bind_creates_session_record() {
343 let g = guard();
344 let v = g.bind(&ctx("t1", "u1", FP), NOW).unwrap();
345 assert!(v.is_allowed(), "首登无历史,不该报异地: {:?}", v.threats);
346 let r = g.store.get("t1").unwrap().expect("record created");
347 assert_eq!(r.subject, "u1");
348 assert_eq!(r.fingerprint, FP);
349 assert_eq!(r.issued_at, NOW);
350 assert_eq!(r.last_seen, NOW);
351 assert_eq!(r.expires_at, NOW + 3600);
352 assert!(!r.revoked);
353 }
354
355 #[test]
356 fn bind_records_login_point() {
357 let g = guard();
358 g.bind(&ctx("t1", "u1", FP), NOW).unwrap();
359 let h = g.store.recent_logins("u1").unwrap();
360 assert_eq!(h.len(), 1);
361 assert_eq!(h[0].location.as_deref(), Some("CN-BJ"));
362 assert_eq!(h[0].at, NOW);
363 }
364
365 #[test]
366 fn bind_drops_non_finite_coords_at_trust_boundary() {
367 let g = guard();
369 let mut bad = ctx("t1", "u1", FP);
370 bad.coords = Some((f64::NAN, 116.4074));
371 g.bind(&bad, NOW).unwrap();
372 assert_eq!(g.store.get("t1").unwrap().unwrap().coords, None);
373 assert_eq!(g.store.recent_logins("u1").unwrap()[0].coords, None);
374
375 let mut good = ctx("t2", "u2", FP);
377 good.coords = Some((31.2304, 121.4737));
378 g.bind(&good, NOW).unwrap();
379 assert_eq!(
380 g.store.get("t2").unwrap().unwrap().coords,
381 Some((31.2304, 121.4737))
382 );
383 }
384
385 #[test]
386 fn bind_stores_signature_baseline() {
387 let g = guard();
388 let mut c = ctx("t1", "u1", FP);
389 c.signature = Some("mac-abc");
390 g.bind(&c, NOW).unwrap();
391 assert_eq!(
392 g.store.get("t1").unwrap().unwrap().signature.as_deref(),
393 Some("mac-abc")
394 );
395 }
396
397 #[test]
398 fn bind_honours_custom_ttl() {
399 let g = SessionGuard::new(
400 MemoryStore::new(),
401 SessionConfig {
402 ttl_secs: 60,
403 ..Default::default()
404 },
405 );
406 g.bind(&ctx("t1", "u1", FP), NOW).unwrap();
407 assert_eq!(g.store.get("t1").unwrap().unwrap().expires_at, NOW + 60);
408 }
409
410 #[test]
411 fn verify_refreshes_last_seen_only_when_allowed() {
412 let g = guard();
413 g.bind(&ctx("t1", "u1", FP), NOW).unwrap();
414 assert!(g.verify(&ctx("t1", "u1", FP), NOW + 10).is_allowed());
415 assert_eq!(g.store.get("t1").unwrap().unwrap().last_seen, NOW + 10);
416 }
417
418 #[test]
419 fn verify_does_not_refresh_last_seen_when_blocked() {
420 let g = guard();
421 g.bind(&ctx("t1", "u1", FP), NOW).unwrap();
422 let v = g.verify(&ctx("t1", "u1", "ATTACKER-FP"), NOW + 10);
423 assert_eq!(v.decision, Decision::Block);
424 assert_eq!(
425 g.store.get("t1").unwrap().unwrap().last_seen,
426 NOW,
427 "被拦的请求不该刷新 last_seen 观测(TTL 绝对,本就不受影响)"
428 );
429 }
430
431 #[test]
432 fn purge_expired_reaches_the_store() {
433 let g = guard();
434 g.bind(&ctx("t1", "u1", FP), NOW).unwrap();
435
436 assert_eq!(g.purge_expired(NOW).unwrap(), 0);
438 assert!(g.store.get("t1").unwrap().is_some());
439
440 let later = NOW + 3_600 + LOGIN_HISTORY_KEEP_SECS + 1;
442 assert_eq!(g.purge_expired(later).unwrap(), 1);
443 assert!(g.store.get("t1").unwrap().is_none());
444 assert!(g.store.recent_logins("u1").unwrap().is_empty());
445 }
446
447 #[test]
448 fn rotate_issues_new_token_and_kills_old() {
449 let g = guard();
450 g.bind(&ctx("old", "u1", FP), NOW).unwrap();
451 g.rotate("old", "new", &ctx("old", "u1", FP), NOW + 10)
452 .unwrap();
453
454 assert_eq!(
455 g.verify(&ctx("old", "u1", FP), NOW + 20).threats,
456 vec![SessionThreat::TokenRevoked]
457 );
458 let v = g.verify(&ctx("new", "u1", FP), NOW + 20);
460 assert!(v.is_allowed(), "got {:?}", v.threats);
461 let r = g.store.get("new").unwrap().unwrap();
462 assert_eq!(r.subject, "u1");
463 assert_eq!(r.fingerprint, FP);
464 assert_eq!(r.location.as_deref(), Some("CN-BJ"));
465 }
466
467 #[test]
468 fn rotate_refreshes_ttl() {
469 let g = guard();
470 g.bind(&ctx("old", "u1", FP), NOW).unwrap();
471 g.rotate("old", "new", &ctx("old", "u1", FP), NOW + 1_000)
472 .unwrap();
473 assert_eq!(
474 g.store.get("new").unwrap().unwrap().expires_at,
475 NOW + 1_000 + 3_600
476 );
477 }
478
479 #[test]
480 fn rotate_ignores_ctx_subject_and_uses_record_subject() {
481 let g = guard();
483 g.bind(&ctx("old", "u1", FP), NOW).unwrap();
484 g.rotate("old", "new", &ctx("old", "VICTIM", FP), NOW + 10)
485 .unwrap();
486 assert_eq!(g.store.get("new").unwrap().unwrap().subject, "u1");
487 }
488}