auth_framework/auth_modular/mfa/
sms_kit.rs1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct SmsKitConfig {
15 pub provider: SmsKitProvider,
17 pub config: SmsKitProviderConfig,
19 pub fallback_provider: Option<SmsKitProvider>,
21 pub fallback_config: Option<SmsKitProviderConfig>,
23 pub webhook_config: Option<WebhookConfig>,
25 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#[derive(Debug, Clone, Serialize, Deserialize)]
44pub enum SmsKitProvider {
45 Twilio,
46 Plivo,
47 AwsSns,
48 Development,
49}
50
51#[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#[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#[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
101pub struct SmsKitManager {
103 storage: Arc<dyn AuthStorage>,
104 config: SmsKitConfig,
105}
106
107impl SmsKitManager {
108 pub fn new(storage: Arc<dyn AuthStorage>) -> Self {
110 Self {
111 storage,
112 config: SmsKitConfig::default(),
113 }
114 }
115
116 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 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 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 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 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 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 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 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), }
395 }
396
397 pub async fn send_verification_code(&self, user_id: &str) -> Result<String> {
399 let code = format!("{:06}", rand::random::<u32>() % 1_000_000);
401
402 self.send_code(user_id, &code).await?;
404
405 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}