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                // Default fallback coordinates (NYC)
261                Some((40.7128, -74.0060))
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            log::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::<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
338/// User-Agent similarity calculation utilities
339pub struct UserAgentUtils;
340
341impl UserAgentUtils {
342    /// Calculate similarity between two user agent strings
343    /// Returns value between 0.0 (completely different) and 1.0 (identical)
344    pub fn calculate_similarity(ua1: &str, ua2: &str) -> f32 {
345        if ua1 == ua2 {
346            return 1.0;
347        }
348
349        // Use character-level similarity for better results
350        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        // Simple Levenshtein distance calculation
361        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    /// Check if user agent change is suspicious
392    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        // IPv4 subnet tests
420        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        // IPv6 subnet tests
433        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        // Very similar user agents
452        let similarity1 = UserAgentUtils::calculate_similarity(ua1, ua2);
453        assert!(similarity1 > 0.8);
454
455        // Different user agents
456        let similarity2 = UserAgentUtils::calculate_similarity(ua1, ua3);
457        assert!(similarity2 < 0.5);
458
459        // Identical user agents
460        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                // Test passed - validation correctly identified as valid
495            }
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}