auth_framework/auth_modular/mfa/
sms_kit.rs

1//! Next-generation SMS MFA manager powered by SMSKit
2
3use crate::errors::{AuthError, Result};
4use crate::storage::AuthStorage;
5use rand::Rng;
6use serde::{Deserialize, Serialize};
7use std::sync::Arc;
8use std::time::Duration;
9use tracing::{debug, error, info};
10use uuid::Uuid;
11
12/// SMSKit configuration for AuthFramework
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct SmsKitConfig {
15    /// Primary SMS provider
16    pub provider: SmsKitProvider,
17    /// Provider-specific configuration
18    pub config: SmsKitProviderConfig,
19    /// Fallback provider (optional)
20    pub fallback_provider: Option<SmsKitProvider>,
21    /// Fallback configuration (optional)
22    pub fallback_config: Option<SmsKitProviderConfig>,
23    /// Webhook configuration for delivery status
24    pub webhook_config: Option<WebhookConfig>,
25    /// Rate limiting configuration
26    pub rate_limiting: RateLimitConfig,
27}
28
29impl Default for SmsKitConfig {
30    fn default() -> Self {
31        Self {
32            provider: SmsKitProvider::Development,
33            config: SmsKitProviderConfig::Development,
34            fallback_provider: None,
35            fallback_config: None,
36            webhook_config: None,
37            rate_limiting: RateLimitConfig::default(),
38        }
39    }
40}
41
42/// Supported SMSKit providers
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub enum SmsKitProvider {
45    Twilio,
46    Plivo,
47    AwsSns,
48    Development,
49}
50
51/// Provider-specific configuration
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub enum SmsKitProviderConfig {
54    Twilio {
55        account_sid: String,
56        auth_token: String,
57        from_number: String,
58        webhook_url: Option<String>,
59    },
60    Plivo {
61        auth_id: String,
62        auth_token: String,
63        from_number: String,
64        webhook_url: Option<String>,
65    },
66    AwsSns {
67        region: String,
68        access_key_id: String,
69        secret_access_key: String,
70    },
71    Development,
72}
73
74/// Webhook configuration for SMS delivery status
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct WebhookConfig {
77    pub endpoint_url: String,
78    pub webhook_secret: String,
79    pub track_delivery: bool,
80    pub track_clicks: bool,
81}
82
83/// Rate limiting configuration
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct RateLimitConfig {
86    pub max_per_hour: u32,
87    pub max_per_day: u32,
88    pub cooldown_seconds: u64,
89}
90
91impl Default for RateLimitConfig {
92    fn default() -> Self {
93        Self {
94            max_per_hour: 10,
95            max_per_day: 20,
96            cooldown_seconds: 60,
97        }
98    }
99}
100
101/// Enhanced SMS manager powered by SMSKit
102pub struct SmsKitManager {
103    storage: Arc<dyn AuthStorage>,
104    config: SmsKitConfig,
105}
106
107impl SmsKitManager {
108    /// Create a new SMSKit manager with default configuration
109    pub fn new(storage: Arc<dyn AuthStorage>) -> Self {
110        Self {
111            storage,
112            config: SmsKitConfig::default(),
113        }
114    }
115
116    /// Create a new SMSKit manager with custom configuration
117    pub fn new_with_config(storage: Arc<dyn AuthStorage>, config: SmsKitConfig) -> Result<Self> {
118        let manager = Self { storage, config };
119        Ok(manager)
120    }
121
122    /// Register phone number for SMS MFA
123    pub async fn register_phone_number(&self, user_id: &str, phone_number: &str) -> Result<()> {
124        debug!("Registering phone number for user '{}' via SMSKit", user_id);
125
126        if phone_number.is_empty() {
127            return Err(AuthError::validation("Phone number cannot be empty"));
128        }
129
130        if !phone_number.starts_with('+') || phone_number.len() < 10 {
131            return Err(AuthError::validation(
132                "Phone number must be in international format (+1234567890)",
133            ));
134        }
135
136        let digits = &phone_number[1..];
137        if !digits.chars().all(|c| c.is_ascii_digit()) {
138            return Err(AuthError::validation(
139                "Phone number must contain only digits after the + sign",
140            ));
141        }
142
143        if digits.len() > 15 || digits.len() < 7 {
144            return Err(AuthError::validation(
145                "Phone number must be between 7 and 15 digits (E.164 format)",
146            ));
147        }
148
149        let key = format!("user:{}:phone", user_id);
150        self.storage
151            .store_kv(&key, phone_number.as_bytes(), None)
152            .await?;
153
154        info!(
155            "Phone number registered for user '{}': {} (SMSKit enabled)",
156            user_id, phone_number
157        );
158
159        Ok(())
160    }
161
162    /// Initiate SMS challenge with rate limiting
163    pub async fn initiate_challenge(&self, user_id: &str) -> Result<String> {
164        debug!("Initiating SMS challenge for user '{}' via SMSKit", user_id);
165
166        if user_id.is_empty() {
167            return Err(AuthError::validation("User ID cannot be empty"));
168        }
169
170        self.check_rate_limits(user_id).await?;
171
172        let challenge_id = crate::utils::string::generate_id(Some("smskit"));
173
174        info!("SMS challenge initiated for user '{}' via SMSKit", user_id);
175        Ok(challenge_id)
176    }
177
178    async fn check_rate_limits(&self, user_id: &str) -> Result<()> {
179        let now = chrono::Utc::now().timestamp();
180        let hour_ago = now - 3600;
181        let day_ago = now - 86400;
182
183        let hourly_key = format!("smskit:{}:hourly", user_id);
184        let hourly_count = self.get_sms_count(&hourly_key, hour_ago).await?;
185        if hourly_count >= self.config.rate_limiting.max_per_hour {
186            return Err(AuthError::rate_limited("SMS hourly limit exceeded"));
187        }
188
189        let daily_key = format!("smskit:{}:daily", user_id);
190        let daily_count = self.get_sms_count(&daily_key, day_ago).await?;
191        if daily_count >= self.config.rate_limiting.max_per_day {
192            return Err(AuthError::rate_limited("SMS daily limit exceeded"));
193        }
194
195        let last_sent_key = format!("smskit:{}:last_sent", user_id);
196        if let Some(last_sent_data) = self.storage.get_kv(&last_sent_key).await?
197            && let Ok(last_sent_str) = std::str::from_utf8(&last_sent_data)
198            && let Ok(last_sent) = last_sent_str.parse::<i64>()
199        {
200            let elapsed = now - last_sent;
201            if elapsed < self.config.rate_limiting.cooldown_seconds as i64 {
202                let remaining = self.config.rate_limiting.cooldown_seconds as i64 - elapsed;
203                return Err(AuthError::rate_limited(format!(
204                    "SMS cooldown active. Please wait {} seconds",
205                    remaining
206                )));
207            }
208        }
209
210        Ok(())
211    }
212
213    async fn get_sms_count(&self, key: &str, _since: i64) -> Result<u32> {
214        if let Some(count_data) = self.storage.get_kv(key).await?
215            && let Ok(count_str) = std::str::from_utf8(&count_data)
216            && let Ok(count) = count_str.parse::<u32>()
217        {
218            return Ok(count);
219        }
220        Ok(0)
221    }
222
223    /// Generate SMS verification code
224    pub async fn generate_code(&self, challenge_id: &str) -> Result<String> {
225        debug!(
226            "Generating SMS code for challenge '{}' via SMSKit",
227            challenge_id
228        );
229
230        let code = format!("{:06}", rand::rng().random_range(0..1000000));
231
232        let sms_key = format!("smskit_challenge:{}:code", challenge_id);
233        self.storage
234            .store_kv(&sms_key, code.as_bytes(), Some(Duration::from_secs(300)))
235            .await?;
236
237        Ok(code)
238    }
239
240    /// Verify SMS code
241    pub async fn verify_code(&self, challenge_id: &str, code: &str) -> Result<bool> {
242        debug!(
243            "Verifying SMS code for challenge '{}' via SMSKit",
244            challenge_id
245        );
246
247        if challenge_id.is_empty() {
248            return Err(AuthError::validation("Challenge ID cannot be empty"));
249        }
250
251        if code.is_empty() {
252            return Err(AuthError::validation("SMS code cannot be empty"));
253        }
254
255        if code.len() != 6 || !code.chars().all(|c| c.is_ascii_digit()) {
256            return Ok(false);
257        }
258
259        let sms_key = format!("smskit_challenge:{}:code", challenge_id);
260        if let Some(stored_code_data) = self.storage.get_kv(&sms_key).await? {
261            let stored_code = std::str::from_utf8(&stored_code_data).unwrap_or("");
262            let is_valid = stored_code == code;
263
264            if is_valid {
265                let _ = self.storage.delete_kv(&sms_key).await;
266            }
267
268            Ok(is_valid)
269        } else {
270            Err(AuthError::validation("Invalid or expired challenge ID"))
271        }
272    }
273
274    /// Send SMS code using SMSKit with fallback support
275    pub async fn send_code(&self, user_id: &str, code: &str) -> Result<()> {
276        debug!("Sending SMS code to user '{}' via SMSKit", user_id);
277
278        let phone_key = format!("user:{}:phone", user_id);
279        let phone_number = if let Some(phone_data) = self.storage.get_kv(&phone_key).await? {
280            String::from_utf8(phone_data)
281                .map_err(|e| AuthError::internal(format!("Failed to parse phone number: {}", e)))?
282        } else {
283            return Err(AuthError::validation("No phone number registered for user"));
284        };
285
286        self.check_rate_limits(user_id).await?;
287
288        let message = format!(
289            "Your verification code is: {}. This code expires in 5 minutes. Do not share this code with anyone.",
290            code
291        );
292
293        match self.send_sms_with_fallback(&phone_number, &message).await {
294            Ok(message_id) => {
295                info!(
296                    "SMS code sent successfully to user '{}' (Message ID: {})",
297                    user_id, message_id
298                );
299                self.update_rate_limits(user_id).await?;
300                Ok(())
301            }
302            Err(e) => {
303                error!("Failed to send SMS to user '{}': {}", user_id, e);
304                Err(e)
305            }
306        }
307    }
308
309    async fn send_sms_with_fallback(&self, phone_number: &str, message: &str) -> Result<String> {
310        match &self.config.provider {
311            SmsKitProvider::Twilio => {
312                info!("📱 [SMSKit Twilio] SMS would be sent to: {}", phone_number);
313            }
314            SmsKitProvider::Plivo => {
315                info!("📱 [SMSKit Plivo] SMS would be sent to: {}", phone_number);
316            }
317            SmsKitProvider::AwsSns => {
318                info!("📱 [SMSKit AWS SNS] SMS would be sent to: {}", phone_number);
319            }
320            SmsKitProvider::Development => {
321                info!(
322                    "📱 [SMSKit Development] SMS would be sent to: {}",
323                    phone_number
324                );
325                info!("   Message: {}", message);
326            }
327        }
328
329        if let Some(fallback_provider) = &self.config.fallback_provider {
330            info!("   Fallback provider configured: {:?}", fallback_provider);
331        }
332
333        tokio::time::sleep(Duration::from_millis(100)).await;
334        Ok(format!("smskit_msg_{}", chrono::Utc::now().timestamp()))
335    }
336
337    async fn update_rate_limits(&self, user_id: &str) -> Result<()> {
338        let now = chrono::Utc::now().timestamp();
339
340        let hourly_key = format!("smskit:{}:hourly", user_id);
341        let hourly_count = self.get_sms_count(&hourly_key, now - 3600).await? + 1;
342        self.storage
343            .store_kv(
344                &hourly_key,
345                hourly_count.to_string().as_bytes(),
346                Some(Duration::from_secs(3600)),
347            )
348            .await?;
349
350        let daily_key = format!("smskit:{}:daily", user_id);
351        let daily_count = self.get_sms_count(&daily_key, now - 86400).await? + 1;
352        self.storage
353            .store_kv(
354                &daily_key,
355                daily_count.to_string().as_bytes(),
356                Some(Duration::from_secs(86400)),
357            )
358            .await?;
359
360        let last_sent_key = format!("smskit:{}:last_sent", user_id);
361        self.storage
362            .store_kv(
363                &last_sent_key,
364                now.to_string().as_bytes(),
365                Some(Duration::from_secs(
366                    self.config.rate_limiting.cooldown_seconds,
367                )),
368            )
369            .await?;
370
371        Ok(())
372    }
373
374    /// Get user's phone number
375    pub async fn get_user_phone(&self, user_id: &str) -> Result<Option<String>> {
376        let phone_key = format!("user:{}:phone", user_id);
377
378        if let Some(phone_data) = self.storage.get_kv(&phone_key).await? {
379            Ok(Some(String::from_utf8(phone_data).map_err(|e| {
380                AuthError::internal(format!("Failed to parse phone number: {}", e))
381            })?))
382        } else {
383            Ok(None)
384        }
385    }
386
387    /// Check if user has phone number configured
388    pub async fn has_phone_number(&self, user_id: &str) -> Result<bool> {
389        let key = format!("user:{}:phone", user_id);
390        match self.storage.get_kv(&key).await {
391            Ok(Some(_)) => Ok(true),
392            Ok(None) => Ok(false),
393            Err(_) => Ok(false), // Assume false on error
394        }
395    }
396
397    /// Send verification code and return the generated code
398    pub async fn send_verification_code(&self, user_id: &str) -> Result<String> {
399        // Generate a 6-digit code
400        let code = format!("{:06}", rand::random::<u32>() % 1_000_000);
401
402        // Send the code via SMS
403        self.send_code(user_id, &code).await?;
404
405        // Store the code for later verification
406        let code_key = format!("sms_verification:{}:{}", user_id, Uuid::new_v4());
407        self.storage
408            .store_kv(
409                &code_key,
410                code.as_bytes(),
411                Some(std::time::Duration::from_secs(300)),
412            )
413            .await?;
414
415        Ok(code)
416    }
417}