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 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        // 异地:与会话记录的位置比对
140        if geo::location_changed(record.location.as_deref(), ctx.location) {
141            threats.push(SessionThreat::LocationChanged);
142        }
143
144        // 异地(铁证):与该身份的登录历史比对
145        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, &current, self.config.impossible_travel_kmh)
155                    {
156                        threats.push(SessionThreat::ImpossibleTravel { kmh });
157                    }
158                }
159            }
160            // fail-closed:历史读不到时静默跳过,等于「后端一坏,异地检测就关」,
161            // 攻击者可以用后端故障(或诱导故障)换掉一整类判定。上报为
162            // StoreUnavailable(⇒ Block),与 bind() 对同一调用用 `?`、
163            // verify() 把 get 的 Err 转 StoreUnavailable 的处置保持一致。
164            Err(_) => threats.push(SessionThreat::StoreUnavailable),
165        }
166
167        if threats.is_empty() {
168            // 只有放行时才刷新活跃度:被拦的请求不该延长会话寿命
169            let _ = self.store.touch(ctx.token, now);
170            return SessionVerdict::allow();
171        }
172
173        SessionVerdict::from_threats(threats)
174    }
175
176    /// 吊销单个会话(登出)。
177    pub fn revoke(&self, token: &str) -> Result<(), StoreError> {
178        self.store.revoke(token)
179    }
180
181    /// 吊销某 subject 的全部会话(改密码 / 踢下线),返回受影响条数。
182    pub fn revoke_all(&self, subject: &str) -> Result<usize, StoreError> {
183        self.store.revoke_subject(subject)
184    }
185
186    /// 续期换 token:旧 token 吊销,新 token 由调用方提供。
187    ///
188    /// 旧会话必须存在、未吊销、未过期,**且指纹与本次 ctx 相符** ——
189    /// 否则等于允许攻击者拿别人的 token 换一个自己的新 token,是提权漏洞。
190    ///
191    /// 新记录的身份字段(subject / location / coords / signature)一律
192    /// 以服务端记录为准,不接受 `ctx` 覆盖。
193    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        // 旧 token 立即失效
222        self.store.revoke(old)?;
223
224        Ok(())
225    }
226}
227
228/// 常数时间字节比较。
229///
230/// 用于比对 MAC / 指纹 —— 直接用 `==` 比较会在首个不同字节处提前返回,
231/// 泄露「前 N 个字节猜对了」的时序信息。
232///
233/// 长度不同立即返回 `false`:长度会泄露,但长度本身不敏感,这是通行做法。
234/// `black_box` 阻止优化器把累积循环改写成提前退出。
235pub(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    // 这些测试要读 `guard.store` 的私有字段,因此留在单测里;
272    // 纯公开 API 的行为测试(含 fail-closed、误报防护)在 tests/session*.rs。
273
274    #[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        // 首字节不同
288        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        // 累积或运算保证:即使差异出现在末尾也返回 false,不会被优化成提前退出
314        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        // SessionGuard 只绑定 SessionStore trait,便于多实例部署换后端
325        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        // NaN 一旦入库/入历史就会长期污染该 subject 的异地判定,必须在入口清洗
358        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        // 合法坐标照常保留
366        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        // 新 token 可用,且继承 subject / 指纹 / 位置基线
433        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        // subject 以服务端记录为准,不能被请求方覆盖
456        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}