Skip to main content

security_rust/session/
guard.rs

1// Copyright (c) 2026 erik <erik@erik.xyz> — https://erik.xyz
2
3use 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    /// 登录:建会话 + 绑指纹 + 记位置,并返回异地判定。
24    ///
25    /// 登录本身总是成功(除非调用方误用或后端故障)—— 异地只影响 verdict,
26    /// 由调用方决定是否走二次验证,不应阻断登录。
27    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        // 坐标在信任边界校验一次:NaN / 越界一律降级为「没有坐标」,
39        // 后面写入记录与登录历史的一律是这个清洗过的值
40        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        // 异地判定必须在写入本次登录点之前取历史,否则会拿自己跟自己比
49        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    /// 每请求校验。
81    ///
82    /// 返回 `SessionVerdict` 而非 `Result`:认证路径上「拒绝」是正常结果而非错误,
83    /// 强制调用方在类型层面处理每一种拒绝。
84    pub fn verify(&self, ctx: &RequestContext, now: u64) -> SessionVerdict {
85        // ── 门槛检查:记录不存在或不可读时,后续检查没有基线可比,必须提前退出 ──
86        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            // fail-closed:后端故障时放行所有请求是一个可被攻击者主动触发的绕过
94            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    /// 累积检查:需要有效基线,逐项收集而非提前退出,以便日志与取证完整。
108    fn verify_binding(
109        &self,
110        ctx: &RequestContext,
111        record: &SessionRecord,
112        now: u64,
113    ) -> SessionVerdict {
114        let mut threats = Vec::new();
115
116        // 劫持:指纹不符
117        if !ct_eq(record.fingerprint.as_bytes(), ctx.fingerprint.as_bytes()) {
118            threats.push(SessionThreat::FingerprintMismatch);
119        }
120
121        // 篡改:签名比对
122        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            // 登录时没设基线,本次却带了签名:请求方与会话建立方行为不一致
128            (None, Some(_)) => threats.push(SessionThreat::SignatureUnexpected),
129            _ => {}
130        }
131
132        // 重放:请求自称时间偏离窗口
133        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        // 异地:与会话记录的位置比对
141        if geo::location_changed(record.location.as_deref(), ctx.location) {
142            threats.push(SessionThreat::LocationChanged);
143        }
144
145        // 异地(铁证):与该身份的登录历史比对
146        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, &current, self.config.impossible_travel_kmh)
155                }) {
156                    threats.push(SessionThreat::ImpossibleTravel { kmh });
157                }
158            }
159            // fail-closed:历史读不到时静默跳过,等于「后端一坏,异地检测就关」,
160            // 攻击者可以用后端故障(或诱导故障)换掉一整类判定。上报为
161            // StoreUnavailable(⇒ Block),与 bind() 对同一调用用 `?`、
162            // verify() 把 get 的 Err 转 StoreUnavailable 的处置保持一致。
163            Err(_) => threats.push(SessionThreat::StoreUnavailable),
164        }
165
166        if threats.is_empty() {
167            // 只有放行时才刷新 last_seen:被拦的请求不该留下「刚刚还活跃」的观测。
168            // 注意这只影响 last_seen 这一个观测值 —— `expires_at` 只在 bind / rotate
169            // 写入、verify 从不延长,TTL 是绝对的,因此这里与「会话寿命」无关。
170            let _ = self.store.touch(ctx.token, now);
171            return SessionVerdict::allow();
172        }
173
174        SessionVerdict::from_threats(threats)
175    }
176
177    /// 吊销单个会话(登出)。
178    pub fn revoke(&self, token: &str) -> Result<(), StoreError> {
179        self.store.revoke(token)
180    }
181
182    /// 吊销某 subject 的全部会话(改密码 / 踢下线),返回受影响条数。
183    pub fn revoke_all(&self, subject: &str) -> Result<usize, StoreError> {
184        self.store.revoke_subject(subject)
185    }
186
187    /// 续期换 token:旧 token 吊销,新 token 由调用方提供。
188    ///
189    /// 旧会话必须存在、未吊销、未过期,**且指纹与本次 ctx 相符** ——
190    /// 否则等于允许攻击者拿别人的 token 换一个自己的新 token,是提权漏洞。
191    ///
192    /// 新记录的身份字段(subject / location / coords / signature)一律
193    /// 以服务端记录为准,不接受 `ctx` 覆盖。
194    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        // 旧 token 立即失效
223        self.store.revoke(old)?;
224
225        Ok(())
226    }
227
228    /// 清除已过期记录,返回清除条数。
229    ///
230    /// 内存后端无后台线程,map 条目只增不减;长期运行的进程需按 `ttl_secs`
231    /// 量级的间隔定时调用(同时回收休眠 subject 的登录历史,见
232    /// [`LOGIN_HISTORY_KEEP_SECS`](super::store::LOGIN_HISTORY_KEEP_SECS))。
233    pub fn purge_expired(&self, now: u64) -> Result<usize, StoreError> {
234        self.store.purge_expired(now)
235    }
236}
237
238/// 常数时间字节比较。
239///
240/// 用于比对 MAC / 指纹 —— 直接用 `==` 比较会在首个不同字节处提前返回,
241/// 泄露「前 N 个字节猜对了」的时序信息。
242///
243/// 长度不同立即返回 `false`:长度会泄露,但长度本身不敏感,这是通行做法。
244/// `black_box` 阻止优化器把累积循环改写成提前退出。
245pub(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    // 这些测试要读 `guard.store` 的私有字段,因此留在单测里;
282    // 纯公开 API 的行为测试(含 fail-closed、误报防护)在 tests/session*.rs。
283
284    #[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        // 首字节不同
298        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        // 累积或运算保证:即使差异出现在末尾也返回 false,不会被优化成提前退出
324        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        // SessionGuard 只绑定 SessionStore trait,便于多实例部署换后端
335        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        // NaN 一旦入库/入历史就会长期污染该 subject 的异地判定,必须在入口清洗
368        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        // 合法坐标照常保留
376        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        // 未过期:会话与登录历史都留着
437        assert_eq!(g.purge_expired(NOW).unwrap(), 0);
438        assert!(g.store.get("t1").unwrap().is_some());
439
440        // 过期后:委派真的落到了 store 上 —— 会话被清,休眠的登录历史也被回收
441        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        // 新 token 可用,且继承 subject / 指纹 / 位置基线
459        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        // subject 以服务端记录为准,不能被请求方覆盖
482        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}