Skip to main content

security_rust/session/
mod.rs

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