use serde::{Deserialize, Serialize};
use std::net::IpAddr;
use std::str::FromStr;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionSecurityConfig {
pub enforce_ip_consistency: bool,
pub allow_ip_range_changes: bool,
pub max_user_agent_deviation: f32,
pub auto_rotate_on_suspicious_activity: bool,
pub max_session_lifetime_hours: u64,
pub require_periodic_validation: bool,
pub validation_period_minutes: u64,
pub enable_device_fingerprinting: bool,
pub max_concurrent_sessions: Option<usize>,
pub enable_geo_validation: bool,
pub max_geo_distance_km: Option<f64>,
}
impl Default for SessionSecurityConfig {
fn default() -> Self {
Self {
enforce_ip_consistency: false, allow_ip_range_changes: true,
max_user_agent_deviation: 0.1, auto_rotate_on_suspicious_activity: true,
max_session_lifetime_hours: 24,
require_periodic_validation: true,
validation_period_minutes: 30,
enable_device_fingerprinting: false, max_concurrent_sessions: Some(5),
enable_geo_validation: false, max_geo_distance_km: Some(1000.0), }
}
}
impl SessionSecurityConfig {
pub fn strict() -> Self {
Self {
enforce_ip_consistency: true,
allow_ip_range_changes: false,
max_user_agent_deviation: 0.05, auto_rotate_on_suspicious_activity: true,
max_session_lifetime_hours: 8, require_periodic_validation: true,
validation_period_minutes: 15, enable_device_fingerprinting: true,
max_concurrent_sessions: Some(2), enable_geo_validation: true,
max_geo_distance_km: Some(100.0), }
}
pub fn lenient() -> Self {
Self {
enforce_ip_consistency: false,
allow_ip_range_changes: true,
max_user_agent_deviation: 0.5, auto_rotate_on_suspicious_activity: false,
max_session_lifetime_hours: 72, require_periodic_validation: false,
validation_period_minutes: 120, enable_device_fingerprinting: false,
max_concurrent_sessions: Some(10),
enable_geo_validation: false,
max_geo_distance_km: None, }
}
pub fn balanced() -> Self {
Self::default()
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum SessionValidationResult {
Valid,
ValidWithWarnings(Vec<SecurityWarning>),
Suspicious(Vec<SecurityThreat>),
Compromised(Vec<SecurityThreat>),
}
#[derive(Debug, Clone, PartialEq)]
pub enum SecurityWarning {
IPAddressChanged {
original: String,
current: String,
subnet_match: bool,
},
UserAgentChanged {
original: String,
current: String,
similarity: f32,
},
SessionNearExpiry { hours_remaining: u64 },
UnusualActivity { description: String },
}
#[derive(Debug, Clone, PartialEq)]
pub enum SecurityThreat {
IPAddressCompromised {
original: String,
current: String,
distance_km: Option<f64>,
},
UserAgentCompromised {
original: String,
current: String,
similarity: f32,
},
SessionExpired { hours_exceeded: u64 },
DeviceFingerprintMismatch { original: String, current: String },
ImpossibleGeography {
original_location: Option<String>,
current_location: Option<String>,
distance_km: f64,
time_seconds: u64,
},
ConcurrentSessionLimitExceeded {
current_count: usize,
max_allowed: usize,
},
}
pub struct IPSecurityUtils;
impl IPSecurityUtils {
pub fn same_subnet(ip1: &str, ip2: &str, prefix_len: u8) -> bool {
let Ok(addr1) = IpAddr::from_str(ip1) else {
return false;
};
let Ok(addr2) = IpAddr::from_str(ip2) else {
return false;
};
match (addr1, addr2) {
(IpAddr::V4(a1), IpAddr::V4(a2)) => Self::same_ipv4_subnet(a1, a2, prefix_len),
(IpAddr::V6(a1), IpAddr::V6(a2)) => Self::same_ipv6_subnet(a1, a2, prefix_len),
_ => false, }
}
fn same_ipv4_subnet(ip1: std::net::Ipv4Addr, ip2: std::net::Ipv4Addr, prefix_len: u8) -> bool {
if prefix_len > 32 {
return false;
}
let mask = if prefix_len == 0 {
0
} else {
!((1u32 << (32 - prefix_len)) - 1)
};
let ip1_int = u32::from(ip1);
let ip2_int = u32::from(ip2);
(ip1_int & mask) == (ip2_int & mask)
}
fn same_ipv6_subnet(ip1: std::net::Ipv6Addr, ip2: std::net::Ipv6Addr, prefix_len: u8) -> bool {
if prefix_len > 128 {
return false;
}
let ip1_bytes = ip1.octets();
let ip2_bytes = ip2.octets();
let full_bytes = (prefix_len / 8) as usize;
let remaining_bits = prefix_len % 8;
if ip1_bytes[..full_bytes] != ip2_bytes[..full_bytes] {
return false;
}
if remaining_bits > 0 && full_bytes < 16 {
let mask = !((1u8 << (8 - remaining_bits)) - 1);
if (ip1_bytes[full_bytes] & mask) != (ip2_bytes[full_bytes] & mask) {
return false;
}
}
true
}
pub fn estimate_distance_km(ip1: &str, ip2: &str) -> Option<f64> {
let location1 = Self::estimate_ip_location(ip1)?;
let location2 = Self::estimate_ip_location(ip2)?;
Some(Self::calculate_haversine_distance(location1, location2))
}
fn estimate_ip_location(ip: &str) -> Option<(f64, f64)> {
if ip.starts_with("192.168.") || ip.starts_with("10.") || ip.starts_with("172.") {
Some((0.0, 0.0))
} else if ip.starts_with("8.8.") || ip.starts_with("1.1.") {
Some((39.0458, -76.6413)) } else {
Self::lookup_maxmind_coordinates(ip).or_else(|| {
Some((0.0, 0.0))
})
}
}
fn calculate_haversine_distance(coord1: (f64, f64), coord2: (f64, f64)) -> f64 {
const EARTH_RADIUS_KM: f64 = 6371.0;
let (lat1, lon1) = coord1;
let (lat2, lon2) = coord2;
let lat1_rad = lat1.to_radians();
let lat2_rad = lat2.to_radians();
let delta_lat = (lat2 - lat1).to_radians();
let delta_lon = (lon2 - lon1).to_radians();
let a = (delta_lat / 2.0).sin().powi(2)
+ lat1_rad.cos() * lat2_rad.cos() * (delta_lon / 2.0).sin().powi(2);
let c = 2.0 * a.sqrt().asin();
EARTH_RADIUS_KM * c
}
fn lookup_maxmind_coordinates(ip: &str) -> Option<(f64, f64)> {
use std::net::IpAddr;
use std::path::Path;
use std::str::FromStr;
let db_path =
std::env::var("MAXMIND_DB_PATH").unwrap_or_else(|_| "GeoLite2-City.mmdb".to_string());
if !Path::new(&db_path).exists() {
tracing::debug!(
"MaxMind database not found at {}, using fallback geolocation",
db_path
);
return None;
}
let ip_addr = match IpAddr::from_str(ip) {
Ok(addr) => addr,
Err(_) => return None,
};
match maxminddb::Reader::open_readfile(&db_path) {
Ok(reader) => match reader.lookup(ip_addr) {
Ok(result) => match result.decode::<maxminddb::geoip2::City>() {
Ok(Some(city)) => {
let location = &city.location;
if let (Some(lat), Some(lon)) = (location.latitude, location.longitude) {
tracing::debug!("MaxMind lookup for {}: lat={}, lon={}", ip, lat, lon);
return Some((lat, lon));
}
tracing::debug!("MaxMind lookup for {} returned no coordinates", ip);
None
}
Ok(None) => {
tracing::debug!("MaxMind lookup returned no data for {}", ip);
None
}
Err(e) => {
tracing::debug!("MaxMind lookup failed for {}: {}", ip, e);
None
}
},
Err(e) => {
tracing::debug!("MaxMind lookup failed for {}: {}", ip, e);
None
}
},
Err(e) => {
tracing::warn!("Failed to open MaxMind database: {}", e);
None
}
}
}
}
pub struct UserAgentUtils;
impl UserAgentUtils {
pub fn calculate_similarity(ua1: &str, ua2: &str) -> f32 {
if ua1 == ua2 {
return 1.0;
}
let len1 = ua1.len();
let len2 = ua2.len();
if len1 == 0 && len2 == 0 {
return 1.0;
}
if len1 == 0 || len2 == 0 {
return 0.0;
}
let mut matrix = vec![vec![0; len2 + 1]; len1 + 1];
for (i, row) in matrix.iter_mut().enumerate().take(len1 + 1) {
row[0] = i;
}
for (j, cell) in matrix[0].iter_mut().enumerate() {
*cell = j;
}
let ua1_chars: Vec<char> = ua1.chars().collect();
let ua2_chars: Vec<char> = ua2.chars().collect();
for i in 1..=len1 {
for j in 1..=len2 {
let cost = if ua1_chars[i - 1] == ua2_chars[j - 1] {
0
} else {
1
};
matrix[i][j] = (matrix[i - 1][j] + 1)
.min(matrix[i][j - 1] + 1)
.min(matrix[i - 1][j - 1] + cost);
}
}
let distance = matrix[len1][len2];
let max_len = len1.max(len2);
1.0 - (distance as f32 / max_len as f32)
}
pub fn is_suspicious_change(original: &str, current: &str, threshold: f32) -> bool {
let similarity = Self::calculate_similarity(original, current);
similarity < threshold
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_session_security_config_presets() {
let strict = SessionSecurityConfig::strict();
assert!(strict.enforce_ip_consistency);
assert_eq!(strict.max_session_lifetime_hours, 8);
let lenient = SessionSecurityConfig::lenient();
assert!(!lenient.enforce_ip_consistency);
assert_eq!(lenient.max_session_lifetime_hours, 72);
let balanced = SessionSecurityConfig::balanced();
assert!(!balanced.enforce_ip_consistency);
assert!(balanced.auto_rotate_on_suspicious_activity);
}
#[test]
fn test_ip_subnet_checking() {
assert!(IPSecurityUtils::same_subnet(
"192.168.1.1",
"192.168.1.2",
24
));
assert!(!IPSecurityUtils::same_subnet(
"192.168.1.1",
"192.168.2.1",
24
));
assert!(IPSecurityUtils::same_subnet("10.0.0.1", "10.0.0.255", 24));
assert!(IPSecurityUtils::same_subnet(
"2001:db8::1",
"2001:db8::2",
64
));
assert!(!IPSecurityUtils::same_subnet(
"2001:db8::1",
"2001:db9::1",
64
));
}
#[test]
fn test_user_agent_similarity() {
let ua1 = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36";
let ua2 = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.37";
let ua3 = "Chrome/91.0.4472.124 Safari/537.36";
let similarity1 = UserAgentUtils::calculate_similarity(ua1, ua2);
assert!(similarity1 > 0.8);
let similarity2 = UserAgentUtils::calculate_similarity(ua1, ua3);
assert!(similarity2 < 0.5);
let similarity3 = UserAgentUtils::calculate_similarity(ua1, ua1);
assert_eq!(similarity3, 1.0);
}
#[test]
fn test_suspicious_user_agent_detection() {
let original = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36";
let minor_change = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.37";
let major_change = "curl/7.68.0";
assert!(!UserAgentUtils::is_suspicious_change(
original,
minor_change,
0.8
));
assert!(UserAgentUtils::is_suspicious_change(
original,
major_change,
0.8
));
}
#[test]
fn test_security_validation_result_enum() {
let valid = SessionValidationResult::Valid;
let suspicious =
SessionValidationResult::Suspicious(vec![SecurityThreat::IPAddressCompromised {
original: "192.168.1.1".to_string(),
current: "10.0.0.1".to_string(),
distance_km: Some(100.0),
}]);
match valid {
SessionValidationResult::Valid => {
}
_ => panic!("Expected valid session validation"),
}
match suspicious {
SessionValidationResult::Suspicious(threats) => {
assert_eq!(threats.len(), 1);
}
_ => panic!("Expected suspicious session validation"),
}
}
}