Skip to main content

auth_framework/security/
secure_session_config.rs

1//! Enhanced Session Security Configuration
2//!
3//! This module provides configurable session security policies to prevent
4//! session hijacking and implement defense-in-depth strategies.
5
6use serde::{Deserialize, Serialize};
7use std::net::IpAddr;
8use std::str::FromStr;
9
10/// Security configuration for session management
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct SessionSecurityConfig {
13    /// Whether to enforce strict IP consistency for sessions
14    pub enforce_ip_consistency: bool,
15
16    /// Allow IP changes within the same subnet/range
17    pub allow_ip_range_changes: bool,
18
19    /// Maximum allowed User-Agent deviation (0.0 = exact match, 1.0 = any)
20    pub max_user_agent_deviation: f32,
21
22    /// Whether to automatically rotate session IDs on security events
23    pub auto_rotate_on_suspicious_activity: bool,
24
25    /// Maximum session lifetime before forced re-authentication
26    pub max_session_lifetime_hours: u64,
27
28    /// Require periodic session validation
29    pub require_periodic_validation: bool,
30
31    /// Period between validation checks (in minutes)
32    pub validation_period_minutes: u64,
33
34    /// Enable device fingerprinting for additional security
35    pub enable_device_fingerprinting: bool,
36
37    /// Maximum number of concurrent sessions per user
38    pub max_concurrent_sessions: Option<usize>,
39
40    /// Geographic location validation
41    pub enable_geo_validation: bool,
42
43    /// Maximum allowed distance for geographic changes (in km)
44    pub max_geo_distance_km: Option<f64>,
45}
46
47impl Default for SessionSecurityConfig {
48    fn default() -> Self {
49        Self {
50            enforce_ip_consistency: false, // Default to warn-only for compatibility
51            allow_ip_range_changes: true,
52            max_user_agent_deviation: 0.1, // Allow minor browser updates
53            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, // Privacy consideration
58            max_concurrent_sessions: Some(5),
59            enable_geo_validation: false, // May require external service
60            max_geo_distance_km: Some(1000.0), // 1000km threshold
61        }
62    }
63}
64
65impl SessionSecurityConfig {
66    /// Create a strict security configuration for high-security environments
67    pub fn strict() -> Self {
68        Self {
69            enforce_ip_consistency: true,
70            allow_ip_range_changes: false,
71            max_user_agent_deviation: 0.05, // Very strict UA validation
72            auto_rotate_on_suspicious_activity: true,
73            max_session_lifetime_hours: 8, // 8-hour workday
74            require_periodic_validation: true,
75            validation_period_minutes: 15, // More frequent validation
76            enable_device_fingerprinting: true,
77            max_concurrent_sessions: Some(2), // Limit concurrent sessions
78            enable_geo_validation: true,
79            max_geo_distance_km: Some(100.0), // 100km threshold
80        }
81    }
82
83    /// Create a lenient configuration for development/testing
84    pub fn lenient() -> Self {
85        Self {
86            enforce_ip_consistency: false,
87            allow_ip_range_changes: true,
88            max_user_agent_deviation: 0.5, // Allow significant UA changes
89            auto_rotate_on_suspicious_activity: false,
90            max_session_lifetime_hours: 72, // 3 days
91            require_periodic_validation: false,
92            validation_period_minutes: 120, // 2 hours
93            enable_device_fingerprinting: false,
94            max_concurrent_sessions: Some(10),
95            enable_geo_validation: false,
96            max_geo_distance_km: None, // No geo restrictions
97        }
98    }
99
100    /// Create a balanced configuration for production use
101    pub fn balanced() -> Self {
102        Self::default()
103    }
104}
105
106/// Security validation result for session checks
107#[derive(Debug, Clone, PartialEq)]
108pub enum SessionValidationResult {
109    /// Session is valid and secure
110    Valid,
111    /// Session is valid but has security warnings
112    ValidWithWarnings(Vec<SecurityWarning>),
113    /// Session is suspicious and should be investigated
114    Suspicious(Vec<SecurityThreat>),
115    /// Session is compromised and should be terminated
116    Compromised(Vec<SecurityThreat>),
117}
118
119/// Security warning indicators
120#[derive(Debug, Clone, PartialEq)]
121pub enum SecurityWarning {
122    /// IP address changed but within allowed parameters
123    IPAddressChanged {
124        original: String,
125        current: String,
126        subnet_match: bool,
127    },
128    /// User agent changed slightly
129    UserAgentChanged {
130        original: String,
131        current: String,
132        similarity: f32,
133    },
134    /// Session is approaching maximum lifetime
135    SessionNearExpiry { hours_remaining: u64 },
136    /// Unusual activity pattern detected
137    UnusualActivity { description: String },
138}
139
140/// Security threat indicators
141#[derive(Debug, Clone, PartialEq)]
142pub enum SecurityThreat {
143    /// IP address changed beyond allowed parameters
144    IPAddressCompromised {
145        original: String,
146        current: String,
147        distance_km: Option<f64>,
148    },
149    /// User agent changed significantly
150    UserAgentCompromised {
151        original: String,
152        current: String,
153        similarity: f32,
154    },
155    /// Session exceeded maximum lifetime
156    SessionExpired { hours_exceeded: u64 },
157    /// Device fingerprint mismatch
158    DeviceFingerprintMismatch { original: String, current: String },
159    /// Geographic location impossible
160    ImpossibleGeography {
161        original_location: Option<String>,
162        current_location: Option<String>,
163        distance_km: f64,
164        time_seconds: u64,
165    },
166    /// Too many concurrent sessions
167    ConcurrentSessionLimitExceeded {
168        current_count: usize,
169        max_allowed: usize,
170    },
171}
172
173/// IP address utilities for session security
174pub struct IPSecurityUtils;
175
176impl IPSecurityUtils {
177    /// Check if two IP addresses are in the same subnet
178    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, // Different IP versions
190        }
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        // Check full bytes
222        if ip1_bytes[..full_bytes] != ip2_bytes[..full_bytes] {
223            return false;
224        }
225
226        // Check remaining bits if any
227        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    /// Estimate geographic distance between IP addresses
238    /// Uses simplified geolocation based on IP prefix patterns
239    pub fn estimate_distance_km(ip1: &str, ip2: &str) -> Option<f64> {
240        // Extract country/region indicators from IP patterns
241        let location1 = Self::estimate_ip_location(ip1)?;
242        let location2 = Self::estimate_ip_location(ip2)?;
243
244        // Calculate approximate distance using haversine formula
245        Some(Self::calculate_haversine_distance(location1, location2))
246    }
247
248    /// Estimate approximate location from IP address patterns
249    fn estimate_ip_location(ip: &str) -> Option<(f64, f64)> {
250        // Basic geolocation based on IP ranges (simplified)
251        if ip.starts_with("192.168.") || ip.starts_with("10.") || ip.starts_with("172.") {
252            // Private/local networks - assume same location
253            Some((0.0, 0.0))
254        } else if ip.starts_with("8.8.") || ip.starts_with("1.1.") {
255            // Public DNS servers - US approximate
256            Some((39.0458, -76.6413)) // US East Coast
257        } else {
258            // Real MaxMind GeoIP2 integration for accurate geolocation
259            Self::lookup_maxmind_coordinates(ip).or_else(|| {
260                // Unknown location fallback — (0,0) means "no data"
261                Some((0.0, 0.0))
262            })
263        }
264    }
265
266    /// Calculate distance between two coordinates using haversine formula
267    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    /// Lookup IP coordinates using MaxMind GeoIP2 database
286    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        // Path to MaxMind GeoLite2-City.mmdb (configurable via environment)
292        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        // Parse IP address
304        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
343/// User-Agent similarity calculation utilities
344pub struct UserAgentUtils;
345
346impl UserAgentUtils {
347    /// Calculate similarity between two user agent strings
348    /// Returns value between 0.0 (completely different) and 1.0 (identical)
349    pub fn calculate_similarity(ua1: &str, ua2: &str) -> f32 {
350        if ua1 == ua2 {
351            return 1.0;
352        }
353
354        // Use character-level similarity for better results
355        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        // Simple Levenshtein distance calculation
366        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    /// Check if user agent change is suspicious
397    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        // IPv4 subnet tests
425        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        // IPv6 subnet tests
438        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        // Very similar user agents
457        let similarity1 = UserAgentUtils::calculate_similarity(ua1, ua2);
458        assert!(similarity1 > 0.8);
459
460        // Different user agents
461        let similarity2 = UserAgentUtils::calculate_similarity(ua1, ua3);
462        assert!(similarity2 < 0.5);
463
464        // Identical user agents
465        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                // Test passed - validation correctly identified as valid
500            }
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}