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((0.0, 0.0))
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 tracing::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(ip_addr) {
311 Ok(result) => match result.decode::<maxminddb::geoip2::City>() {
312 Ok(Some(city)) => {
313 let location = &city.location;
314 if let (Some(lat), Some(lon)) = (location.latitude, location.longitude) {
315 tracing::debug!("MaxMind lookup for {}: lat={}, lon={}", ip, lat, lon);
316 return Some((lat, lon));
317 }
318 tracing::debug!("MaxMind lookup for {} returned no coordinates", ip);
319 None
320 }
321 Ok(None) => {
322 tracing::debug!("MaxMind lookup returned no data for {}", ip);
323 None
324 }
325 Err(e) => {
326 tracing::debug!("MaxMind lookup failed for {}: {}", ip, e);
327 None
328 }
329 },
330 Err(e) => {
331 tracing::debug!("MaxMind lookup failed for {}: {}", ip, e);
332 None
333 }
334 },
335 Err(e) => {
336 tracing::warn!("Failed to open MaxMind database: {}", e);
337 None
338 }
339 }
340 }
341}
342
343pub struct UserAgentUtils;
345
346impl UserAgentUtils {
347 pub fn calculate_similarity(ua1: &str, ua2: &str) -> f32 {
350 if ua1 == ua2 {
351 return 1.0;
352 }
353
354 let len1 = ua1.len();
356 let len2 = ua2.len();
357
358 if len1 == 0 && len2 == 0 {
359 return 1.0;
360 }
361 if len1 == 0 || len2 == 0 {
362 return 0.0;
363 }
364
365 let mut matrix = vec![vec![0; len2 + 1]; len1 + 1];
367
368 for (i, row) in matrix.iter_mut().enumerate().take(len1 + 1) {
369 row[0] = i;
370 }
371 for (j, cell) in matrix[0].iter_mut().enumerate() {
372 *cell = j;
373 }
374 let ua1_chars: Vec<char> = ua1.chars().collect();
375 let ua2_chars: Vec<char> = ua2.chars().collect();
376
377 for i in 1..=len1 {
378 for j in 1..=len2 {
379 let cost = if ua1_chars[i - 1] == ua2_chars[j - 1] {
380 0
381 } else {
382 1
383 };
384 matrix[i][j] = (matrix[i - 1][j] + 1)
385 .min(matrix[i][j - 1] + 1)
386 .min(matrix[i - 1][j - 1] + cost);
387 }
388 }
389
390 let distance = matrix[len1][len2];
391 let max_len = len1.max(len2);
392
393 1.0 - (distance as f32 / max_len as f32)
394 }
395
396 pub fn is_suspicious_change(original: &str, current: &str, threshold: f32) -> bool {
398 let similarity = Self::calculate_similarity(original, current);
399 similarity < threshold
400 }
401}
402
403#[cfg(test)]
404mod tests {
405 use super::*;
406
407 #[test]
408 fn test_session_security_config_presets() {
409 let strict = SessionSecurityConfig::strict();
410 assert!(strict.enforce_ip_consistency);
411 assert_eq!(strict.max_session_lifetime_hours, 8);
412
413 let lenient = SessionSecurityConfig::lenient();
414 assert!(!lenient.enforce_ip_consistency);
415 assert_eq!(lenient.max_session_lifetime_hours, 72);
416
417 let balanced = SessionSecurityConfig::balanced();
418 assert!(!balanced.enforce_ip_consistency);
419 assert!(balanced.auto_rotate_on_suspicious_activity);
420 }
421
422 #[test]
423 fn test_ip_subnet_checking() {
424 assert!(IPSecurityUtils::same_subnet(
426 "192.168.1.1",
427 "192.168.1.2",
428 24
429 ));
430 assert!(!IPSecurityUtils::same_subnet(
431 "192.168.1.1",
432 "192.168.2.1",
433 24
434 ));
435 assert!(IPSecurityUtils::same_subnet("10.0.0.1", "10.0.0.255", 24));
436
437 assert!(IPSecurityUtils::same_subnet(
439 "2001:db8::1",
440 "2001:db8::2",
441 64
442 ));
443 assert!(!IPSecurityUtils::same_subnet(
444 "2001:db8::1",
445 "2001:db9::1",
446 64
447 ));
448 }
449
450 #[test]
451 fn test_user_agent_similarity() {
452 let ua1 = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36";
453 let ua2 = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.37";
454 let ua3 = "Chrome/91.0.4472.124 Safari/537.36";
455
456 let similarity1 = UserAgentUtils::calculate_similarity(ua1, ua2);
458 assert!(similarity1 > 0.8);
459
460 let similarity2 = UserAgentUtils::calculate_similarity(ua1, ua3);
462 assert!(similarity2 < 0.5);
463
464 let similarity3 = UserAgentUtils::calculate_similarity(ua1, ua1);
466 assert_eq!(similarity3, 1.0);
467 }
468
469 #[test]
470 fn test_suspicious_user_agent_detection() {
471 let original = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36";
472 let minor_change = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.37";
473 let major_change = "curl/7.68.0";
474
475 assert!(!UserAgentUtils::is_suspicious_change(
476 original,
477 minor_change,
478 0.8
479 ));
480 assert!(UserAgentUtils::is_suspicious_change(
481 original,
482 major_change,
483 0.8
484 ));
485 }
486
487 #[test]
488 fn test_security_validation_result_enum() {
489 let valid = SessionValidationResult::Valid;
490 let suspicious =
491 SessionValidationResult::Suspicious(vec![SecurityThreat::IPAddressCompromised {
492 original: "192.168.1.1".to_string(),
493 current: "10.0.0.1".to_string(),
494 distance_km: Some(100.0),
495 }]);
496
497 match valid {
498 SessionValidationResult::Valid => {
499 }
501 _ => panic!("Expected valid session validation"),
502 }
503
504 match suspicious {
505 SessionValidationResult::Suspicious(threats) => {
506 assert_eq!(threats.len(), 1);
507 }
508 _ => panic!("Expected suspicious session validation"),
509 }
510 }
511}