1use std::collections::HashMap;
22use std::sync::Mutex;
23
24use serde::{Deserialize, Serialize};
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct PhoneCode {
29 pub phone: String,
30 pub code: String,
31 pub issued_at: u64,
36 pub expires_at: u64,
37 pub attempts: u32,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum PhoneCodeError {
42 NotFound,
43 Expired,
44 BadCode,
45 TooManyAttempts,
46 Throttled { retry_after_secs: u64 },
47 InvalidPhone,
48}
49
50impl std::fmt::Display for PhoneCodeError {
51 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52 match self {
53 Self::NotFound => f.write_str("no pending code for this phone"),
54 Self::Expired => f.write_str("code expired"),
55 Self::BadCode => f.write_str("wrong code"),
56 Self::TooManyAttempts => f.write_str("too many failed attempts; request a new code"),
57 Self::Throttled { retry_after_secs } => {
58 write!(f, "wait {retry_after_secs}s before requesting another code")
59 }
60 Self::InvalidPhone => f.write_str("phone number not in E.164 format"),
61 }
62 }
63}
64
65pub trait PhoneCodeBackend: Send + Sync {
66 fn put(&self, phone: &str, code: &PhoneCode);
67 fn get(&self, phone: &str) -> Option<PhoneCode>;
68 fn remove(&self, phone: &str);
69 fn put_attempts(&self, phone: &str, attempts: u32);
70}
71
72pub struct InMemoryPhoneCodeBackend {
73 codes: Mutex<HashMap<String, PhoneCode>>,
74}
75
76impl Default for InMemoryPhoneCodeBackend {
77 fn default() -> Self {
78 Self {
79 codes: Mutex::new(HashMap::new()),
80 }
81 }
82}
83
84impl PhoneCodeBackend for InMemoryPhoneCodeBackend {
85 fn put(&self, phone: &str, code: &PhoneCode) {
86 self.codes
87 .lock()
88 .unwrap()
89 .insert(phone.to_string(), code.clone());
90 }
91 fn get(&self, phone: &str) -> Option<PhoneCode> {
92 self.codes.lock().unwrap().get(phone).cloned()
93 }
94 fn remove(&self, phone: &str) {
95 self.codes.lock().unwrap().remove(phone);
96 }
97 fn put_attempts(&self, phone: &str, attempts: u32) {
98 if let Some(c) = self.codes.lock().unwrap().get_mut(phone) {
99 c.attempts = attempts;
100 }
101 }
102}
103
104pub struct PhoneCodeStore {
105 backend: Box<dyn PhoneCodeBackend>,
106}
107
108impl Default for PhoneCodeStore {
109 fn default() -> Self {
110 Self::new()
111 }
112}
113
114impl PhoneCodeStore {
115 const TTL_SECS: u64 = 10 * 60;
116 const RESEND_THROTTLE_SECS: u64 = 30;
117 const MAX_ATTEMPTS: u32 = 5;
118
119 pub fn new() -> Self {
120 Self::with_backend(Box::new(InMemoryPhoneCodeBackend::default()))
121 }
122 pub fn with_backend(backend: Box<dyn PhoneCodeBackend>) -> Self {
123 Self { backend }
124 }
125
126 pub fn try_create(&self, phone: &str) -> Result<String, PhoneCodeError> {
130 let normalized = normalize(phone).ok_or(PhoneCodeError::InvalidPhone)?;
131 let now = now_secs();
132 if let Some(existing) = self.backend.get(&normalized) {
133 if now - existing.issued_at < Self::RESEND_THROTTLE_SECS {
134 return Err(PhoneCodeError::Throttled {
135 retry_after_secs: Self::RESEND_THROTTLE_SECS - (now - existing.issued_at),
136 });
137 }
138 }
139 let code = generate_code();
140 let pc = PhoneCode {
141 phone: normalized.clone(),
142 code: code.clone(),
143 issued_at: now,
144 expires_at: now + Self::TTL_SECS,
145 attempts: 0,
146 };
147 self.backend.put(&normalized, &pc);
148 Ok(code)
149 }
150
151 pub fn try_verify(&self, phone: &str, code: &str) -> Result<(), PhoneCodeError> {
157 let normalized = normalize(phone).ok_or(PhoneCodeError::InvalidPhone)?;
158 let entry = self.backend.get(&normalized);
159 match entry {
160 None => {
161 let _ = crate::constant_time_eq(b"000000", code.trim().as_bytes());
163 Err(PhoneCodeError::NotFound)
164 }
165 Some(mut entry) => {
166 if entry.expires_at <= now_secs() {
167 self.backend.remove(&normalized);
168 return Err(PhoneCodeError::Expired);
169 }
170 if entry.attempts >= Self::MAX_ATTEMPTS {
171 self.backend.remove(&normalized);
172 return Err(PhoneCodeError::TooManyAttempts);
173 }
174 let ok = crate::constant_time_eq(entry.code.as_bytes(), code.trim().as_bytes());
175 if ok {
176 self.backend.remove(&normalized);
177 Ok(())
178 } else {
179 entry.attempts += 1;
180 self.backend.put_attempts(&normalized, entry.attempts);
181 if entry.attempts >= Self::MAX_ATTEMPTS {
182 self.backend.remove(&normalized);
183 return Err(PhoneCodeError::TooManyAttempts);
184 }
185 Err(PhoneCodeError::BadCode)
186 }
187 }
188 }
189 }
190}
191
192pub fn normalize(input: &str) -> Option<String> {
196 let mut out = String::with_capacity(input.len());
197 let mut started = false;
198 for ch in input.chars() {
199 match ch {
200 '+' if !started => {
201 out.push('+');
202 started = true;
203 }
204 '0'..='9' => {
205 out.push(ch);
206 started = true;
207 }
208 ' ' | '-' | '.' | '(' | ')' | '\t' => continue,
209 _ => return None,
210 }
211 }
212 if !out.starts_with('+') || out.len() < 8 || out.len() > 16 {
213 return None;
214 }
215 Some(out)
216}
217
218fn generate_code() -> String {
220 use rand::Rng;
221 format!("{:06}", rand::thread_rng().gen_range(0..1_000_000))
222}
223
224fn now_secs() -> u64 {
225 use std::time::{SystemTime, UNIX_EPOCH};
226 SystemTime::now()
227 .duration_since(UNIX_EPOCH)
228 .unwrap_or_default()
229 .as_secs()
230}
231
232pub trait SmsSender: Send + Sync {
239 fn send_sms(&self, phone: &str, body: &str) -> Result<(), String>;
240}
241
242pub struct NullSmsTransport;
244
245impl SmsSender for NullSmsTransport {
246 fn send_sms(&self, _phone: &str, _body: &str) -> Result<(), String> {
247 Ok(())
248 }
249}
250
251pub struct TwilioSmsTransport {
255 account_sid: String,
256 auth_token: String,
257 from: String,
258}
259
260impl TwilioSmsTransport {
261 pub fn from_env() -> Option<Self> {
262 Some(Self {
263 account_sid: std::env::var("PYLON_TWILIO_ACCOUNT_SID").ok()?,
264 auth_token: std::env::var("PYLON_TWILIO_AUTH_TOKEN").ok()?,
265 from: std::env::var("PYLON_TWILIO_FROM").ok()?,
266 })
267 }
268}
269
270impl SmsSender for TwilioSmsTransport {
271 fn send_sms(&self, phone: &str, body: &str) -> Result<(), String> {
272 let url = format!(
273 "https://api.twilio.com/2010-04-01/Accounts/{}/Messages.json",
274 self.account_sid
275 );
276 let form = format!(
277 "From={}&To={}&Body={}",
278 url_encode(&self.from),
279 url_encode(phone),
280 url_encode(body),
281 );
282 use base64::{engine::general_purpose::STANDARD, Engine};
283 let basic = STANDARD.encode(format!("{}:{}", self.account_sid, self.auth_token).as_bytes());
284 let agent = ureq::AgentBuilder::new()
285 .timeout_connect(std::time::Duration::from_secs(10))
286 .timeout_read(std::time::Duration::from_secs(10))
287 .build();
288 match agent
289 .post(&url)
290 .set("Content-Type", "application/x-www-form-urlencoded")
291 .set("Authorization", &format!("Basic {basic}"))
292 .send_string(&form)
293 {
294 Ok(_) => Ok(()),
295 Err(ureq::Error::Status(code, r)) => {
296 let body = r.into_string().unwrap_or_default();
297 Err(format!("twilio HTTP {code}: {body}"))
298 }
299 Err(e) => Err(format!("twilio: {e}")),
300 }
301 }
302}
303
304fn url_encode(s: &str) -> String {
305 let mut out = String::with_capacity(s.len());
306 for b in s.bytes() {
307 match b {
308 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
309 out.push(b as char)
310 }
311 _ => out.push_str(&format!("%{b:02X}")),
312 }
313 }
314 out
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320
321 #[test]
322 fn normalize_strips_formatting() {
323 assert_eq!(normalize("+1 (555) 123-4567"), Some("+15551234567".into()));
324 assert_eq!(normalize("+44 20 7946 0958"), Some("+442079460958".into()));
325 assert_eq!(normalize("+1.555.123.4567"), Some("+15551234567".into()));
326 }
327
328 #[test]
329 fn normalize_rejects_no_plus() {
330 assert!(normalize("5551234567").is_none());
331 }
332
333 #[test]
334 fn normalize_rejects_letters() {
335 assert!(normalize("+1-555-CALL-NOW").is_none());
336 }
337
338 #[test]
339 fn normalize_length_bounds() {
340 assert!(normalize("+1234").is_none()); assert!(normalize("+12345678901234567").is_none()); }
343
344 #[test]
345 fn create_and_verify_round_trip() {
346 let store = PhoneCodeStore::new();
347 let code = store.try_create("+15551234567").unwrap();
348 assert_eq!(code.len(), 6);
349 assert!(store.try_verify("+15551234567", &code).is_ok());
350 assert_eq!(
352 store.try_verify("+15551234567", &code).unwrap_err(),
353 PhoneCodeError::NotFound
354 );
355 }
356
357 #[test]
358 fn verify_rejects_wrong_code() {
359 let store = PhoneCodeStore::new();
360 let _ = store.try_create("+15551234567").unwrap();
361 assert_eq!(
362 store.try_verify("+15551234567", "000000").unwrap_err(),
363 PhoneCodeError::BadCode
364 );
365 }
366
367 #[test]
368 fn too_many_attempts_locks() {
369 let store = PhoneCodeStore::new();
370 let _ = store.try_create("+15551234567").unwrap();
371 for _ in 0..PhoneCodeStore::MAX_ATTEMPTS - 1 {
372 let _ = store.try_verify("+15551234567", "000000");
373 }
374 assert_eq!(
376 store.try_verify("+15551234567", "000000").unwrap_err(),
377 PhoneCodeError::TooManyAttempts
378 );
379 }
380
381 #[test]
382 fn invalid_phone_rejected() {
383 let store = PhoneCodeStore::new();
384 assert_eq!(
385 store.try_create("not-a-number").unwrap_err(),
386 PhoneCodeError::InvalidPhone
387 );
388 }
389
390 #[test]
391 fn normalization_collapses_formatting_at_send() {
392 let store = PhoneCodeStore::new();
395 let _ = store.try_create("+1 555 123 4567").unwrap();
396 let err = store.try_create("+15551234567").unwrap_err();
398 assert!(matches!(err, PhoneCodeError::Throttled { .. }));
399 }
400}