1pub 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#[derive(Debug, Clone)]
14pub struct RequestContext<'a> {
15 pub token: &'a str,
17 pub subject: &'a str,
25 pub fingerprint: &'a str,
27 pub location: Option<&'a str>,
29 pub coords: Option<(f64, f64)>,
31 pub signature: Option<&'a str>,
33 pub at: Option<u64>,
35}
36
37#[derive(Debug, Clone, PartialEq)]
39pub enum SessionThreat {
40 TokenUnknown,
41 TokenExpired,
42 TokenRevoked,
43 FingerprintMismatch,
44 SignatureInvalid,
45 SignatureMissing,
46 SignatureUnexpected,
48 LocationChanged,
49 ImpossibleTravel {
50 kmh: f64,
51 },
52 TimestampSkew,
53 StoreUnavailable,
54}
55
56impl SessionThreat {
57 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 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
85impl 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
109pub enum Decision {
110 Allow,
111 Challenge,
112 Block,
113}
114
115impl 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#[derive(Debug, Clone, PartialEq)]
128pub struct SessionVerdict {
129 pub decision: Decision,
130 pub severity: Option<Severity>,
135 pub threats: Vec<SessionThreat>,
136}
137
138impl SessionVerdict {
139 pub fn allow() -> Self {
141 Self {
142 decision: Decision::Allow,
143 severity: None,
144 threats: Vec::new(),
145 }
146 }
147
148 pub fn single(threat: SessionThreat) -> Self {
150 Self::from_threats(vec![threat])
151 }
152
153 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 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
184fn 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#[derive(Debug, Clone)]
196pub struct SessionConfig {
197 pub ttl_secs: u64,
199 pub impossible_travel_kmh: f64,
201 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#[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#[derive(Debug, Clone, PartialEq, Eq)]
235pub enum SessionError {
236 EmptyToken,
237 EmptySubject,
238 EmptyFingerprint,
239 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 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, SessionThreat::FingerprintMismatch, ]);
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, SessionThreat::FingerprintMismatch, SessionThreat::LocationChanged, ]);
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 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 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 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}