Skip to main content

security_rust/session/
mod.rs

1// Copyright (c) 2026 erik <erik@erik.xyz> — https://erik.xyz
2
3mod geo;
4pub mod guard;
5pub mod store;
6
7use crate::Severity;
8
9pub use guard::SessionGuard;
10pub use store::{LoginPoint, MemoryStore, SessionRecord, SessionStore};
11
12/// 一次请求的全部输入,字段由调用方填写。
13#[derive(Debug, Clone)]
14pub struct RequestContext<'a> {
15    /// 调用方签发的 token 值。`verify` 中为空 ⇒ `TokenUnknown`。
16    pub token: &'a str,
17    /// 用户标识。**仅 `bind` 使用,`verify` 完全忽略它**:每请求校验的身份
18    /// 一律取自服务端 [`SessionRecord`](异地历史按 `record.subject` 聚合),
19    /// 请求方提供的 subject 不可信。
20    ///
21    /// 因此中间件里传 `subject: ""` 是合法的 —— `verify` 不看这个字段
22    /// (`bind` 才要求非空)。也正因如此,**绝不要**把请求头里的用户标识
23    /// 填进来当身份:现在它进不了判定,将来重构也未必。
24    pub subject: &'a str,
25    /// 客户端指纹(如 IP + User-Agent 的规范化拼接),登录时绑定。
26    pub fingerprint: &'a str,
27    /// 区域标识,如 "CN-BJ"。由调用方用已有 geo 库从 IP 解析。
28    pub location: Option<&'a str>,
29    /// (纬度, 经度),用于「不可能旅行」判定。
30    pub coords: Option<(f64, f64)>,
31    /// 调用方算好的 MAC。
32    pub signature: Option<&'a str>,
33    /// 请求自称的时间(unix 秒,如 token 内嵌的 iat)。
34    pub at: Option<u64>,
35}
36
37/// 会话安全威胁。见 spec 的判定与处置映射表。
38// 3.0 起封口:威胁分类会随检测能力增长,`#[non_exhaustive]` 把下游 `match`
39// 逼出一个 `_` 臂,此后新增变体是 minor 而非 major(下游不再被编译错误绑架)。
40#[derive(Debug, Clone, PartialEq)]
41#[non_exhaustive]
42pub enum SessionThreat {
43    TokenUnknown,
44    TokenExpired,
45    TokenRevoked,
46    FingerprintMismatch,
47    SignatureInvalid,
48    SignatureMissing,
49    /// 登录时未设签名基线,但本请求提供了签名。
50    SignatureUnexpected,
51    LocationChanged,
52    ImpossibleTravel {
53        kmh: f64,
54    },
55    TimestampSkew,
56    StoreUnavailable,
57}
58
59impl SessionThreat {
60    /// 该威胁对应的严重度。映射是固定默认值,不做配置。
61    pub fn severity(&self) -> Severity {
62        match self {
63            SessionThreat::TokenUnknown
64            | SessionThreat::FingerprintMismatch
65            | SessionThreat::SignatureInvalid
66            | SessionThreat::ImpossibleTravel { .. } => Severity::Critical,
67            SessionThreat::TokenRevoked
68            | SessionThreat::SignatureMissing
69            | SessionThreat::StoreUnavailable => Severity::High,
70            SessionThreat::LocationChanged
71            | SessionThreat::TimestampSkew
72            | SessionThreat::SignatureUnexpected => Severity::Medium,
73            SessionThreat::TokenExpired => Severity::Low,
74        }
75    }
76
77    /// 该威胁对应的处置建议。
78    pub fn decision(&self) -> Decision {
79        match self {
80            SessionThreat::LocationChanged
81            | SessionThreat::TimestampSkew
82            | SessionThreat::SignatureUnexpected => Decision::Challenge,
83            _ => Decision::Block,
84        }
85    }
86}
87
88/// 人类可读的威胁描述,供日志直接打印。
89impl std::fmt::Display for SessionThreat {
90    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91        match self {
92            SessionThreat::TokenUnknown => write!(f, "unknown token"),
93            SessionThreat::TokenExpired => write!(f, "token expired"),
94            SessionThreat::TokenRevoked => write!(f, "token revoked"),
95            SessionThreat::FingerprintMismatch => write!(f, "fingerprint mismatch"),
96            SessionThreat::SignatureInvalid => write!(f, "signature invalid"),
97            SessionThreat::SignatureMissing => write!(f, "signature missing"),
98            SessionThreat::SignatureUnexpected => write!(f, "unexpected signature"),
99            SessionThreat::LocationChanged => write!(f, "location changed"),
100            // km/h 是这条判定最有用的信息,不能让它只存在于 Debug 形状里
101            SessionThreat::ImpossibleTravel { kmh } => {
102                write!(f, "impossible travel ({kmh:.0} km/h)")
103            }
104            SessionThreat::TimestampSkew => write!(f, "timestamp skew"),
105            SessionThreat::StoreUnavailable => write!(f, "session store unavailable"),
106        }
107    }
108}
109
110/// 严格度递增(声明顺序即 Ord 顺序),取最严格者作为最终决策。
111#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
112pub enum Decision {
113    Allow,
114    Challenge,
115    Block,
116}
117
118/// 状态标签,与 [`Severity`] 同样用大写 —— 这三种是处置结论,不是描述。
119impl std::fmt::Display for Decision {
120    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121        match self {
122            Decision::Allow => write!(f, "ALLOW"),
123            Decision::Challenge => write!(f, "CHALLENGE"),
124            Decision::Block => write!(f, "BLOCK"),
125        }
126    }
127}
128
129/// 一次校验的结论。
130#[derive(Debug, Clone, PartialEq)]
131pub struct SessionVerdict {
132    pub decision: Decision,
133    /// 最严重威胁的严重度;**无威胁(放行)时为 `None`**。
134    ///
135    /// 不拿 `Severity::Low` 占位:占位值在日志里跟「发现了一条低危」长得一模一样,
136    /// 一条完全放行的正常请求会被读成有发现。没有威胁就是没有严重度,由类型说明。
137    pub severity: Option<Severity>,
138    pub threats: Vec<SessionThreat>,
139}
140
141impl SessionVerdict {
142    /// 无任何威胁:放行,`severity` 为 `None`。
143    pub fn allow() -> Self {
144        Self {
145            decision: Decision::Allow,
146            severity: None,
147            threats: Vec::new(),
148        }
149    }
150
151    /// 单个威胁。
152    pub fn single(threat: SessionThreat) -> Self {
153        Self::from_threats(vec![threat])
154    }
155
156    /// 由威胁列表聚合:decision 取最严格者,severity 取最严重者(空列表 ⇒ `None`)。
157    ///
158    /// `Severity` 不提供任何序(派生 `Ord` 会按声明顺序 Critical < Low,与严重程度相反),
159    /// 因此严重度比较一律走显式的 `severity_rank`;decision 的比较则可用 `Decision` 的 `Ord`。
160    pub fn from_threats(threats: Vec<SessionThreat>) -> Self {
161        if threats.is_empty() {
162            return Self::allow();
163        }
164        let decision = threats
165            .iter()
166            .map(SessionThreat::decision)
167            .max()
168            .unwrap_or(Decision::Block);
169        // 走到这里 threats 必非空,`max_by_key` 必为 `Some`;用 `Option` 承接而不是
170        // 补一个不可能失败的 `unwrap_or` 占位值,正是为了让 `None` 只表示「无威胁」
171        let severity = threats
172            .iter()
173            .map(SessionThreat::severity)
174            .max_by_key(severity_rank);
175        Self {
176            decision,
177            severity,
178            threats,
179        }
180    }
181
182    pub fn is_allowed(&self) -> bool {
183        self.decision == Decision::Allow
184    }
185}
186
187/// 严重度权重,数值越大越严重。这是 `Severity` 唯一的排序依据。
188fn severity_rank(s: &Severity) -> u8 {
189    match s {
190        Severity::Low => 0,
191        Severity::Medium => 1,
192        Severity::High => 2,
193        Severity::Critical => 3,
194    }
195}
196
197/// 只放校准旋钮(阈值),不放策略。
198#[derive(Debug, Clone)]
199pub struct SessionConfig {
200    /// 会话有效期(秒)。
201    pub ttl_secs: u64,
202    /// 不可能旅行的速度上限(km/h)。
203    pub impossible_travel_kmh: f64,
204    /// 请求自称时间与 `now` 的最大容忍偏离(秒)。
205    pub timestamp_skew_secs: u64,
206}
207
208impl Default for SessionConfig {
209    fn default() -> Self {
210        Self {
211            ttl_secs: 3600,
212            impossible_travel_kmh: 900.0,
213            timestamp_skew_secs: 300,
214        }
215    }
216}
217
218/// 存储后端故障。
219// 同 SessionThreat:封口,新增故障种类(如 Redis 后端的超时 / 序列化失败)
220// 对下游只是「多一个 `_` 臂」,不再是破坏性变更。
221#[derive(Debug, Clone, PartialEq, Eq)]
222#[non_exhaustive]
223pub enum StoreError {
224    Unavailable,
225    Corrupt,
226}
227
228impl std::fmt::Display for StoreError {
229    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
230        match self {
231            StoreError::Unavailable => write!(f, "session store unavailable"),
232            StoreError::Corrupt => write!(f, "session store corrupt"),
233        }
234    }
235}
236
237impl std::error::Error for StoreError {}
238
239/// 调用方误用,或后端故障向上传递。
240///
241/// **错误分界**:只有需要校验调用方输入或做安全判定的方法才返回本类型
242/// (`bind` / `rotate`);纯委派给 store 的方法(`revoke` / `revoke_all` /
243/// `purge_expired`)没有可误用的输入也不产生判定,直接透出 [`StoreError`]。
244// 封口理由同 SessionThreat:错误变体会随守卫的判定项增长。
245#[derive(Debug, Clone, PartialEq, Eq)]
246#[non_exhaustive]
247pub enum SessionError {
248    EmptyToken,
249    EmptySubject,
250    EmptyFingerprint,
251    /// `rotate` 的旧 token 不存在、已吊销或已过期,或指纹与本请求不符。
252    UnknownSession,
253    /// `rotate` 的目标 token 已是一个在用会话(未吊销且未过期),
254    /// 包括 `new == old` —— 换到同一个 token 语义上自相矛盾,见 `rotate`。
255    TokenInUse,
256    Store(StoreError),
257}
258
259impl std::fmt::Display for SessionError {
260    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
261        match self {
262            SessionError::EmptyToken => write!(f, "token must not be empty"),
263            SessionError::EmptySubject => write!(f, "subject must not be empty"),
264            SessionError::EmptyFingerprint => write!(f, "fingerprint must not be empty"),
265            SessionError::UnknownSession => write!(f, "session not found or no longer valid"),
266            SessionError::TokenInUse => write!(f, "new token is already in use"),
267            SessionError::Store(e) => write!(f, "session store error: {e}"),
268        }
269    }
270}
271
272impl std::error::Error for SessionError {
273    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
274        match self {
275            SessionError::Store(e) => Some(e),
276            _ => None,
277        }
278    }
279}
280
281impl From<StoreError> for SessionError {
282    fn from(e: StoreError) -> Self {
283        SessionError::Store(e)
284    }
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    #[test]
292    fn decision_ordering_strictest_is_block() {
293        assert!(Decision::Allow < Decision::Challenge);
294        assert!(Decision::Challenge < Decision::Block);
295        assert_eq!(
296            [Decision::Allow, Decision::Block, Decision::Challenge]
297                .into_iter()
298                .max()
299                .unwrap(),
300            Decision::Block
301        );
302    }
303
304    #[test]
305    fn severity_rank_is_not_declaration_order() {
306        // Severity 没有任何 Ord,rank 是它唯一的严重度排序依据
307        assert!(severity_rank(&Severity::Critical) > severity_rank(&Severity::Low));
308    }
309
310    #[test]
311    fn empty_threats_yield_allow() {
312        let v = SessionVerdict::from_threats(vec![]);
313        assert_eq!(v.decision, Decision::Allow);
314        assert!(v.is_allowed());
315        assert!(v.threats.is_empty());
316        assert_eq!(v.severity, None, "放行时没有发现,就没有严重度");
317    }
318
319    #[test]
320    fn block_beats_challenge() {
321        let v = SessionVerdict::from_threats(vec![
322            SessionThreat::LocationChanged,     // Challenge
323            SessionThreat::FingerprintMismatch, // Block
324        ]);
325        assert_eq!(v.decision, Decision::Block);
326    }
327
328    #[test]
329    fn challenge_wins_when_no_block_present() {
330        let v = SessionVerdict::from_threats(vec![
331            SessionThreat::TimestampSkew,
332            SessionThreat::LocationChanged,
333        ]);
334        assert_eq!(v.decision, Decision::Challenge);
335    }
336
337    #[test]
338    fn severity_takes_the_most_severe_not_the_max() {
339        let v = SessionVerdict::from_threats(vec![
340            SessionThreat::TokenExpired,        // Low
341            SessionThreat::FingerprintMismatch, // Critical
342            SessionThreat::LocationChanged,     // Medium
343        ]);
344        assert_eq!(v.severity, Some(Severity::Critical));
345        assert_eq!(v.decision, Decision::Block);
346    }
347
348    #[test]
349    fn single_threat_maps_correctly() {
350        let v = SessionVerdict::single(SessionThreat::TokenExpired);
351        assert_eq!(v.decision, Decision::Block);
352        assert_eq!(v.severity, Some(Severity::Low));
353    }
354
355    #[test]
356    fn display_is_human_readable_not_debug() {
357        assert_eq!(Decision::Challenge.to_string(), "CHALLENGE");
358        assert_eq!(SessionThreat::TokenExpired.to_string(), "token expired");
359        assert_eq!(
360            SessionThreat::StoreUnavailable.to_string(),
361            "session store unavailable"
362        );
363        // km/h 必须真的出现在输出里,而不是只留在 Debug 形状的内层
364        assert_eq!(
365            SessionThreat::ImpossibleTravel { kmh: 11_205.4 }.to_string(),
366            "impossible travel (11205 km/h)"
367        );
368    }
369
370    #[test]
371    fn config_defaults_match_spec() {
372        let c = SessionConfig::default();
373        assert_eq!(c.ttl_secs, 3600);
374        assert_eq!(c.impossible_travel_kmh, 900.0);
375        assert_eq!(c.timestamp_skew_secs, 300);
376    }
377
378    #[test]
379    fn threat_severity_mapping_matches_spec() {
380        assert_eq!(SessionThreat::TokenUnknown.severity(), Severity::Critical);
381        assert_eq!(
382            SessionThreat::FingerprintMismatch.severity(),
383            Severity::Critical
384        );
385        assert_eq!(
386            SessionThreat::SignatureInvalid.severity(),
387            Severity::Critical
388        );
389        assert_eq!(
390            SessionThreat::ImpossibleTravel { kmh: 9_000.0 }.severity(),
391            Severity::Critical
392        );
393        assert_eq!(SessionThreat::TokenRevoked.severity(), Severity::High);
394        assert_eq!(SessionThreat::SignatureMissing.severity(), Severity::High);
395        assert_eq!(SessionThreat::StoreUnavailable.severity(), Severity::High);
396        assert_eq!(SessionThreat::TokenExpired.severity(), Severity::Low);
397        assert_eq!(SessionThreat::LocationChanged.severity(), Severity::Medium);
398        assert_eq!(SessionThreat::TimestampSkew.severity(), Severity::Medium);
399        assert_eq!(
400            SessionThreat::SignatureUnexpected.severity(),
401            Severity::Medium
402        );
403    }
404
405    #[test]
406    fn only_advisory_threats_challenge() {
407        // 这三项是「信号」而非「结论」:可能只是出差 / 时钟漂移 / 调用方
408        // 与自己行为不一致,先二次验证而不是直接拒绝
409        assert_eq!(
410            SessionThreat::LocationChanged.decision(),
411            Decision::Challenge
412        );
413        assert_eq!(SessionThreat::TimestampSkew.decision(), Decision::Challenge);
414        assert_eq!(
415            SessionThreat::SignatureUnexpected.decision(),
416            Decision::Challenge
417        );
418        // 其余一律 Block
419        assert_eq!(SessionThreat::TokenExpired.decision(), Decision::Block);
420        assert_eq!(SessionThreat::StoreUnavailable.decision(), Decision::Block);
421    }
422
423    #[test]
424    fn errors_display_and_source() {
425        assert_eq!(
426            SessionError::EmptyToken.to_string(),
427            "token must not be empty"
428        );
429        assert_eq!(
430            SessionError::UnknownSession.to_string(),
431            "session not found or no longer valid"
432        );
433        assert_eq!(
434            SessionError::TokenInUse.to_string(),
435            "new token is already in use"
436        );
437        assert_eq!(
438            StoreError::Unavailable.to_string(),
439            "session store unavailable"
440        );
441        assert_eq!(StoreError::Corrupt.to_string(), "session store corrupt");
442        let e = SessionError::from(StoreError::Corrupt);
443        assert!(std::error::Error::source(&e).is_some());
444        assert!(std::error::Error::source(&SessionError::EmptyToken).is_none());
445        assert!(std::error::Error::source(&SessionError::TokenInUse).is_none());
446    }
447}