pub mod geo;
pub mod guard;
pub mod store;
use crate::Severity;
pub use guard::SessionGuard;
pub use store::{LoginPoint, MemoryStore, SessionRecord, SessionStore};
#[derive(Debug, Clone)]
pub struct RequestContext<'a> {
pub token: &'a str,
pub subject: &'a str,
pub fingerprint: &'a str,
pub location: Option<&'a str>,
pub coords: Option<(f64, f64)>,
pub signature: Option<&'a str>,
pub at: Option<u64>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum SessionThreat {
TokenUnknown,
TokenExpired,
TokenRevoked,
FingerprintMismatch,
SignatureInvalid,
SignatureMissing,
SignatureUnexpected,
LocationChanged,
ImpossibleTravel {
kmh: f64,
},
TimestampSkew,
StoreUnavailable,
}
impl SessionThreat {
pub fn severity(&self) -> Severity {
match self {
SessionThreat::TokenUnknown
| SessionThreat::FingerprintMismatch
| SessionThreat::SignatureInvalid
| SessionThreat::ImpossibleTravel { .. } => Severity::Critical,
SessionThreat::TokenRevoked
| SessionThreat::SignatureMissing
| SessionThreat::StoreUnavailable => Severity::High,
SessionThreat::LocationChanged
| SessionThreat::TimestampSkew
| SessionThreat::SignatureUnexpected => Severity::Medium,
SessionThreat::TokenExpired => Severity::Low,
}
}
pub fn decision(&self) -> Decision {
match self {
SessionThreat::LocationChanged
| SessionThreat::TimestampSkew
| SessionThreat::SignatureUnexpected => Decision::Challenge,
_ => Decision::Block,
}
}
}
impl std::fmt::Display for SessionThreat {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SessionThreat::TokenUnknown => write!(f, "unknown token"),
SessionThreat::TokenExpired => write!(f, "token expired"),
SessionThreat::TokenRevoked => write!(f, "token revoked"),
SessionThreat::FingerprintMismatch => write!(f, "fingerprint mismatch"),
SessionThreat::SignatureInvalid => write!(f, "signature invalid"),
SessionThreat::SignatureMissing => write!(f, "signature missing"),
SessionThreat::SignatureUnexpected => write!(f, "unexpected signature"),
SessionThreat::LocationChanged => write!(f, "location changed"),
SessionThreat::ImpossibleTravel { kmh } => {
write!(f, "impossible travel ({kmh:.0} km/h)")
}
SessionThreat::TimestampSkew => write!(f, "timestamp skew"),
SessionThreat::StoreUnavailable => write!(f, "session store unavailable"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Decision {
Allow,
Challenge,
Block,
}
impl std::fmt::Display for Decision {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Decision::Allow => write!(f, "ALLOW"),
Decision::Challenge => write!(f, "CHALLENGE"),
Decision::Block => write!(f, "BLOCK"),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct SessionVerdict {
pub decision: Decision,
pub severity: Option<Severity>,
pub threats: Vec<SessionThreat>,
}
impl SessionVerdict {
pub fn allow() -> Self {
Self {
decision: Decision::Allow,
severity: None,
threats: Vec::new(),
}
}
pub fn single(threat: SessionThreat) -> Self {
Self::from_threats(vec![threat])
}
pub fn from_threats(threats: Vec<SessionThreat>) -> Self {
if threats.is_empty() {
return Self::allow();
}
let decision = threats
.iter()
.map(SessionThreat::decision)
.max()
.unwrap_or(Decision::Block);
let severity = threats
.iter()
.map(SessionThreat::severity)
.max_by_key(severity_rank);
Self {
decision,
severity,
threats,
}
}
pub fn is_allowed(&self) -> bool {
self.decision == Decision::Allow
}
}
fn severity_rank(s: &Severity) -> u8 {
match s {
Severity::Low => 0,
Severity::Medium => 1,
Severity::High => 2,
Severity::Critical => 3,
}
}
#[derive(Debug, Clone)]
pub struct SessionConfig {
pub ttl_secs: u64,
pub impossible_travel_kmh: f64,
pub timestamp_skew_secs: u64,
}
impl Default for SessionConfig {
fn default() -> Self {
Self {
ttl_secs: 3600,
impossible_travel_kmh: 900.0,
timestamp_skew_secs: 300,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StoreError {
Unavailable,
Corrupt,
}
impl std::fmt::Display for StoreError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
StoreError::Unavailable => write!(f, "session store unavailable"),
StoreError::Corrupt => write!(f, "session store corrupt"),
}
}
}
impl std::error::Error for StoreError {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SessionError {
EmptyToken,
EmptySubject,
EmptyFingerprint,
UnknownSession,
Store(StoreError),
}
impl std::fmt::Display for SessionError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SessionError::EmptyToken => write!(f, "token must not be empty"),
SessionError::EmptySubject => write!(f, "subject must not be empty"),
SessionError::EmptyFingerprint => write!(f, "fingerprint must not be empty"),
SessionError::UnknownSession => write!(f, "session not found or no longer valid"),
SessionError::Store(e) => write!(f, "session store error: {e}"),
}
}
}
impl std::error::Error for SessionError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
SessionError::Store(e) => Some(e),
_ => None,
}
}
}
impl From<StoreError> for SessionError {
fn from(e: StoreError) -> Self {
SessionError::Store(e)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decision_ordering_strictest_is_block() {
assert!(Decision::Allow < Decision::Challenge);
assert!(Decision::Challenge < Decision::Block);
assert_eq!(
[Decision::Allow, Decision::Block, Decision::Challenge]
.into_iter()
.max()
.unwrap(),
Decision::Block
);
}
#[test]
fn severity_rank_is_not_declaration_order() {
assert!(severity_rank(&Severity::Critical) > severity_rank(&Severity::Low));
}
#[test]
fn empty_threats_yield_allow() {
let v = SessionVerdict::from_threats(vec![]);
assert_eq!(v.decision, Decision::Allow);
assert!(v.is_allowed());
assert!(v.threats.is_empty());
assert_eq!(v.severity, None, "放行时没有发现,就没有严重度");
}
#[test]
fn block_beats_challenge() {
let v = SessionVerdict::from_threats(vec![
SessionThreat::LocationChanged, SessionThreat::FingerprintMismatch, ]);
assert_eq!(v.decision, Decision::Block);
}
#[test]
fn challenge_wins_when_no_block_present() {
let v = SessionVerdict::from_threats(vec![
SessionThreat::TimestampSkew,
SessionThreat::LocationChanged,
]);
assert_eq!(v.decision, Decision::Challenge);
}
#[test]
fn severity_takes_the_most_severe_not_the_max() {
let v = SessionVerdict::from_threats(vec![
SessionThreat::TokenExpired, SessionThreat::FingerprintMismatch, SessionThreat::LocationChanged, ]);
assert_eq!(v.severity, Some(Severity::Critical));
assert_eq!(v.decision, Decision::Block);
}
#[test]
fn single_threat_maps_correctly() {
let v = SessionVerdict::single(SessionThreat::TokenExpired);
assert_eq!(v.decision, Decision::Block);
assert_eq!(v.severity, Some(Severity::Low));
}
#[test]
fn display_is_human_readable_not_debug() {
assert_eq!(Decision::Challenge.to_string(), "CHALLENGE");
assert_eq!(SessionThreat::TokenExpired.to_string(), "token expired");
assert_eq!(
SessionThreat::StoreUnavailable.to_string(),
"session store unavailable"
);
assert_eq!(
SessionThreat::ImpossibleTravel { kmh: 11_205.4 }.to_string(),
"impossible travel (11205 km/h)"
);
}
#[test]
fn config_defaults_match_spec() {
let c = SessionConfig::default();
assert_eq!(c.ttl_secs, 3600);
assert_eq!(c.impossible_travel_kmh, 900.0);
assert_eq!(c.timestamp_skew_secs, 300);
}
#[test]
fn threat_severity_mapping_matches_spec() {
assert_eq!(SessionThreat::TokenUnknown.severity(), Severity::Critical);
assert_eq!(
SessionThreat::FingerprintMismatch.severity(),
Severity::Critical
);
assert_eq!(
SessionThreat::SignatureInvalid.severity(),
Severity::Critical
);
assert_eq!(
SessionThreat::ImpossibleTravel { kmh: 9_000.0 }.severity(),
Severity::Critical
);
assert_eq!(SessionThreat::TokenRevoked.severity(), Severity::High);
assert_eq!(SessionThreat::SignatureMissing.severity(), Severity::High);
assert_eq!(SessionThreat::StoreUnavailable.severity(), Severity::High);
assert_eq!(SessionThreat::TokenExpired.severity(), Severity::Low);
assert_eq!(SessionThreat::LocationChanged.severity(), Severity::Medium);
assert_eq!(SessionThreat::TimestampSkew.severity(), Severity::Medium);
assert_eq!(
SessionThreat::SignatureUnexpected.severity(),
Severity::Medium
);
}
#[test]
fn only_advisory_threats_challenge() {
assert_eq!(
SessionThreat::LocationChanged.decision(),
Decision::Challenge
);
assert_eq!(SessionThreat::TimestampSkew.decision(), Decision::Challenge);
assert_eq!(
SessionThreat::SignatureUnexpected.decision(),
Decision::Challenge
);
assert_eq!(SessionThreat::TokenExpired.decision(), Decision::Block);
assert_eq!(SessionThreat::StoreUnavailable.decision(), Decision::Block);
}
#[test]
fn errors_display_and_source() {
assert_eq!(
SessionError::EmptyToken.to_string(),
"token must not be empty"
);
assert_eq!(
SessionError::UnknownSession.to_string(),
"session not found or no longer valid"
);
assert_eq!(
StoreError::Unavailable.to_string(),
"session store unavailable"
);
assert_eq!(StoreError::Corrupt.to_string(), "session store corrupt");
let e = SessionError::from(StoreError::Corrupt);
assert!(std::error::Error::source(&e).is_some());
assert!(std::error::Error::source(&SessionError::EmptyToken).is_none());
}
}