Skip to main content

synapse_pingora/geo/
types.rs

1//! Type definitions for geographic analysis and impossible travel detection.
2//!
3//! Provides types for tracking user logins across geographic locations
4//! and detecting account takeover attempts via impossible travel patterns.
5
6use serde::{Deserialize, Serialize};
7
8// ============================================================================
9// Configuration Constants
10// ============================================================================
11
12/// Default maximum speed (km/h) before flagging as impossible.
13/// Commercial jets cruise at ~900 km/h, so 1000 km/h catches unrealistic travel.
14pub const DEFAULT_MAX_SPEED_KMH: f64 = 1000.0;
15
16/// Default minimum distance (km) to consider for travel analysis.
17/// Short distances are skipped to avoid false positives from GeoIP inaccuracy.
18pub const DEFAULT_MIN_DISTANCE_KM: f64 = 50.0;
19
20/// Default history window (hours) to retain login events.
21pub const DEFAULT_HISTORY_WINDOW_HOURS: f64 = 24.0;
22
23/// Default maximum login history entries per user.
24pub const DEFAULT_MAX_HISTORY_PER_USER: usize = 10;
25
26/// Earth's mean radius in kilometers for haversine calculations.
27pub const EARTH_RADIUS_KM: f64 = 6371.0;
28
29// ============================================================================
30// Geographic Location
31// ============================================================================
32
33/// Geographic location from GeoIP lookup.
34#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
35pub struct GeoLocation {
36    /// IP address that was resolved.
37    pub ip: String,
38    /// Latitude in degrees (-90 to 90).
39    pub latitude: f64,
40    /// Longitude in degrees (-180 to 180).
41    pub longitude: f64,
42    /// City name if available.
43    pub city: Option<String>,
44    /// Full country name.
45    pub country: String,
46    /// ISO 3166-1 alpha-2 country code (e.g., "US", "GB").
47    pub country_code: String,
48    /// GeoIP accuracy radius in kilometers.
49    pub accuracy_radius_km: u32,
50}
51
52impl GeoLocation {
53    /// Create a new GeoLocation with required fields.
54    pub fn new(
55        ip: impl Into<String>,
56        latitude: f64,
57        longitude: f64,
58        country: impl Into<String>,
59        country_code: impl Into<String>,
60    ) -> Self {
61        Self {
62            ip: ip.into(),
63            latitude,
64            longitude,
65            city: None,
66            country: country.into(),
67            country_code: country_code.into(),
68            accuracy_radius_km: 50, // Default accuracy
69        }
70    }
71
72    /// Create with full details including city.
73    pub fn with_city(mut self, city: impl Into<String>) -> Self {
74        self.city = Some(city.into());
75        self
76    }
77
78    /// Set accuracy radius.
79    pub fn with_accuracy(mut self, accuracy_km: u32) -> Self {
80        self.accuracy_radius_km = accuracy_km;
81        self
82    }
83}
84
85// ============================================================================
86// Login Event
87// ============================================================================
88
89/// Login event for travel analysis.
90///
91/// Represents a user authentication attempt at a specific location and time.
92#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct LoginEvent {
94    /// User identifier (user ID, email, or session subject).
95    pub user_id: String,
96    /// Unix timestamp in milliseconds.
97    pub timestamp_ms: u64,
98    /// Geographic location of the login.
99    pub location: GeoLocation,
100    /// Whether the login was successful.
101    pub success: bool,
102    /// Optional device/client fingerprint for correlation.
103    pub device_fingerprint: Option<String>,
104}
105
106impl LoginEvent {
107    /// Create a new login event.
108    pub fn new(user_id: impl Into<String>, timestamp_ms: u64, location: GeoLocation) -> Self {
109        Self {
110            user_id: user_id.into(),
111            timestamp_ms,
112            location,
113            success: true,
114            device_fingerprint: None,
115        }
116    }
117
118    /// Set success status.
119    pub fn with_success(mut self, success: bool) -> Self {
120        self.success = success;
121        self
122    }
123
124    /// Set device fingerprint.
125    pub fn with_fingerprint(mut self, fingerprint: impl Into<String>) -> Self {
126        self.device_fingerprint = Some(fingerprint.into());
127        self
128    }
129}
130
131// ============================================================================
132// Severity
133// ============================================================================
134
135/// Severity level for impossible travel alerts.
136///
137/// Ordered from least to most severe based on how impossible the travel is.
138#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
139#[serde(rename_all = "lowercase")]
140pub enum Severity {
141    /// Above threshold but borderline (1000-2000 km/h).
142    Low,
143    /// Cross-country impossible speed (2000-5000 km/h).
144    Medium,
145    /// Intercontinental in minutes (5000-10000 km/h).
146    High,
147    /// Effectively teleportation (>10000 km/h or instant).
148    Critical,
149}
150
151impl Severity {
152    /// Convert to string representation.
153    pub fn as_str(&self) -> &'static str {
154        match self {
155            Severity::Low => "low",
156            Severity::Medium => "medium",
157            Severity::High => "high",
158            Severity::Critical => "critical",
159        }
160    }
161}
162
163impl std::fmt::Display for Severity {
164    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
165        write!(f, "{}", self.as_str())
166    }
167}
168
169// ============================================================================
170// Travel Alert
171// ============================================================================
172
173/// Alert generated when impossible travel is detected.
174///
175/// Contains full context about the suspicious travel pattern including
176/// source/destination locations, timing, and confidence metrics.
177#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct TravelAlert {
179    /// User ID that triggered the alert.
180    pub user_id: String,
181    /// Severity based on how impossible the travel is.
182    pub severity: Severity,
183    /// Location of the previous login.
184    pub from_location: GeoLocation,
185    /// Timestamp of the previous login (Unix ms).
186    pub from_time: u64,
187    /// Location of the current login.
188    pub to_location: GeoLocation,
189    /// Timestamp of the current login (Unix ms).
190    pub to_time: u64,
191    /// Great-circle distance between locations in kilometers.
192    pub distance_km: f64,
193    /// Time difference in hours.
194    pub time_diff_hours: f64,
195    /// Speed required to make the trip (km/h), -1.0 for instant.
196    pub required_speed_kmh: f64,
197    /// Confidence score (0.0 to 1.0) based on accuracy and context.
198    pub confidence: f64,
199}
200
201impl TravelAlert {
202    /// Create a new travel alert with all context.
203    #[allow(clippy::too_many_arguments)]
204    pub fn new(
205        user_id: impl Into<String>,
206        severity: Severity,
207        from_location: GeoLocation,
208        from_time: u64,
209        to_location: GeoLocation,
210        to_time: u64,
211        distance_km: f64,
212        time_diff_hours: f64,
213        required_speed_kmh: f64,
214        confidence: f64,
215    ) -> Self {
216        Self {
217            user_id: user_id.into(),
218            severity,
219            from_location,
220            from_time,
221            to_location,
222            to_time,
223            distance_km,
224            time_diff_hours,
225            required_speed_kmh,
226            confidence,
227        }
228    }
229
230    /// Check if this is a high-severity alert (High or Critical).
231    pub fn is_high_severity(&self) -> bool {
232        matches!(self.severity, Severity::High | Severity::Critical)
233    }
234}
235
236// ============================================================================
237// Travel Configuration
238// ============================================================================
239
240/// Configuration for impossible travel detection.
241#[derive(Debug, Clone, Serialize, Deserialize)]
242pub struct TravelConfig {
243    /// Maximum speed (km/h) before flagging as impossible.
244    pub max_speed_kmh: f64,
245    /// Minimum distance (km) to analyze (skip short distances).
246    pub min_distance_km: f64,
247    /// History window in milliseconds.
248    pub history_window_ms: u64,
249    /// Maximum history entries per user.
250    pub max_history_per_user: usize,
251}
252
253impl Default for TravelConfig {
254    fn default() -> Self {
255        Self {
256            max_speed_kmh: DEFAULT_MAX_SPEED_KMH,
257            min_distance_km: DEFAULT_MIN_DISTANCE_KM,
258            history_window_ms: (DEFAULT_HISTORY_WINDOW_HOURS * 3600.0 * 1000.0) as u64,
259            max_history_per_user: DEFAULT_MAX_HISTORY_PER_USER,
260        }
261    }
262}
263
264impl TravelConfig {
265    /// Create a new configuration with custom values.
266    pub fn new(
267        max_speed_kmh: f64,
268        min_distance_km: f64,
269        history_window_hours: f64,
270        max_history_per_user: usize,
271    ) -> Self {
272        Self {
273            max_speed_kmh,
274            min_distance_km,
275            history_window_ms: (history_window_hours * 3600.0 * 1000.0) as u64,
276            max_history_per_user,
277        }
278    }
279
280    /// Get history window in hours.
281    pub fn history_window_hours(&self) -> f64 {
282        self.history_window_ms as f64 / (3600.0 * 1000.0)
283    }
284}
285
286// ============================================================================
287// Statistics
288// ============================================================================
289
290/// Statistics for the impossible travel detector.
291#[derive(Debug, Clone, Default, Serialize, Deserialize)]
292pub struct TravelStats {
293    /// Number of users currently being tracked.
294    pub tracked_users: u32,
295    /// Total login events processed.
296    pub total_logins: u64,
297    /// Total alerts generated.
298    pub alerts_generated: u64,
299    /// Number of whitelisted routes (user-specific).
300    pub whitelist_routes: u32,
301}
302
303// ============================================================================
304// Tests
305// ============================================================================
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    #[test]
312    fn test_geo_location_builder() {
313        let loc = GeoLocation::new("1.2.3.4", 40.7128, -74.0060, "United States", "US")
314            .with_city("New York")
315            .with_accuracy(10);
316
317        assert_eq!(loc.ip, "1.2.3.4");
318        assert_eq!(loc.latitude, 40.7128);
319        assert_eq!(loc.longitude, -74.0060);
320        assert_eq!(loc.city, Some("New York".to_string()));
321        assert_eq!(loc.country_code, "US");
322        assert_eq!(loc.accuracy_radius_km, 10);
323    }
324
325    #[test]
326    fn test_login_event_builder() {
327        let loc = GeoLocation::new("1.2.3.4", 40.7128, -74.0060, "United States", "US");
328        let event = LoginEvent::new("user123", 1000000, loc)
329            .with_success(false)
330            .with_fingerprint("fp-abc123");
331
332        assert_eq!(event.user_id, "user123");
333        assert!(!event.success);
334        assert_eq!(event.device_fingerprint, Some("fp-abc123".to_string()));
335    }
336
337    #[test]
338    fn test_severity_ordering() {
339        assert!(Severity::Low < Severity::Medium);
340        assert!(Severity::Medium < Severity::High);
341        assert!(Severity::High < Severity::Critical);
342    }
343
344    #[test]
345    fn test_severity_display() {
346        assert_eq!(Severity::Low.to_string(), "low");
347        assert_eq!(Severity::Critical.to_string(), "critical");
348    }
349
350    #[test]
351    fn test_travel_config_default() {
352        let config = TravelConfig::default();
353        assert_eq!(config.max_speed_kmh, 1000.0);
354        assert_eq!(config.min_distance_km, 50.0);
355        assert_eq!(config.max_history_per_user, 10);
356        assert!((config.history_window_hours() - 24.0).abs() < 0.001);
357    }
358
359    #[test]
360    fn test_travel_alert_high_severity() {
361        let from = GeoLocation::new("1.1.1.1", 0.0, 0.0, "X", "XX");
362        let to = GeoLocation::new("2.2.2.2", 10.0, 10.0, "Y", "YY");
363
364        let alert = TravelAlert::new(
365            "user1",
366            Severity::High,
367            from.clone(),
368            1000,
369            to.clone(),
370            2000,
371            1000.0,
372            0.5,
373            5000.0,
374            0.9,
375        );
376
377        assert!(alert.is_high_severity());
378
379        let low_alert = TravelAlert::new(
380            "user2",
381            Severity::Low,
382            from,
383            1000,
384            to,
385            2000,
386            100.0,
387            1.0,
388            1100.0,
389            0.6,
390        );
391
392        assert!(!low_alert.is_high_severity());
393    }
394}