Skip to main content

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 ring::rand::SecureRandom;
6use subtle::ConstantTimeEq;
7use serde::{Deserialize, Serialize};
8use std::sync::Arc;
9use std::time::Duration;
10use tracing::{debug, error, info, warn};
11use uuid::Uuid;
12
13/// SMSKit configuration for AuthFramework
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct SmsKitConfig {
16    /// Primary SMS provider
17    pub provider: SmsKitProvider,
18    /// Provider-specific configuration
19    pub config: SmsKitProviderConfig,
20    /// Fallback provider (optional)
21    pub fallback_provider: Option<SmsKitProvider>,
22    /// Fallback configuration (optional)
23    pub fallback_config: Option<SmsKitProviderConfig>,
24    /// Webhook configuration for delivery status
25    pub webhook_config: Option<WebhookConfig>,
26    /// Rate limiting configuration
27    pub rate_limiting: RateLimitConfig,
28}
29
30impl Default for SmsKitConfig {
31    fn default() -> Self {
32        Self {
33            provider: SmsKitProvider::Development,
34            config: SmsKitProviderConfig::Development,
35            fallback_provider: None,
36            fallback_config: None,
37            webhook_config: None,
38            rate_limiting: RateLimitConfig::default(),
39        }
40    }
41}
42
43/// Supported SMSKit providers
44///
45/// `Plivo` and `AwsSns` require the `smskit` feature flag and their respective
46/// SDK crates (`sms-plivo`, `sms-aws-sns`). When selected without the feature
47/// enabled, `send_sms_with_fallback` returns a descriptive error at runtime.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub enum SmsKitProvider {
50    Twilio,
51    /// Requires `smskit` feature and `sms-plivo` crate.
52    Plivo,
53    /// Requires `smskit` feature and `sms-aws-sns` crate.
54    AwsSns,
55    Development,
56}
57
58/// Provider-specific configuration
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub enum SmsKitProviderConfig {
61    Twilio {
62        account_sid: String,
63        auth_token: String,
64        from_number: String,
65        webhook_url: Option<String>,
66    },
67    Plivo {
68        auth_id: String,
69        auth_token: String,
70        from_number: String,
71        webhook_url: Option<String>,
72    },
73    AwsSns {
74        region: String,
75        access_key_id: String,
76        secret_access_key: String,
77    },
78    Development,
79}
80
81/// Webhook configuration for SMS delivery status
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct WebhookConfig {
84    pub endpoint_url: String,
85    pub webhook_secret: String,
86    pub track_delivery: bool,
87    pub track_clicks: bool,
88}
89
90/// Rate limiting configuration
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct RateLimitConfig {
93    pub max_per_hour: u32,
94    pub max_per_day: u32,
95    pub cooldown_seconds: u64,
96}
97
98impl Default for RateLimitConfig {
99    fn default() -> Self {
100        Self {
101            max_per_hour: 10,
102            max_per_day: 20,
103            cooldown_seconds: 60,
104        }
105    }
106}
107
108/// Enhanced SMS manager powered by SMSKit
109pub struct SmsKitManager {
110    storage: Arc<dyn AuthStorage>,
111    config: SmsKitConfig,
112}
113
114impl SmsKitManager {
115    /// Create a new SMSKit manager with default configuration
116    pub fn new(storage: Arc<dyn AuthStorage>) -> Self {
117        Self {
118            storage,
119            config: SmsKitConfig::default(),
120        }
121    }
122
123    /// Create a new SMSKit manager with custom configuration
124    pub fn new_with_config(storage: Arc<dyn AuthStorage>, config: SmsKitConfig) -> Result<Self> {
125        let manager = Self { storage, config };
126        Ok(manager)
127    }
128
129    /// Register phone number for SMS MFA
130    pub async fn register_phone_number(&self, user_id: &str, phone_number: &str) -> Result<()> {
131        debug!("Registering phone number for user '{}' via SMSKit", user_id);
132
133        if phone_number.is_empty() {
134            return Err(AuthError::validation("Phone number cannot be empty"));
135        }
136
137        if !phone_number.starts_with('+') || phone_number.len() < 10 {
138            return Err(AuthError::validation(
139                "Phone number must be in international format (+1234567890)",
140            ));
141        }
142
143        let digits = &phone_number[1..];
144        if !digits.chars().all(|c| c.is_ascii_digit()) {
145            return Err(AuthError::validation(
146                "Phone number must contain only digits after the + sign",
147            ));
148        }
149
150        if digits.len() > 15 || digits.len() < 7 {
151            return Err(AuthError::validation(
152                "Phone number must be between 7 and 15 digits (E.164 format)",
153            ));
154        }
155
156        let key = format!("user:{}:phone", user_id);
157        self.storage
158            .store_kv(&key, phone_number.as_bytes(), None)
159            .await?;
160
161        info!(
162            "Phone number registered for user '{}': {} (SMSKit enabled)",
163            user_id, phone_number
164        );
165
166        Ok(())
167    }
168
169    /// Initiate SMS challenge with rate limiting
170    pub async fn initiate_challenge(&self, user_id: &str) -> Result<String> {
171        debug!("Initiating SMS challenge for user '{}' via SMSKit", user_id);
172
173        if user_id.is_empty() {
174            return Err(AuthError::validation("User ID cannot be empty"));
175        }
176
177        self.check_rate_limits(user_id).await?;
178
179        let challenge_id = crate::utils::string::generate_id(Some("smskit"));
180
181        info!("SMS challenge initiated for user '{}' via SMSKit", user_id);
182        Ok(challenge_id)
183    }
184
185    async fn check_rate_limits(&self, user_id: &str) -> Result<()> {
186        let now = chrono::Utc::now().timestamp();
187        let hour_ago = now - 3600;
188        let day_ago = now - 86400;
189
190        let hourly_key = format!("smskit:{}:hourly", user_id);
191        let hourly_count = self.get_sms_count(&hourly_key, hour_ago).await?;
192        if hourly_count >= self.config.rate_limiting.max_per_hour {
193            return Err(AuthError::rate_limited("SMS hourly limit exceeded"));
194        }
195
196        let daily_key = format!("smskit:{}:daily", user_id);
197        let daily_count = self.get_sms_count(&daily_key, day_ago).await?;
198        if daily_count >= self.config.rate_limiting.max_per_day {
199            return Err(AuthError::rate_limited("SMS daily limit exceeded"));
200        }
201
202        let last_sent_key = format!("smskit:{}:last_sent", user_id);
203        if let Some(last_sent_data) = self.storage.get_kv(&last_sent_key).await?
204            && let Ok(last_sent_str) = std::str::from_utf8(&last_sent_data)
205            && let Ok(last_sent) = last_sent_str.parse::<i64>()
206        {
207            let elapsed = now - last_sent;
208            if elapsed < self.config.rate_limiting.cooldown_seconds as i64 {
209                let remaining = self.config.rate_limiting.cooldown_seconds as i64 - elapsed;
210                return Err(AuthError::rate_limited(format!(
211                    "SMS cooldown active. Please wait {} seconds",
212                    remaining
213                )));
214            }
215        }
216
217        Ok(())
218    }
219
220    async fn get_sms_count(&self, key: &str, _since: i64) -> Result<u32> {
221        if let Some(count_data) = self.storage.get_kv(key).await?
222            && let Ok(count_str) = std::str::from_utf8(&count_data)
223            && let Ok(count) = count_str.parse::<u32>()
224        {
225            return Ok(count);
226        }
227        Ok(0)
228    }
229
230    /// Generate SMS verification code
231    pub async fn generate_code(&self, challenge_id: &str) -> Result<String> {
232        debug!(
233            "Generating SMS code for challenge '{}' via SMSKit",
234            challenge_id
235        );
236
237        let rng = ring::rand::SystemRandom::new();
238        let mut buf = [0u8; 4];
239        rng.fill(&mut buf).expect("system RNG failure");
240        let val = u32::from_le_bytes(buf) % 1_000_000;
241        let code = format!("{:06}", val);
242
243        let sms_key = format!("smskit_challenge:{}:code", challenge_id);
244        self.storage
245            .store_kv(&sms_key, code.as_bytes(), Some(Duration::from_secs(300)))
246            .await?;
247
248        Ok(code)
249    }
250
251    /// Verify SMS code
252    pub async fn verify_code(&self, challenge_id: &str, code: &str) -> Result<bool> {
253        debug!(
254            "Verifying SMS code for challenge '{}' via SMSKit",
255            challenge_id
256        );
257
258        if challenge_id.is_empty() {
259            return Err(AuthError::validation("Challenge ID cannot be empty"));
260        }
261
262        if code.is_empty() {
263            return Err(AuthError::validation("SMS code cannot be empty"));
264        }
265
266        if code.len() != 6 || !code.chars().all(|c| c.is_ascii_digit()) {
267            return Ok(false);
268        }
269
270        let sms_key = format!("smskit_challenge:{}:code", challenge_id);
271        if let Some(stored_code_data) = self.storage.get_kv(&sms_key).await? {
272            let stored_code = std::str::from_utf8(&stored_code_data).unwrap_or("");
273            let is_valid: bool = stored_code.as_bytes().ct_eq(code.as_bytes()).into();
274
275            if is_valid {
276                let _ = self.storage.delete_kv(&sms_key).await;
277            }
278
279            Ok(is_valid)
280        } else {
281            Err(AuthError::validation("Invalid or expired challenge ID"))
282        }
283    }
284
285    /// Send SMS code using SMSKit with fallback support
286    pub async fn send_code(&self, user_id: &str, code: &str) -> Result<()> {
287        debug!("Sending SMS code to user '{}' via SMSKit", user_id);
288
289        let phone_key = format!("user:{}:phone", user_id);
290        let phone_number = if let Some(phone_data) = self.storage.get_kv(&phone_key).await? {
291            String::from_utf8(phone_data)
292                .map_err(|e| AuthError::internal(format!("Failed to parse phone number: {}", e)))?
293        } else {
294            return Err(AuthError::validation("No phone number registered for user"));
295        };
296
297        self.check_rate_limits(user_id).await?;
298
299        let message = format!(
300            "Your verification code is: {}. This code expires in 5 minutes. Do not share this code with anyone.",
301            code
302        );
303
304        match self.send_sms_with_fallback(&phone_number, &message).await {
305            Ok(message_id) => {
306                info!(
307                    "SMS code sent successfully to user '{}' (Message ID: {})",
308                    user_id, message_id
309                );
310                self.update_rate_limits(user_id).await?;
311                Ok(())
312            }
313            Err(e) => {
314                error!("Failed to send SMS to user '{}': {}", user_id, e);
315                Err(e)
316            }
317        }
318    }
319
320    async fn send_sms_with_fallback(&self, phone_number: &str, message: &str) -> Result<String> {
321        let result = match &self.config.provider {
322            SmsKitProvider::Twilio => self.send_via_twilio(phone_number, message).await,
323            SmsKitProvider::Plivo => self.send_via_plivo(phone_number, message).await,
324            SmsKitProvider::AwsSns => self.send_via_aws_sns(phone_number, message).await,
325            SmsKitProvider::Development => {
326                info!("📱 [SMSKit Development] SMS sent to: {}", phone_number);
327                info!("   Message: {}", message);
328                Ok(format!("dev_msg_{}", chrono::Utc::now().timestamp()))
329            }
330        };
331
332        // On failure, try fallback provider if configured
333        match result {
334            Ok(msg_id) => Ok(msg_id),
335            Err(primary_err) => {
336                if let Some(fallback_provider) = &self.config.fallback_provider {
337                    warn!(
338                        "Primary SMS provider failed ({}), trying fallback: {:?}",
339                        primary_err, fallback_provider
340                    );
341                    match fallback_provider {
342                        SmsKitProvider::Development => {
343                            info!(
344                                "📱 [SMSKit Development Fallback] SMS sent to: {}",
345                                phone_number
346                            );
347                            info!("   Message: {}", message);
348                            Ok(format!(
349                                "dev_fallback_msg_{}",
350                                chrono::Utc::now().timestamp()
351                            ))
352                        }
353                        _ => Err(primary_err),
354                    }
355                } else {
356                    Err(primary_err)
357                }
358            }
359        }
360    }
361
362    /// Send SMS via Twilio using sms-twilio crate
363    #[cfg(feature = "smskit")]
364    async fn send_via_twilio(&self, phone_number: &str, message: &str) -> Result<String> {
365        use sms_core::SmsClient;
366
367        let (client, from_number) = if let SmsKitProviderConfig::Twilio {
368            account_sid,
369            auth_token,
370            from_number,
371            ..
372        } = &self.config.config
373        {
374            if account_sid.is_empty() || auth_token.is_empty() || from_number.is_empty() {
375                return Err(AuthError::internal("Twilio credentials are incomplete"));
376            }
377            (
378                sms_twilio::TwilioClient::new(account_sid, auth_token),
379                from_number.clone(),
380            )
381        } else {
382            let from_number = std::env::var("TWILIO_FROM_NUMBER").map_err(|_| {
383                AuthError::internal("Twilio from number not configured: set TWILIO_FROM_NUMBER")
384            })?;
385            let c = sms_twilio::TwilioClient::from_env()
386                .map_err(|e| AuthError::internal(format!("Twilio env config failed: {}", e)))?;
387            (c, from_number)
388        };
389
390        let request = sms_core::SendRequest {
391            to: phone_number,
392            from: &from_number,
393            text: message,
394        };
395
396        let response = client
397            .send(request)
398            .await
399            .map_err(|e| AuthError::internal(format!("Twilio SMS send failed: {}", e)))?;
400
401        debug!("Twilio SMS sent successfully, ID: {}", response.id);
402        Ok(response.id)
403    }
404
405    #[cfg(not(feature = "smskit"))]
406    async fn send_via_twilio(&self, _phone_number: &str, _message: &str) -> Result<String> {
407        Err(AuthError::internal(
408            "Twilio SMS requires the 'smskit' feature flag to be enabled",
409        ))
410    }
411
412    /// Send SMS via Plivo using sms-plivo crate
413    #[cfg(feature = "smskit")]
414    async fn send_via_plivo(&self, phone_number: &str, message: &str) -> Result<String> {
415        use sms_core::SmsClient;
416
417        let (client, from_number) = if let SmsKitProviderConfig::Plivo {
418            auth_id,
419            auth_token,
420            from_number,
421            ..
422        } = &self.config.config
423        {
424            if auth_id.is_empty() || auth_token.is_empty() || from_number.is_empty() {
425                return Err(AuthError::internal("Plivo credentials are incomplete"));
426            }
427            (
428                sms_plivo::PlivoClient::new(auth_id, auth_token),
429                from_number.clone(),
430            )
431        } else {
432            let from_number = std::env::var("PLIVO_FROM_NUMBER").map_err(|_| {
433                AuthError::internal("Plivo from number not configured: set PLIVO_FROM_NUMBER")
434            })?;
435            let c = sms_plivo::PlivoClient::from_env()
436                .map_err(|e| AuthError::internal(format!("Plivo env config failed: {}", e)))?;
437            (c, from_number)
438        };
439
440        let request = sms_core::SendRequest {
441            to: phone_number,
442            from: &from_number,
443            text: message,
444        };
445
446        let response = client
447            .send(request)
448            .await
449            .map_err(|e| AuthError::internal(format!("Plivo SMS send failed: {}", e)))?;
450
451        debug!("Plivo SMS sent successfully, ID: {}", response.id);
452        Ok(response.id)
453    }
454
455    #[cfg(not(feature = "smskit"))]
456    async fn send_via_plivo(&self, _phone_number: &str, _message: &str) -> Result<String> {
457        Err(AuthError::internal(
458            "Plivo SMS requires the 'smskit' feature flag to be enabled",
459        ))
460    }
461
462    /// Send SMS via AWS SNS using sms-aws-sns crate
463    #[cfg(feature = "smskit")]
464    async fn send_via_aws_sns(&self, phone_number: &str, message: &str) -> Result<String> {
465        use sms_core::SmsClient;
466
467        let client = if let SmsKitProviderConfig::AwsSns {
468            region,
469            access_key_id,
470            secret_access_key,
471        } = &self.config.config
472        {
473            if access_key_id.is_empty() || secret_access_key.is_empty() {
474                return Err(AuthError::internal("AWS credentials are incomplete"));
475            }
476            sms_aws_sns::AwsSnsClient::new(region, access_key_id, secret_access_key)
477        } else {
478            sms_aws_sns::AwsSnsClient::from_env()
479                .map_err(|e| AuthError::internal(format!("AWS SNS env config failed: {}", e)))?
480        };
481
482        let request = sms_core::SendRequest {
483            to: phone_number,
484            from: "",
485            text: message,
486        };
487
488        let response = client
489            .send(request)
490            .await
491            .map_err(|e| AuthError::internal(format!("AWS SNS SMS send failed: {}", e)))?;
492
493        debug!("AWS SNS SMS sent successfully, ID: {}", response.id);
494        Ok(response.id)
495    }
496
497    #[cfg(not(feature = "smskit"))]
498    async fn send_via_aws_sns(&self, _phone_number: &str, _message: &str) -> Result<String> {
499        Err(AuthError::internal(
500            "AWS SNS SMS requires the 'smskit' feature flag to be enabled",
501        ))
502    }
503
504    async fn update_rate_limits(&self, user_id: &str) -> Result<()> {
505        let now = chrono::Utc::now().timestamp();
506
507        let hourly_key = format!("smskit:{}:hourly", user_id);
508        let hourly_count = self.get_sms_count(&hourly_key, now - 3600).await? + 1;
509        self.storage
510            .store_kv(
511                &hourly_key,
512                hourly_count.to_string().as_bytes(),
513                Some(Duration::from_secs(3600)),
514            )
515            .await?;
516
517        let daily_key = format!("smskit:{}:daily", user_id);
518        let daily_count = self.get_sms_count(&daily_key, now - 86400).await? + 1;
519        self.storage
520            .store_kv(
521                &daily_key,
522                daily_count.to_string().as_bytes(),
523                Some(Duration::from_secs(86400)),
524            )
525            .await?;
526
527        let last_sent_key = format!("smskit:{}:last_sent", user_id);
528        self.storage
529            .store_kv(
530                &last_sent_key,
531                now.to_string().as_bytes(),
532                Some(Duration::from_secs(
533                    self.config.rate_limiting.cooldown_seconds,
534                )),
535            )
536            .await?;
537
538        Ok(())
539    }
540
541    /// Get user's phone number
542    pub async fn get_user_phone(&self, user_id: &str) -> Result<Option<String>> {
543        let phone_key = format!("user:{}:phone", user_id);
544
545        if let Some(phone_data) = self.storage.get_kv(&phone_key).await? {
546            Ok(Some(String::from_utf8(phone_data).map_err(|e| {
547                AuthError::internal(format!("Failed to parse phone number: {}", e))
548            })?))
549        } else {
550            Ok(None)
551        }
552    }
553
554    /// Check if user has phone number configured
555    pub async fn has_phone_number(&self, user_id: &str) -> Result<bool> {
556        let key = format!("user:{}:phone", user_id);
557        match self.storage.get_kv(&key).await {
558            Ok(Some(_)) => Ok(true),
559            Ok(None) => Ok(false),
560            Err(_) => Ok(false), // Assume false on error
561        }
562    }
563
564    /// Send verification code and return the generated code
565    pub async fn send_verification_code(&self, user_id: &str) -> Result<String> {
566        // Generate a 6-digit code
567        let code = format!("{:06}", rand::random::<u32>() % 1_000_000);
568
569        // Send the code via SMS
570        self.send_code(user_id, &code).await?;
571
572        // Store the code for later verification
573        let code_key = format!("sms_verification:{}:{}", user_id, Uuid::new_v4());
574        self.storage
575            .store_kv(
576                &code_key,
577                code.as_bytes(),
578                Some(std::time::Duration::from_secs(300)),
579            )
580            .await?;
581
582        Ok(code)
583    }
584}