1use serde::{Deserialize, Serialize};
7use std::net::IpAddr;
8use std::str::FromStr;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct SessionSecurityConfig {
13 pub enforce_ip_consistency: bool,
15
16 pub allow_ip_range_changes: bool,
18
19 pub max_user_agent_deviation: f32,
21
22 pub auto_rotate_on_suspicious_activity: bool,
24
25 pub max_session_lifetime_hours: u64,
27
28 pub require_periodic_validation: bool,
30
31 pub validation_period_minutes: u64,
33
34 pub enable_device_fingerprinting: bool,
36
37 pub max_concurrent_sessions: Option<usize>,
39
40 pub enable_geo_validation: bool,
42
43 pub max_geo_distance_km: Option<f64>,
45}
46
47impl Default for SessionSecurityConfig {
48 fn default() -> Self {
49 Self {
50 enforce_ip_consistency: false, allow_ip_range_changes: true,
52 max_user_agent_deviation: 0.1, auto_rotate_on_suspicious_activity: true,
54 max_session_lifetime_hours: 24,
55 require_periodic_validation: true,
56 validation_period_minutes: 30,
57 enable_device_fingerprinting: false, max_concurrent_sessions: Some(5),
59 enable_geo_validation: false, max_geo_distance_km: Some(1000.0), }
62 }
63}
64
65impl SessionSecurityConfig {
66 pub fn strict() -> Self {
68 Self {
69 enforce_ip_consistency: true,
70 allow_ip_range_changes: false,
71 max_user_agent_deviation: 0.05, auto_rotate_on_suspicious_activity: true,
73 max_session_lifetime_hours: 8, require_periodic_validation: true,
75 validation_period_minutes: 15, enable_device_fingerprinting: true,
77 max_concurrent_sessions: Some(2), enable_geo_validation: true,
79 max_geo_distance_km: Some(100.0), }
81 }
82
83 pub fn lenient() -> Self {
85 Self {
86 enforce_ip_consistency: false,
87 allow_ip_range_changes: true,
88 max_user_agent_deviation: 0.5, auto_rotate_on_suspicious_activity: false,
90 max_session_lifetime_hours: 72, require_periodic_validation: false,
92 validation_period_minutes: 120, enable_device_fingerprinting: false,
94 max_concurrent_sessions: Some(10),
95 enable_geo_validation: false,
96 max_geo_distance_km: None, }
98 }
99
100 pub fn balanced() -> Self {
102 Self::default()
103 }
104}
105
106#[derive(Debug, Clone, PartialEq)]
108pub enum SessionValidationResult {
109 Valid,
111 ValidWithWarnings(Vec<SecurityWarning>),
113 Suspicious(Vec<SecurityThreat>),
115 Compromised(Vec<SecurityThreat>),
117}
118
119#[derive(Debug, Clone, PartialEq)]
121pub enum SecurityWarning {
122 IPAddressChanged {
124 original: String,
125 current: String,
126 subnet_match: bool,
127 },
128 UserAgentChanged {
130 original: String,
131 current: String,
132 similarity: f32,
133 },
134 SessionNearExpiry { hours_remaining: u64 },
136 UnusualActivity { description: String },
138}
139
140#[derive(Debug, Clone, PartialEq)]
142pub enum SecurityThreat {
143 IPAddressCompromised {
145 original: String,
146 current: String,
147 distance_km: Option<f64>,
148 },
149 UserAgentCompromised {
151 original: String,
152 current: String,
153 similarity: f32,
154 },
155 SessionExpired { hours_exceeded: u64 },
157 DeviceFingerprintMismatch { original: String, current: String },
159 ImpossibleGeography {
161 original_location: Option<String>,
162 current_location: Option<String>,
163 distance_km: f64,
164 time_seconds: u64,
165 },
166 ConcurrentSessionLimitExceeded {
168 current_count: usize,
169 max_allowed: usize,
170 },
171}
172
173pub struct IPSecurityUtils;
175
176impl IPSecurityUtils {
177 pub fn same_subnet(ip1: &str, ip2: &str, prefix_len: u8) -> bool {
179 let Ok(addr1) = IpAddr::from_str(ip1) else {
180 return false;
181 };
182 let Ok(addr2) = IpAddr::from_str(ip2) else {
183 return false;
184 };
185
186 match (addr1, addr2) {
187 (IpAddr::V4(a1), IpAddr::V4(a2)) => Self::same_ipv4_subnet(a1, a2, prefix_len),
188 (IpAddr::V6(a1), IpAddr::V6(a2)) => Self::same_ipv6_subnet(a1, a2, prefix_len),
189 _ => false, }
191 }
192
193 fn same_ipv4_subnet(ip1: std::net::Ipv4Addr, ip2: std::net::Ipv4Addr, prefix_len: u8) -> bool {
194 if prefix_len > 32 {
195 return false;
196 }
197
198 let mask = if prefix_len == 0 {
199 0
200 } else {
201 !((1u32 << (32 - prefix_len)) - 1)
202 };
203
204 let ip1_int = u32::from(ip1);
205 let ip2_int = u32::from(ip2);
206
207 (ip1_int & mask) == (ip2_int & mask)
208 }
209
210 fn same_ipv6_subnet(ip1: std::net::Ipv6Addr, ip2: std::net::Ipv6Addr, prefix_len: u8) -> bool {
211 if prefix_len > 128 {
212 return false;
213 }
214
215 let ip1_bytes = ip1.octets();
216 let ip2_bytes = ip2.octets();
217
218 let full_bytes = (prefix_len / 8) as usize;
219 let remaining_bits = prefix_len % 8;
220
221 if ip1_bytes[..full_bytes] != ip2_bytes[..full_bytes] {
223 return false;
224 }
225
226 if remaining_bits > 0 && full_bytes < 16 {
228 let mask = !((1u8 << (8 - remaining_bits)) - 1);
229 if (ip1_bytes[full_bytes] & mask) != (ip2_bytes[full_bytes] & mask) {
230 return false;
231 }
232 }
233
234 true
235 }
236
237 pub fn estimate_distance_km(ip1: &str, ip2: &str) -> Option<f64> {
240 let location1 = Self::estimate_ip_location(ip1)?;
242 let location2 = Self::estimate_ip_location(ip2)?;
243
244 Some(Self::calculate_haversine_distance(location1, location2))
246 }
247
248 fn estimate_ip_location(ip: &str) -> Option<(f64, f64)> {
250 if ip.starts_with("192.168.") || ip.starts_with("10.") || ip.starts_with("172.") {
252 Some((0.0, 0.0))
254 } else if ip.starts_with("8.8.") || ip.starts_with("1.1.") {
255 Some((39.0458, -76.6413)) } else {
258 Self::lookup_maxmind_coordinates(ip).or_else(|| {
260 Some((40.7128, -74.0060))
262 })
263 }
264 }
265
266 fn calculate_haversine_distance(coord1: (f64, f64), coord2: (f64, f64)) -> f64 {
268 const EARTH_RADIUS_KM: f64 = 6371.0;
269
270 let (lat1, lon1) = coord1;
271 let (lat2, lon2) = coord2;
272
273 let lat1_rad = lat1.to_radians();
274 let lat2_rad = lat2.to_radians();
275 let delta_lat = (lat2 - lat1).to_radians();
276 let delta_lon = (lon2 - lon1).to_radians();
277
278 let a = (delta_lat / 2.0).sin().powi(2)
279 + lat1_rad.cos() * lat2_rad.cos() * (delta_lon / 2.0).sin().powi(2);
280 let c = 2.0 * a.sqrt().asin();
281
282 EARTH_RADIUS_KM * c
283 }
284
285 fn lookup_maxmind_coordinates(ip: &str) -> Option<(f64, f64)> {
287 use std::net::IpAddr;
288 use std::path::Path;
289 use std::str::FromStr;
290
291 let db_path =
293 std::env::var("MAXMIND_DB_PATH").unwrap_or_else(|_| "GeoLite2-City.mmdb".to_string());
294
295 if !Path::new(&db_path).exists() {
296 log::debug!(
297 "MaxMind database not found at {}, using fallback geolocation",
298 db_path
299 );
300 return None;
301 }
302
303 let ip_addr = match IpAddr::from_str(ip) {
305 Ok(addr) => addr,
306 Err(_) => return None,
307 };
308
309 match maxminddb::Reader::open_readfile(&db_path) {
310 Ok(reader) => match reader.lookup::<maxminddb::geoip2::City>(ip_addr) {
311 Ok(Some(city)) => {
312 if let Some(location) = city.location
313 && let (Some(lat), Some(lon)) = (location.latitude, location.longitude)
314 {
315 log::debug!("MaxMind lookup for {}: lat={}, lon={}", ip, lat, lon);
316 return Some((lat, lon));
317 }
318 log::debug!("MaxMind lookup for {} returned no coordinates", ip);
319 None
320 }
321 Ok(None) => {
322 log::debug!("MaxMind lookup for {} returned no data", ip);
323 None
324 }
325 Err(e) => {
326 log::debug!("MaxMind lookup failed for {}: {}", ip, e);
327 None
328 }
329 },
330 Err(e) => {
331 log::warn!("Failed to open MaxMind database: {}", e);
332 None
333 }
334 }
335 }
336}
337
338pub struct UserAgentUtils;
340
341impl UserAgentUtils {
342 pub fn calculate_similarity(ua1: &str, ua2: &str) -> f32 {
345 if ua1 == ua2 {
346 return 1.0;
347 }
348
349 let len1 = ua1.len();
351 let len2 = ua2.len();
352
353 if len1 == 0 && len2 == 0 {
354 return 1.0;
355 }
356 if len1 == 0 || len2 == 0 {
357 return 0.0;
358 }
359
360 let mut matrix = vec![vec![0; len2 + 1]; len1 + 1];
362
363 for (i, row) in matrix.iter_mut().enumerate().take(len1 + 1) {
364 row[0] = i;
365 }
366 for j in 0..=len2 {
367 matrix[0][j] = j;
368 }
369 let ua1_chars: Vec<char> = ua1.chars().collect();
370 let ua2_chars: Vec<char> = ua2.chars().collect();
371
372 for i in 1..=len1 {
373 for j in 1..=len2 {
374 let cost = if ua1_chars[i - 1] == ua2_chars[j - 1] {
375 0
376 } else {
377 1
378 };
379 matrix[i][j] = (matrix[i - 1][j] + 1)
380 .min(matrix[i][j - 1] + 1)
381 .min(matrix[i - 1][j - 1] + cost);
382 }
383 }
384
385 let distance = matrix[len1][len2];
386 let max_len = len1.max(len2);
387
388 1.0 - (distance as f32 / max_len as f32)
389 }
390
391 pub fn is_suspicious_change(original: &str, current: &str, threshold: f32) -> bool {
393 let similarity = Self::calculate_similarity(original, current);
394 similarity < threshold
395 }
396}
397
398#[cfg(test)]
399mod tests {
400 use super::*;
401
402 #[test]
403 fn test_session_security_config_presets() {
404 let strict = SessionSecurityConfig::strict();
405 assert!(strict.enforce_ip_consistency);
406 assert_eq!(strict.max_session_lifetime_hours, 8);
407
408 let lenient = SessionSecurityConfig::lenient();
409 assert!(!lenient.enforce_ip_consistency);
410 assert_eq!(lenient.max_session_lifetime_hours, 72);
411
412 let balanced = SessionSecurityConfig::balanced();
413 assert!(!balanced.enforce_ip_consistency);
414 assert!(balanced.auto_rotate_on_suspicious_activity);
415 }
416
417 #[test]
418 fn test_ip_subnet_checking() {
419 assert!(IPSecurityUtils::same_subnet(
421 "192.168.1.1",
422 "192.168.1.2",
423 24
424 ));
425 assert!(!IPSecurityUtils::same_subnet(
426 "192.168.1.1",
427 "192.168.2.1",
428 24
429 ));
430 assert!(IPSecurityUtils::same_subnet("10.0.0.1", "10.0.0.255", 24));
431
432 assert!(IPSecurityUtils::same_subnet(
434 "2001:db8::1",
435 "2001:db8::2",
436 64
437 ));
438 assert!(!IPSecurityUtils::same_subnet(
439 "2001:db8::1",
440 "2001:db9::1",
441 64
442 ));
443 }
444
445 #[test]
446 fn test_user_agent_similarity() {
447 let ua1 = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36";
448 let ua2 = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.37";
449 let ua3 = "Chrome/91.0.4472.124 Safari/537.36";
450
451 let similarity1 = UserAgentUtils::calculate_similarity(ua1, ua2);
453 assert!(similarity1 > 0.8);
454
455 let similarity2 = UserAgentUtils::calculate_similarity(ua1, ua3);
457 assert!(similarity2 < 0.5);
458
459 let similarity3 = UserAgentUtils::calculate_similarity(ua1, ua1);
461 assert_eq!(similarity3, 1.0);
462 }
463
464 #[test]
465 fn test_suspicious_user_agent_detection() {
466 let original = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36";
467 let minor_change = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.37";
468 let major_change = "curl/7.68.0";
469
470 assert!(!UserAgentUtils::is_suspicious_change(
471 original,
472 minor_change,
473 0.8
474 ));
475 assert!(UserAgentUtils::is_suspicious_change(
476 original,
477 major_change,
478 0.8
479 ));
480 }
481
482 #[test]
483 fn test_security_validation_result_enum() {
484 let valid = SessionValidationResult::Valid;
485 let suspicious =
486 SessionValidationResult::Suspicious(vec![SecurityThreat::IPAddressCompromised {
487 original: "192.168.1.1".to_string(),
488 current: "10.0.0.1".to_string(),
489 distance_km: Some(100.0),
490 }]);
491
492 match valid {
493 SessionValidationResult::Valid => {
494 }
496 _ => panic!("Expected valid session validation"),
497 }
498
499 match suspicious {
500 SessionValidationResult::Suspicious(threats) => {
501 assert_eq!(threats.len(), 1);
502 }
503 _ => panic!("Expected suspicious session validation"),
504 }
505 }
506}