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    ///
85    /// `get` → `recent_logins` → `touch` 是三次独立锁获取,**不原子**(与
86    /// `throttle::Throttle::record_failure` 的窗口说明同一取舍):并发下
87    /// `touch` 可能落在一条刚被 revoke / purge 掉的记录上(落空,无副作用),
88    /// 登录历史也可能读到略旧的一份。这不构成绕过 —— 各判定只吃自己那一份
89    /// 快照(`get` 返回的是克隆),没有跨字段的复合不变量会被撕开;
90    /// 要消除这个窗口得把「读记录 + 读历史 + 刷新 last_seen」并成一个 store 操作。
91    pub fn verify(&self, ctx: &RequestContext, now: u64) -> SessionVerdict {
92        // ── 门槛检查:记录不存在或不可读时,后续检查没有基线可比,必须提前退出 ──
93        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            // fail-closed:后端故障时放行所有请求是一个可被攻击者主动触发的绕过
101            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    /// 累积检查:需要有效基线,逐项收集而非提前退出,以便日志与取证完整。
115    fn verify_binding(
116        &self,
117        ctx: &RequestContext,
118        record: &SessionRecord,
119        now: u64,
120    ) -> SessionVerdict {
121        let mut threats = Vec::new();
122
123        // 劫持:指纹不符
124        if !ct_eq(record.fingerprint.as_bytes(), ctx.fingerprint.as_bytes()) {
125            threats.push(SessionThreat::FingerprintMismatch);
126        }
127
128        // 篡改:签名比对
129        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            // 登录时没设基线,本次却带了签名:请求方与会话建立方行为不一致
135            (None, Some(_)) => threats.push(SessionThreat::SignatureUnexpected),
136            _ => {}
137        }
138
139        // 重放:请求自称时间偏离窗口
140        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        // 异地:与会话记录的位置比对
148        if geo::location_changed(record.location.as_deref(), ctx.location) {
149            threats.push(SessionThreat::LocationChanged);
150        }
151
152        // 异地(铁证):与该身份的登录历史比对
153        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, &current, self.config.impossible_travel_kmh)
162                }) {
163                    threats.push(SessionThreat::ImpossibleTravel { kmh });
164                }
165            }
166            // fail-closed:历史读不到时静默跳过,等于「后端一坏,异地检测就关」,
167            // 攻击者可以用后端故障(或诱导故障)换掉一整类判定。上报为
168            // StoreUnavailable(⇒ Block),与 bind() 对同一调用用 `?`、
169            // verify() 把 get 的 Err 转 StoreUnavailable 的处置保持一致。
170            Err(_) => threats.push(SessionThreat::StoreUnavailable),
171        }
172
173        if threats.is_empty() {
174            // 只有放行时才刷新 last_seen:被拦的请求不该留下「刚刚还活跃」的观测。
175            // 注意这只影响 last_seen 这一个观测值 —— `expires_at` 只在 bind / rotate
176            // 写入、verify 从不延长,TTL 是绝对的,因此这里与「会话寿命」无关。
177            let _ = self.store.touch(ctx.token, now);
178            return SessionVerdict::allow();
179        }
180
181        SessionVerdict::from_threats(threats)
182    }
183
184    /// 吊销单个会话(登出)。
185    pub fn revoke(&self, token: &str) -> Result<(), StoreError> {
186        self.store.revoke(token)
187    }
188
189    /// 吊销某 subject 的全部会话(改密码 / 踢下线),返回受影响条数。
190    pub fn revoke_all(&self, subject: &str) -> Result<usize, StoreError> {
191        self.store.revoke_subject(subject)
192    }
193
194    /// 续期换 token:旧 token 吊销,新 token 由调用方提供。
195    ///
196    /// 旧会话必须存在、未吊销、未过期,**且指纹与本次 ctx 相符** ——
197    /// 否则等于允许攻击者拿别人的 token 换一个自己的新 token,是提权漏洞。
198    ///
199    /// **前置条件:`new` 必须是一个未在用的 token**(不存在,或已过期,或已吊销
200    /// —— 死记录可以覆盖)。指向一个在用会话返回 [`SessionError::TokenInUse`]
201    /// 且不改动任何记录:store 的 `put` 是无条件 upsert,不挡这一下就等于让
202    /// 攻击者用自己的会话把受害者的记录整个盖掉(受害者下次 verify 读到的是
203    /// 攻击者的指纹 ⇒ 被动踢下线)。`new == old` 同样按 `TokenInUse` 拒绝 ——
204    /// 它自相矛盾(既要吊销 old 又要 new 可用),静默接受只会得到一个
205    /// 「rotate 成功但会话已死」的假象。
206    ///
207    /// 新记录的身份字段(subject / location / coords / signature)一律
208    /// 以服务端记录为准,不接受 `ctx` 覆盖。
209    ///
210    /// **TOCTOU**:查 `new` 与随后的 `put` 是两次独立锁获取,**不原子** ——
211    /// 两个并发 rotate 指向同一个新 token 时仍可能互相覆盖(要攻击者猜到对方
212    /// 的新 token 并卡进这个窄窗)。本 trait 没有条件写 / 事务,这个检查把
213    /// 「必然覆盖」降级为「窄窗竞争」,仍然值得;要彻底关窗得加 `put_if_absent`
214    /// 一类的原子原语。
215    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        // 「在用」与上面判 old 口径一致:存在 && 未吊销 && 未过期。
236        // 放在指纹校验之后:先确认调用方有权动这个旧会话,再谈新 token 能不能用。
237        // 命中即返回,此时还没有任何写入 —— 失败的 rotate 不留副作用。
238        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        // 旧 token 立即失效
254        self.store.revoke(old)?;
255
256        Ok(())
257    }
258
259    /// 清除已过期记录,返回清除条数。
260    ///
261    /// 内存后端无后台线程,map 条目只增不减;长期运行的进程需按 `ttl_secs`
262    /// 量级的间隔定时调用(同时回收休眠 subject 的登录历史,见
263    /// [`LOGIN_HISTORY_KEEP_SECS`](super::store::LOGIN_HISTORY_KEEP_SECS))。
264    pub fn purge_expired(&self, now: u64) -> Result<usize, StoreError> {
265        self.store.purge_expired(now)
266    }
267}
268
269/// 常数时间字节比较。
270///
271/// 用于比对 MAC / 指纹 —— 直接用 `==` 比较会在首个不同字节处提前返回,
272/// 泄露「前 N 个字节猜对了」的时序信息。
273///
274/// 长度不同立即返回 `false`:长度会泄露,但长度本身不敏感,这是通行做法。
275/// `black_box` 阻止优化器把累积循环改写成提前退出。
276pub(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    // 这些测试要读 `guard.store` 的私有字段,因此留在单测里;
313    // 纯公开 API 的行为测试(含 fail-closed、误报防护)在 tests/session*.rs。
314
315    #[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        // 首字节不同
329        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        // 累积或运算保证:即使差异出现在末尾也返回 false,不会被优化成提前退出
355        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        // SessionGuard 只绑定 SessionStore trait,便于多实例部署换后端
366        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        // NaN 一旦入库/入历史就会长期污染该 subject 的异地判定,必须在入口清洗
399        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        // 合法坐标照常保留
407        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        // 未过期:会话与登录历史都留着
468        assert_eq!(g.purge_expired(NOW).unwrap(), 0);
469        assert!(g.store.get("t1").unwrap().is_some());
470
471        // 过期后:委派真的落到了 store 上 —— 会话被清,休眠的登录历史也被回收
472        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        // 新 token 可用,且继承 subject / 指纹 / 位置基线
490        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        // subject 以服务端记录为准,不能被请求方覆盖
513        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        // 攻击者拿自己的活跃会话把新 token 指向受害者的 token:不挡这一下,
523        // 无条件 upsert 的 put 会整条盖掉受害者记录,受害者下次 verify 就
524        // 读到自己记录上的攻击者指纹 ⇒ 被动踢下线
525        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        // 受害者记录一字未动:还是他自己的 subject / 指纹,且仍然放行
542        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        // 失败的 rotate 不留副作用:攻击者自己的旧会话也照旧可用
551        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        // 死记录(已吊销)不是「在用」,可以覆盖:登出后留下的记录不该永久占坑
560        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        // stale 过期于 NOW+3600;old 晚 1 秒签发(过期于 NOW+3601),
576        // 好让它在下面的边界时刻仍然活着可轮换
577        let g = guard();
578        g.bind(&ctx("stale", "u1", FP), NOW).unwrap();
579        g.bind(&ctx("old", "u2", FP), NOW + 1).unwrap();
580
581        // 边界内(expires_at > now 仍为真)→ 在用,拒绝
582        assert_eq!(
583            g.rotate("old", "stale", &ctx("old", "u2", FP), NOW + 3_599)
584                .unwrap_err(),
585            SessionError::TokenInUse
586        );
587
588        // expires_at == now → 已过期(与 verify 的 `expires_at <= now` 同一口径),可以覆盖
589        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        // new == old 自相矛盾:既要吊销 old 又要 new 可用。旧实现 put 完立刻
599        // revoke,返回 Ok 而会话已死 —— 调用方以为续期成功,用户却被登出。
600        // 拒绝且零副作用,让这种调用当场暴露而不是静默失效。
601        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}