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 let Some(at) = ctx.at {
134 if now.abs_diff(at) > self.config.timestamp_skew_secs {
135 threats.push(SessionThreat::TimestampSkew);
136 }
137 }
138
139 if geo::location_changed(record.location.as_deref(), ctx.location) {
141 threats.push(SessionThreat::LocationChanged);
142 }
143
144 match self.store.recent_logins(&record.subject) {
146 Ok(history) => {
147 let current = LoginPoint {
148 location: ctx.location.map(str::to_string),
149 coords: geo::sanitize_coords(ctx.coords),
150 at: now,
151 };
152 if let Some(prev) = history.last() {
153 if let Some(kmh) =
154 geo::impossible_travel(prev, ¤t, self.config.impossible_travel_kmh)
155 {
156 threats.push(SessionThreat::ImpossibleTravel { kmh });
157 }
158 }
159 }
160 Err(_) => threats.push(SessionThreat::StoreUnavailable),
165 }
166
167 if threats.is_empty() {
168 let _ = self.store.touch(ctx.token, now);
170 return SessionVerdict::allow();
171 }
172
173 SessionVerdict::from_threats(threats)
174 }
175
176 pub fn revoke(&self, token: &str) -> Result<(), StoreError> {
178 self.store.revoke(token)
179 }
180
181 pub fn revoke_all(&self, subject: &str) -> Result<usize, StoreError> {
183 self.store.revoke_subject(subject)
184 }
185
186 pub fn rotate(
194 &self,
195 old: &str,
196 new: &str,
197 ctx: &RequestContext,
198 now: u64,
199 ) -> Result<(), SessionError> {
200 if old.is_empty() || new.is_empty() {
201 return Err(SessionError::EmptyToken);
202 }
203
204 let record = match self.store.get(old)? {
205 Some(r) if !r.revoked && r.expires_at > now => r,
206 _ => return Err(SessionError::UnknownSession),
207 };
208
209 if !ct_eq(record.fingerprint.as_bytes(), ctx.fingerprint.as_bytes()) {
210 return Err(SessionError::UnknownSession);
211 }
212
213 self.store.put(SessionRecord {
214 token: new.to_string(),
215 issued_at: now,
216 last_seen: now,
217 expires_at: now.saturating_add(self.config.ttl_secs),
218 revoked: false,
219 ..record
220 })?;
221 self.store.revoke(old)?;
223
224 Ok(())
225 }
226}
227
228pub(crate) fn ct_eq(a: &[u8], b: &[u8]) -> bool {
236 if a.len() != b.len() {
237 return false;
238 }
239 let mut diff = 0u8;
240 for (x, y) in a.iter().zip(b.iter()) {
241 diff |= x ^ y;
242 }
243 std::hint::black_box(diff) == 0
244}
245
246#[cfg(test)]
247mod tests {
248 use super::super::Decision;
249 use super::super::store::MemoryStore;
250 use super::*;
251
252 const NOW: u64 = 1_000_000;
253 const FP: &str = "ip=1.2.3.4|ua=curl";
254
255 fn ctx<'a>(token: &'a str, subject: &'a str, fp: &'a str) -> RequestContext<'a> {
256 RequestContext {
257 token,
258 subject,
259 fingerprint: fp,
260 location: Some("CN-BJ"),
261 coords: Some((39.9042, 116.4074)),
262 signature: None,
263 at: None,
264 }
265 }
266
267 fn guard() -> SessionGuard<MemoryStore> {
268 SessionGuard::new(MemoryStore::new(), SessionConfig::default())
269 }
270
271 #[test]
275 fn ct_eq_equal() {
276 assert!(ct_eq(b"abc123", b"abc123"));
277 }
278
279 #[test]
280 fn ct_eq_empty_is_equal() {
281 assert!(ct_eq(b"", b""));
282 }
283
284 #[test]
285 fn ct_eq_single_byte_difference() {
286 assert!(!ct_eq(b"abc123", b"abc124"));
287 assert!(!ct_eq(b"abc123", b"zbc123"));
289 }
290
291 #[test]
292 fn ct_eq_long_common_prefix_still_differs() {
293 let a = vec![7u8; 4096];
294 let mut b = a.clone();
295 b[4095] = 8;
296 assert!(!ct_eq(&a, &b));
297 }
298
299 #[test]
300 fn ct_eq_length_mismatch() {
301 assert!(!ct_eq(b"abc", b"abcd"));
302 assert!(!ct_eq(b"", b"a"));
303 }
304
305 #[test]
306 fn ct_eq_non_ascii_bytes() {
307 assert!(ct_eq("签名".as_bytes(), "签名".as_bytes()));
308 assert!(!ct_eq("签名".as_bytes(), "签名!".as_bytes()));
309 }
310
311 #[test]
312 fn ct_eq_differs_only_in_last_byte_of_each_length() {
313 for len in [1usize, 2, 3, 16, 32, 256] {
315 let a = vec![0u8; len];
316 let mut b = a.clone();
317 b[len - 1] = 1;
318 assert!(!ct_eq(&a, &b), "len {len}");
319 }
320 }
321
322 #[test]
323 fn guard_is_generic_over_store() {
324 let g = SessionGuard::new(MemoryStore::new(), SessionConfig::default());
326 assert_eq!(g.config().ttl_secs, 3600);
327 assert_eq!(g.config().impossible_travel_kmh, 900.0);
328 assert_eq!(g.config().timestamp_skew_secs, 300);
329 }
330
331 #[test]
332 fn bind_creates_session_record() {
333 let g = guard();
334 let v = g.bind(&ctx("t1", "u1", FP), NOW).unwrap();
335 assert!(v.is_allowed(), "首登无历史,不该报异地: {:?}", v.threats);
336 let r = g.store.get("t1").unwrap().expect("record created");
337 assert_eq!(r.subject, "u1");
338 assert_eq!(r.fingerprint, FP);
339 assert_eq!(r.issued_at, NOW);
340 assert_eq!(r.last_seen, NOW);
341 assert_eq!(r.expires_at, NOW + 3600);
342 assert!(!r.revoked);
343 }
344
345 #[test]
346 fn bind_records_login_point() {
347 let g = guard();
348 g.bind(&ctx("t1", "u1", FP), NOW).unwrap();
349 let h = g.store.recent_logins("u1").unwrap();
350 assert_eq!(h.len(), 1);
351 assert_eq!(h[0].location.as_deref(), Some("CN-BJ"));
352 assert_eq!(h[0].at, NOW);
353 }
354
355 #[test]
356 fn bind_drops_non_finite_coords_at_trust_boundary() {
357 let g = guard();
359 let mut bad = ctx("t1", "u1", FP);
360 bad.coords = Some((f64::NAN, 116.4074));
361 g.bind(&bad, NOW).unwrap();
362 assert_eq!(g.store.get("t1").unwrap().unwrap().coords, None);
363 assert_eq!(g.store.recent_logins("u1").unwrap()[0].coords, None);
364
365 let mut good = ctx("t2", "u2", FP);
367 good.coords = Some((31.2304, 121.4737));
368 g.bind(&good, NOW).unwrap();
369 assert_eq!(
370 g.store.get("t2").unwrap().unwrap().coords,
371 Some((31.2304, 121.4737))
372 );
373 }
374
375 #[test]
376 fn bind_stores_signature_baseline() {
377 let g = guard();
378 let mut c = ctx("t1", "u1", FP);
379 c.signature = Some("mac-abc");
380 g.bind(&c, NOW).unwrap();
381 assert_eq!(
382 g.store.get("t1").unwrap().unwrap().signature.as_deref(),
383 Some("mac-abc")
384 );
385 }
386
387 #[test]
388 fn bind_honours_custom_ttl() {
389 let g = SessionGuard::new(
390 MemoryStore::new(),
391 SessionConfig {
392 ttl_secs: 60,
393 ..Default::default()
394 },
395 );
396 g.bind(&ctx("t1", "u1", FP), NOW).unwrap();
397 assert_eq!(g.store.get("t1").unwrap().unwrap().expires_at, NOW + 60);
398 }
399
400 #[test]
401 fn verify_refreshes_last_seen_only_when_allowed() {
402 let g = guard();
403 g.bind(&ctx("t1", "u1", FP), NOW).unwrap();
404 assert!(g.verify(&ctx("t1", "u1", FP), NOW + 10).is_allowed());
405 assert_eq!(g.store.get("t1").unwrap().unwrap().last_seen, NOW + 10);
406 }
407
408 #[test]
409 fn verify_does_not_refresh_last_seen_when_blocked() {
410 let g = guard();
411 g.bind(&ctx("t1", "u1", FP), NOW).unwrap();
412 let v = g.verify(&ctx("t1", "u1", "ATTACKER-FP"), NOW + 10);
413 assert_eq!(v.decision, Decision::Block);
414 assert_eq!(
415 g.store.get("t1").unwrap().unwrap().last_seen,
416 NOW,
417 "被拦的请求不该延长会话寿命"
418 );
419 }
420
421 #[test]
422 fn rotate_issues_new_token_and_kills_old() {
423 let g = guard();
424 g.bind(&ctx("old", "u1", FP), NOW).unwrap();
425 g.rotate("old", "new", &ctx("old", "u1", FP), NOW + 10)
426 .unwrap();
427
428 assert_eq!(
429 g.verify(&ctx("old", "u1", FP), NOW + 20).threats,
430 vec![SessionThreat::TokenRevoked]
431 );
432 let v = g.verify(&ctx("new", "u1", FP), NOW + 20);
434 assert!(v.is_allowed(), "got {:?}", v.threats);
435 let r = g.store.get("new").unwrap().unwrap();
436 assert_eq!(r.subject, "u1");
437 assert_eq!(r.fingerprint, FP);
438 assert_eq!(r.location.as_deref(), Some("CN-BJ"));
439 }
440
441 #[test]
442 fn rotate_refreshes_ttl() {
443 let g = guard();
444 g.bind(&ctx("old", "u1", FP), NOW).unwrap();
445 g.rotate("old", "new", &ctx("old", "u1", FP), NOW + 1_000)
446 .unwrap();
447 assert_eq!(
448 g.store.get("new").unwrap().unwrap().expires_at,
449 NOW + 1_000 + 3_600
450 );
451 }
452
453 #[test]
454 fn rotate_ignores_ctx_subject_and_uses_record_subject() {
455 let g = guard();
457 g.bind(&ctx("old", "u1", FP), NOW).unwrap();
458 g.rotate("old", "new", &ctx("old", "VICTIM", FP), NOW + 10)
459 .unwrap();
460 assert_eq!(g.store.get("new").unwrap().unwrap().subject, "u1");
461 }
462}