1mod 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)]
41#[non_exhaustive]
42pub enum SessionThreat {
43 TokenUnknown,
44 TokenExpired,
45 TokenRevoked,
46 FingerprintMismatch,
47 SignatureInvalid,
48 SignatureMissing,
49 SignatureUnexpected,
51 LocationChanged,
52 ImpossibleTravel {
53 kmh: f64,
54 },
55 TimestampSkew,
56 StoreUnavailable,
57}
58
59impl SessionThreat {
60 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 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
88impl 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
112pub enum Decision {
113 Allow,
114 Challenge,
115 Block,
116}
117
118impl 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#[derive(Debug, Clone, PartialEq)]
131pub struct SessionVerdict {
132 pub decision: Decision,
133 pub severity: Option<Severity>,
138 pub threats: Vec<SessionThreat>,
139}
140
141impl SessionVerdict {
142 pub fn allow() -> Self {
144 Self {
145 decision: Decision::Allow,
146 severity: None,
147 threats: Vec::new(),
148 }
149 }
150
151 pub fn single(threat: SessionThreat) -> Self {
153 Self::from_threats(vec![threat])
154 }
155
156 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 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
187fn 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#[derive(Debug, Clone)]
199pub struct SessionConfig {
200 pub ttl_secs: u64,
202 pub impossible_travel_kmh: f64,
204 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#[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#[derive(Debug, Clone, PartialEq, Eq)]
246#[non_exhaustive]
247pub enum SessionError {
248 EmptyToken,
249 EmptySubject,
250 EmptyFingerprint,
251 UnknownSession,
253 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 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, SessionThreat::FingerprintMismatch, ]);
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, SessionThreat::FingerprintMismatch, SessionThreat::LocationChanged, ]);
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 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 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 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}