1use aes_gcm::{
19 aead::{Aead, KeyInit},
20 Aes256Gcm, Nonce,
21};
22use async_trait::async_trait;
23use chrono::{DateTime, Utc};
24use rand::Rng;
25use serde::{Deserialize, Serialize};
26use std::collections::HashMap;
27use thiserror::Error;
28use tracing::info;
29use uuid::Uuid;
30
31use uvb_core::{TenantId, UserId};
32
33#[derive(Debug, Error)]
35pub enum PushError {
36 #[error("Push provider error: {0}")]
37 Provider(String),
38
39 #[error("Encryption failed: {0}")]
40 Encryption(String),
41
42 #[error("Decryption failed: {0}")]
43 Decryption(String),
44
45 #[error("Challenge not found: {0}")]
46 ChallengeNotFound(String),
47
48 #[error("Challenge expired: {0}")]
49 ChallengeExpired(String),
50
51 #[error("Invalid number match")]
52 InvalidNumberMatch,
53
54 #[error("Device token not found")]
55 DeviceTokenNotFound,
56}
57
58#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
60pub enum PushProvider {
61 Firebase,
62 ApnsPush,
63 OneSignal,
64 Custom,
65}
66
67#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
69#[allow(non_camel_case_types)]
70pub enum DevicePlatform {
71 iOS,
72 Android,
73 Web,
74}
75
76#[derive(Clone, Debug, Serialize, Deserialize)]
78pub struct PushChallenge {
79 pub challenge_id: String,
81
82 pub user_id: UserId,
84
85 pub tenant_id: TenantId,
87
88 pub number_match_code: Option<String>,
90
91 pub context: PushContext,
93
94 pub created_at: DateTime<Utc>,
96
97 pub expires_at: DateTime<Utc>,
99
100 pub is_approved: bool,
102
103 pub responded_at: Option<DateTime<Utc>>,
105
106 pub response: Option<PushResponse>,
108}
109
110#[derive(Clone, Debug, Serialize, Deserialize)]
112pub struct PushContext {
113 pub device_info: DeviceInfo,
115
116 pub location: LocationInfo,
118
119 pub ip_address: String,
121
122 pub timestamp: DateTime<Utc>,
124
125 pub app_name: String,
127
128 pub operation: String,
130
131 pub recent_activity: Vec<ActivitySummary>,
133}
134
135#[derive(Clone, Debug, Serialize, Deserialize)]
137pub struct DeviceInfo {
138 pub platform: DevicePlatform,
140
141 pub os_version: Option<String>,
143
144 pub browser: Option<String>,
146
147 pub app_version: Option<String>,
149
150 pub device_model: Option<String>,
152}
153
154#[derive(Clone, Debug, Serialize, Deserialize)]
156pub struct LocationInfo {
157 pub city: Option<String>,
159
160 pub country: Option<String>,
162
163 pub coordinates: Option<(f64, f64)>,
165}
166
167#[derive(Clone, Debug, Serialize, Deserialize)]
169pub struct ActivitySummary {
170 pub activity_type: String,
172
173 pub timestamp: DateTime<Utc>,
175
176 pub location: Option<String>,
178}
179
180#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
182pub enum PushResponse {
183 Approved,
184 Denied,
185 Timeout,
186}
187
188#[derive(Clone, Debug, Serialize, Deserialize)]
190pub struct EncryptedPushPayload {
191 pub challenge_id: String,
193
194 pub encrypted_context: Vec<u8>,
196
197 pub nonce: Vec<u8>,
199
200 pub encrypted_at: DateTime<Utc>,
202}
203
204#[derive(Clone, Debug, Serialize, Deserialize)]
206pub struct PushNotificationContent {
207 pub title: String,
209
210 pub body: String,
212
213 pub number_match: Option<String>,
215
216 pub data: HashMap<String, String>,
218}
219
220impl PushNotificationContent {
221 pub fn from_challenge(challenge: &PushChallenge) -> Self {
223 let location_str = if let Some(ref city) = challenge.context.location.city {
224 if let Some(ref country) = challenge.context.location.country {
225 format!("{}, {}", city, country)
226 } else {
227 city.clone()
228 }
229 } else {
230 "Unknown location".to_string()
231 };
232
233 let body = format!(
234 "Login attempt from {} at {}. Device: {:?}, IP: {}",
235 location_str,
236 challenge.context.timestamp.format("%Y-%m-%d %H:%M UTC"),
237 challenge.context.device_info.platform,
238 challenge.context.ip_address
239 );
240
241 let mut data = HashMap::new();
242 data.insert("challenge_id".to_string(), challenge.challenge_id.clone());
243 data.insert("app_name".to_string(), challenge.context.app_name.clone());
244 data.insert("operation".to_string(), challenge.context.operation.clone());
245
246 Self {
247 title: format!("Authentication Request - {}", challenge.context.app_name),
248 body,
249 number_match: challenge.number_match_code.clone(),
250 data,
251 }
252 }
253}
254
255#[derive(Clone, Debug, Serialize, Deserialize)]
257pub struct PushNotificationConfig {
258 pub enabled: bool,
260
261 pub provider: PushProvider,
263
264 pub enable_encryption: bool,
266
267 pub enable_number_matching: bool,
269
270 pub number_match_length: usize,
272
273 pub challenge_timeout_secs: i64,
275
276 pub include_recent_activity: bool,
278
279 pub max_recent_activities: usize,
281
282 pub never_include_otp: bool,
284
285 pub require_client_cert: bool,
287}
288
289impl Default for PushNotificationConfig {
290 fn default() -> Self {
291 Self {
292 enabled: true,
293 provider: PushProvider::Firebase,
294 enable_encryption: true,
295 enable_number_matching: true,
296 number_match_length: 3,
297 challenge_timeout_secs: 60, include_recent_activity: true,
299 max_recent_activities: 3,
300 never_include_otp: true, require_client_cert: true,
302 }
303 }
304}
305
306#[async_trait]
308pub trait PushNotificationProvider: Send + Sync {
309 async fn send_push(
311 &self,
312 device_token: &str,
313 content: &PushNotificationContent,
314 ) -> Result<String, PushError>; async fn get_delivery_status(&self, message_id: &str) -> Result<DeliveryStatus, PushError>;
318}
319
320#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
322pub enum DeliveryStatus {
323 Pending,
324 Delivered,
325 Failed,
326 Expired,
327}
328
329#[async_trait]
331pub trait PushChallengeStorage: Send + Sync {
332 async fn save_challenge(&self, challenge: &PushChallenge) -> Result<(), PushError>;
334
335 async fn get_challenge(&self, challenge_id: &str) -> Result<Option<PushChallenge>, PushError>;
337
338 async fn update_response(
340 &self,
341 challenge_id: &str,
342 response: PushResponse,
343 ) -> Result<(), PushError>;
344
345 async fn get_device_token(
347 &self,
348 user_id: &UserId,
349 platform: DevicePlatform,
350 ) -> Result<Option<String>, PushError>;
351}
352
353pub struct InMemoryPushStorage {
355 challenges: tokio::sync::RwLock<HashMap<String, PushChallenge>>,
356 device_tokens: tokio::sync::RwLock<HashMap<(UserId, DevicePlatform), String>>,
357}
358
359impl InMemoryPushStorage {
360 pub fn new() -> Self {
361 Self {
362 challenges: tokio::sync::RwLock::new(HashMap::new()),
363 device_tokens: tokio::sync::RwLock::new(HashMap::new()),
364 }
365 }
366}
367
368impl Default for InMemoryPushStorage {
369 fn default() -> Self {
370 Self::new()
371 }
372}
373
374#[async_trait]
375impl PushChallengeStorage for InMemoryPushStorage {
376 async fn save_challenge(&self, challenge: &PushChallenge) -> Result<(), PushError> {
377 let mut challenges = self.challenges.write().await;
378 challenges.insert(challenge.challenge_id.clone(), challenge.clone());
379 Ok(())
380 }
381
382 async fn get_challenge(&self, challenge_id: &str) -> Result<Option<PushChallenge>, PushError> {
383 let challenges = self.challenges.read().await;
384 Ok(challenges.get(challenge_id).cloned())
385 }
386
387 async fn update_response(
388 &self,
389 challenge_id: &str,
390 response: PushResponse,
391 ) -> Result<(), PushError> {
392 let mut challenges = self.challenges.write().await;
393 if let Some(challenge) = challenges.get_mut(challenge_id) {
394 challenge.is_approved = matches!(response, PushResponse::Approved);
395 challenge.response = Some(response);
396 challenge.responded_at = Some(Utc::now());
397 }
398 Ok(())
399 }
400
401 async fn get_device_token(
402 &self,
403 user_id: &UserId,
404 platform: DevicePlatform,
405 ) -> Result<Option<String>, PushError> {
406 let tokens = self.device_tokens.read().await;
407 Ok(tokens.get(&(user_id.clone(), platform)).cloned())
408 }
409}
410
411pub struct PushNotificationManager<S: PushChallengeStorage, P: PushNotificationProvider> {
413 storage: S,
414 provider: P,
415 config: PushNotificationConfig,
416 encryption_key: Option<Aes256Gcm>,
417}
418
419impl<S: PushChallengeStorage, P: PushNotificationProvider> PushNotificationManager<S, P> {
420 pub fn new(
422 storage: S,
423 provider: P,
424 config: PushNotificationConfig,
425 encryption_key: Option<&[u8; 32]>,
426 ) -> Self {
427 let cipher = if config.enable_encryption {
428 encryption_key.map(|key| Aes256Gcm::new(key.into()))
429 } else {
430 None
431 };
432
433 Self {
434 storage,
435 provider,
436 config,
437 encryption_key: cipher,
438 }
439 }
440
441 pub async fn create_challenge(
443 &self,
444 user_id: UserId,
445 tenant_id: TenantId,
446 context: PushContext,
447 ) -> Result<PushChallenge, PushError> {
448 let challenge_id = Uuid::new_v4().to_string();
449
450 let number_match_code = if self.config.enable_number_matching {
452 Some(self.generate_number_match_code())
453 } else {
454 None
455 };
456
457 let challenge = PushChallenge {
458 challenge_id,
459 user_id: user_id.clone(),
460 tenant_id,
461 number_match_code,
462 context,
463 created_at: Utc::now(),
464 expires_at: Utc::now() + chrono::Duration::seconds(self.config.challenge_timeout_secs),
465 is_approved: false,
466 responded_at: None,
467 response: None,
468 };
469
470 self.storage.save_challenge(&challenge).await?;
471
472 info!(
473 "Created push challenge {} for user {:?}",
474 challenge.challenge_id, user_id
475 );
476
477 Ok(challenge)
478 }
479
480 pub async fn send_push_notification(
482 &self,
483 challenge: &PushChallenge,
484 platform: DevicePlatform,
485 ) -> Result<String, PushError> {
486 let device_token = self
488 .storage
489 .get_device_token(&challenge.user_id, platform)
490 .await?
491 .ok_or(PushError::DeviceTokenNotFound)?;
492
493 let content = PushNotificationContent::from_challenge(challenge);
495
496 if self.config.never_include_otp {
498 }
501
502 let message_id = self.provider.send_push(&device_token, &content).await?;
504
505 info!(
506 "Sent push notification {} for challenge {}",
507 message_id, challenge.challenge_id
508 );
509
510 Ok(message_id)
511 }
512
513 pub async fn verify_number_match(
515 &self,
516 challenge_id: &str,
517 provided_code: &str,
518 ) -> Result<bool, PushError> {
519 let challenge = self
520 .storage
521 .get_challenge(challenge_id)
522 .await?
523 .ok_or_else(|| PushError::ChallengeNotFound(challenge_id.to_string()))?;
524
525 if Utc::now() > challenge.expires_at {
527 return Err(PushError::ChallengeExpired(
528 challenge.expires_at.to_string(),
529 ));
530 }
531
532 if let Some(ref expected_code) = challenge.number_match_code {
534 if expected_code == provided_code {
535 self.storage
537 .update_response(challenge_id, PushResponse::Approved)
538 .await?;
539
540 info!("Number match verified for challenge {}", challenge_id);
541 return Ok(true);
542 }
543 }
544
545 Ok(false)
546 }
547
548 pub async fn respond_to_challenge(
550 &self,
551 challenge_id: &str,
552 response: PushResponse,
553 ) -> Result<(), PushError> {
554 let challenge = self
555 .storage
556 .get_challenge(challenge_id)
557 .await?
558 .ok_or_else(|| PushError::ChallengeNotFound(challenge_id.to_string()))?;
559
560 if Utc::now() > challenge.expires_at {
562 return Err(PushError::ChallengeExpired(
563 challenge.expires_at.to_string(),
564 ));
565 }
566
567 self.storage.update_response(challenge_id, response).await?;
568
569 info!("Challenge {} responded: {:?}", challenge_id, response);
570
571 Ok(())
572 }
573
574 fn generate_number_match_code(&self) -> String {
576 let mut rng = rand::thread_rng();
577 let code: u32 = rng.gen_range(0..10_u32.pow(self.config.number_match_length as u32));
578 format!("{:0width$}", code, width = self.config.number_match_length)
579 }
580
581 pub fn encrypt_payload(
583 &self,
584 challenge: &PushChallenge,
585 ) -> Result<EncryptedPushPayload, PushError> {
586 let cipher = self
587 .encryption_key
588 .as_ref()
589 .ok_or_else(|| PushError::Encryption("Encryption not enabled".to_string()))?;
590
591 let plaintext = serde_json::to_vec(&challenge.context)
592 .map_err(|e| PushError::Encryption(e.to_string()))?;
593
594 let mut nonce_bytes = [0u8; 12];
595 rand::thread_rng().fill(&mut nonce_bytes);
596 let nonce = Nonce::from_slice(&nonce_bytes);
597
598 let ciphertext = cipher
599 .encrypt(nonce, plaintext.as_ref())
600 .map_err(|e| PushError::Encryption(e.to_string()))?;
601
602 Ok(EncryptedPushPayload {
603 challenge_id: challenge.challenge_id.clone(),
604 encrypted_context: ciphertext,
605 nonce: nonce_bytes.to_vec(),
606 encrypted_at: Utc::now(),
607 })
608 }
609}
610
611#[cfg(test)]
612mod tests {
613 use super::*;
614
615 #[test]
616 fn test_number_match_generation() {
617 let config = PushNotificationConfig::default();
618 let storage = InMemoryPushStorage::new();
619 let provider = MockPushProvider::new();
620 let manager = PushNotificationManager::new(storage, provider, config, None);
621
622 let code = manager.generate_number_match_code();
623 assert_eq!(code.len(), 3);
624 assert!(code.chars().all(|c| c.is_ascii_digit()));
625 }
626
627 #[tokio::test]
628 async fn test_challenge_creation() {
629 let storage = InMemoryPushStorage::new();
630 let provider = MockPushProvider::new();
631 let config = PushNotificationConfig::default();
632 let manager = PushNotificationManager::new(storage, provider, config, None);
633
634 let context = PushContext {
635 device_info: DeviceInfo {
636 platform: DevicePlatform::iOS,
637 os_version: Some("15.0".to_string()),
638 browser: None,
639 app_version: Some("1.0".to_string()),
640 device_model: Some("iPhone 13".to_string()),
641 },
642 location: LocationInfo {
643 city: Some("San Francisco".to_string()),
644 country: Some("USA".to_string()),
645 coordinates: None,
646 },
647 ip_address: "192.0.2.1".to_string(),
648 timestamp: Utc::now(),
649 app_name: "MyApp".to_string(),
650 operation: "login".to_string(),
651 recent_activity: vec![],
652 };
653
654 let challenge = manager
655 .create_challenge(
656 UserId::new("test-user"),
657 TenantId::new("test-tenant"),
658 context,
659 )
660 .await
661 .unwrap();
662
663 assert!(challenge.number_match_code.is_some());
664 assert!(!challenge.is_approved);
665 }
666
667 pub struct MockPushProvider;
669
670 impl MockPushProvider {
671 pub fn new() -> Self {
672 Self
673 }
674 }
675
676 impl Default for MockPushProvider {
677 fn default() -> Self {
678 Self::new()
679 }
680 }
681
682 #[async_trait]
683 impl PushNotificationProvider for MockPushProvider {
684 async fn send_push(
685 &self,
686 _device_token: &str,
687 _content: &PushNotificationContent,
688 ) -> Result<String, PushError> {
689 Ok(Uuid::new_v4().to_string())
690 }
691
692 async fn get_delivery_status(
693 &self,
694 _message_id: &str,
695 ) -> Result<DeliveryStatus, PushError> {
696 Ok(DeliveryStatus::Delivered)
697 }
698 }
699}