1use std::collections::HashMap;
7use std::sync::Arc;
8
9use chrono::{DateTime, Utc};
10use parking_lot::RwLock;
11use serde::{Deserialize, Serialize};
12use tracing::{debug, info};
13use uuid::Uuid;
14
15use crate::{OtpError, OtpResult};
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct OtpRecord {
23 pub id: Uuid,
25 pub token_hash: Vec<u8>,
27 pub entity_id: String,
29 pub label: String,
31 pub profile: String,
33 pub created_at: DateTime<Utc>,
35 pub expires_at: DateTime<Utc>,
37 pub max_uses: u32,
39 pub current_uses: u32,
41 pub revoked: bool,
43}
44
45pub trait OtpStore: Send + Sync {
49 fn insert(&self, record: OtpRecord) -> impl std::future::Future<Output = OtpResult<()>> + Send;
51
52 fn find_by_hash(
54 &self,
55 hash: &[u8],
56 ) -> impl std::future::Future<Output = OtpResult<Option<OtpRecord>>> + Send;
57
58 fn increment_uses(
66 &self,
67 id: &Uuid,
68 max_uses: u32,
69 ) -> impl std::future::Future<Output = OtpResult<()>> + Send;
70
71 fn revoke(&self, id: &Uuid) -> impl std::future::Future<Output = OtpResult<()>> + Send;
73
74 fn cleanup_expired(&self) -> impl std::future::Future<Output = OtpResult<u64>> + Send;
76}
77
78#[derive(Clone)]
86pub struct InMemoryOtpStore {
87 records: Arc<RwLock<HashMap<Uuid, OtpRecord>>>,
88}
89
90impl InMemoryOtpStore {
91 pub fn new() -> Self {
93 Self {
94 records: Arc::new(RwLock::new(HashMap::new())),
95 }
96 }
97}
98
99impl Default for InMemoryOtpStore {
100 fn default() -> Self {
101 Self::new()
102 }
103}
104
105impl OtpStore for InMemoryOtpStore {
106 async fn insert(&self, record: OtpRecord) -> OtpResult<()> {
107 debug!(id = %record.id, entity_id = %record.entity_id, "inserting OTP record");
108 self.records.write().insert(record.id, record);
109 Ok(())
110 }
111
112 async fn find_by_hash(&self, hash: &[u8]) -> OtpResult<Option<OtpRecord>> {
113 let records = self.records.read();
114 Ok(records.values().find(|r| r.token_hash == hash).cloned())
115 }
116
117 async fn increment_uses(&self, id: &Uuid, max_uses: u32) -> OtpResult<()> {
118 let mut records = self.records.write();
119 match records.get_mut(id) {
120 Some(r) => {
121 if r.current_uses >= max_uses {
122 return Err(OtpError::UsageLimitExceeded { max_uses });
123 }
124 r.current_uses += 1;
125 Ok(())
126 }
127 None => Err(OtpError::NotFound),
128 }
129 }
130
131 async fn revoke(&self, id: &Uuid) -> OtpResult<()> {
132 let mut records = self.records.write();
133 match records.get_mut(id) {
134 Some(r) => {
135 r.revoked = true;
136 Ok(())
137 }
138 None => Err(OtpError::NotFound),
139 }
140 }
141
142 async fn cleanup_expired(&self) -> OtpResult<u64> {
143 let now = Utc::now();
144 let mut records = self.records.write();
145 let before = records.len();
146 records.retain(|_, r| r.expires_at > now);
147 let removed = (before - records.len()) as u64;
148 if removed > 0 {
149 info!(removed, "cleaned up expired OTP records");
150 }
151 Ok(removed)
152 }
153}
154
155#[cfg(feature = "db")]
168pub struct DbOtpStore {
169 pool: sqlx::AnyPool,
170 is_postgres: bool,
172}
173
174#[cfg(feature = "db")]
175impl DbOtpStore {
176 pub fn new(pool: sqlx::AnyPool, is_postgres: bool) -> Self {
181 Self { pool, is_postgres }
182 }
183
184 fn sql(&self, s: &str) -> String {
189 if !self.is_postgres {
190 return s.to_string();
191 }
192 let mut result = String::with_capacity(s.len() + 16);
193 let mut param_num = 0u32;
194 for ch in s.chars() {
195 if ch == '?' {
196 param_num += 1;
197 result.push('$');
198 result.push_str(¶m_num.to_string());
199 } else {
200 result.push(ch);
201 }
202 }
203 result
204 }
205
206 fn id_to_uuid(db_id: i64) -> Uuid {
208 Uuid::from_u128(db_id as u128)
209 }
210
211 fn uuid_to_id(uuid: &Uuid) -> i64 {
213 uuid.as_u128() as i64
214 }
215}
216
217#[cfg(feature = "db")]
218impl OtpStore for DbOtpStore {
219 async fn insert(&self, record: OtpRecord) -> OtpResult<()> {
220 let token_hash_hex = hex::encode(&record.token_hash);
221 let expires_at_str = record.expires_at.to_rfc3339();
222 let created_at_str = record.created_at.to_rfc3339();
223
224 debug!(
225 entity_id = %record.entity_id,
226 label = %record.label,
227 profile = %record.profile,
228 max_uses = record.max_uses,
229 "inserting OTP record into database"
230 );
231
232 let sql = self.sql(
233 "INSERT INTO otp_tokens (token_hash, entity_id, label, profile, created_at, expires_at, max_uses, current_uses, revoked) \
234 VALUES (?, ?, ?, ?, ?, ?, ?, 0, 0)",
235 );
236
237 sqlx::query(sqlx::AssertSqlSafe(sql))
238 .bind(&token_hash_hex)
239 .bind(&record.entity_id)
240 .bind(&record.label)
241 .bind(&record.profile)
242 .bind(&created_at_str)
243 .bind(&expires_at_str)
244 .bind(record.max_uses as i32)
245 .execute(&self.pool)
246 .await
247 .map_err(|e| OtpError::StorageError(format!("insert failed: {e}")))?;
248
249 info!(entity_id = %record.entity_id, "OTP record stored in database");
250 Ok(())
251 }
252
253 async fn find_by_hash(&self, hash: &[u8]) -> OtpResult<Option<OtpRecord>> {
254 let hash_hex = hex::encode(hash);
255
256 let sql = self.sql(
257 "SELECT id, token_hash, entity_id, label, profile, created_at, expires_at, max_uses, current_uses, revoked \
258 FROM otp_tokens WHERE token_hash = ? AND revoked = 0",
259 );
260
261 let row = sqlx::query_as::<
262 _,
263 (
264 i64, String, String, String, String, String, String, i32, i32, i32, ),
275 >(sqlx::AssertSqlSafe(sql))
276 .bind(&hash_hex)
277 .fetch_optional(&self.pool)
278 .await
279 .map_err(|e| OtpError::StorageError(format!("find_by_hash query failed: {e}")))?;
280
281 match row {
282 None => Ok(None),
283 Some((
284 db_id,
285 token_hash_hex,
286 entity_id,
287 label,
288 profile,
289 created_str,
290 expires_str,
291 max_uses,
292 current_uses,
293 revoked,
294 )) => {
295 let token_hash = hex::decode(&token_hash_hex).map_err(|e| {
296 OtpError::StorageError(format!("invalid hex in token_hash: {e}"))
297 })?;
298
299 let created_at = chrono::DateTime::parse_from_rfc3339(&created_str)
300 .map(|dt| dt.with_timezone(&Utc))
301 .map_err(|e| OtpError::StorageError(format!("invalid created_at: {e}")))?;
302
303 let expires_at = chrono::DateTime::parse_from_rfc3339(&expires_str)
304 .map(|dt| dt.with_timezone(&Utc))
305 .map_err(|e| OtpError::StorageError(format!("invalid expires_at: {e}")))?;
306
307 Ok(Some(OtpRecord {
308 id: Self::id_to_uuid(db_id),
309 token_hash,
310 entity_id,
311 label,
312 profile,
313 created_at,
314 expires_at,
315 max_uses: max_uses as u32,
316 current_uses: current_uses as u32,
317 revoked: revoked != 0,
318 }))
319 }
320 }
321 }
322
323 async fn increment_uses(&self, id: &Uuid, max_uses: u32) -> OtpResult<()> {
324 let db_id = Self::uuid_to_id(id);
325
326 let sql = self.sql(
329 "UPDATE otp_tokens SET current_uses = current_uses + 1 \
330 WHERE id = ? AND current_uses < ?",
331 );
332
333 let result = sqlx::query(sqlx::AssertSqlSafe(sql))
334 .bind(db_id)
335 .bind(max_uses as i32)
336 .execute(&self.pool)
337 .await
338 .map_err(|e| OtpError::StorageError(format!("increment_uses failed: {e}")))?;
339
340 if result.rows_affected() == 0 {
341 return Err(OtpError::UsageLimitExceeded { max_uses });
343 }
344
345 debug!(db_id, max_uses, "OTP usage count atomically incremented");
346 Ok(())
347 }
348
349 async fn revoke(&self, id: &Uuid) -> OtpResult<()> {
350 let db_id = Self::uuid_to_id(id);
351 let now_str = Utc::now().to_rfc3339();
352
353 let sql = self.sql("UPDATE otp_tokens SET revoked = 1, revoked_at = ? WHERE id = ?");
354
355 let result = sqlx::query(sqlx::AssertSqlSafe(sql))
356 .bind(&now_str)
357 .bind(db_id)
358 .execute(&self.pool)
359 .await
360 .map_err(|e| OtpError::StorageError(format!("revoke failed: {e}")))?;
361
362 if result.rows_affected() == 0 {
363 return Err(OtpError::NotFound);
364 }
365
366 info!(db_id, "OTP record revoked");
367 Ok(())
368 }
369
370 async fn cleanup_expired(&self) -> OtpResult<u64> {
371 let now_str = Utc::now().to_rfc3339();
372
373 let sql = self.sql("DELETE FROM otp_tokens WHERE expires_at < ?");
374
375 let result = sqlx::query(sqlx::AssertSqlSafe(sql))
376 .bind(&now_str)
377 .execute(&self.pool)
378 .await
379 .map_err(|e| OtpError::StorageError(format!("cleanup_expired failed: {e}")))?;
380
381 let removed = result.rows_affected();
382 if removed > 0 {
383 info!(removed, "cleaned up expired OTP records from database");
384 }
385 Ok(removed)
386 }
387}
388
389#[cfg(not(feature = "db"))]
397pub struct DbOtpStore {
398 _private: (),
399}
400
401#[cfg(not(feature = "db"))]
402impl DbOtpStore {
403 pub fn new() -> Self {
405 Self { _private: () }
406 }
407}
408
409#[cfg(not(feature = "db"))]
410impl Default for DbOtpStore {
411 fn default() -> Self {
412 Self::new()
413 }
414}
415
416#[cfg(not(feature = "db"))]
417impl OtpStore for DbOtpStore {
418 async fn insert(&self, _record: OtpRecord) -> OtpResult<()> {
419 Err(OtpError::StorageError(
420 "database OTP store not compiled (enable 'db' feature)".into(),
421 ))
422 }
423
424 async fn find_by_hash(&self, _hash: &[u8]) -> OtpResult<Option<OtpRecord>> {
425 Err(OtpError::StorageError(
426 "database OTP store not compiled (enable 'db' feature)".into(),
427 ))
428 }
429
430 async fn increment_uses(&self, _id: &Uuid, _max_uses: u32) -> OtpResult<()> {
431 Err(OtpError::StorageError(
432 "database OTP store not compiled (enable 'db' feature)".into(),
433 ))
434 }
435
436 async fn revoke(&self, _id: &Uuid) -> OtpResult<()> {
437 Err(OtpError::StorageError(
438 "database OTP store not compiled (enable 'db' feature)".into(),
439 ))
440 }
441
442 async fn cleanup_expired(&self) -> OtpResult<u64> {
443 Err(OtpError::StorageError(
444 "database OTP store not compiled (enable 'db' feature)".into(),
445 ))
446 }
447}
448
449#[cfg(test)]
450mod tests {
451 use super::*;
452 use crate::generate::{OtpGenerator, OtpGeneratorConfig};
453
454 #[tokio::test]
455 async fn in_memory_store_round_trip() {
456 let store = InMemoryOtpStore::new();
457 let generator = OtpGenerator::new(OtpGeneratorConfig::default()).unwrap();
458 let otp = generator
459 .generate("host.example.com", "test", "default")
460 .unwrap();
461
462 let record = OtpRecord {
463 id: otp.metadata.id,
464 token_hash: otp.token_hash.clone(),
465 entity_id: otp.metadata.entity_id.clone(),
466 label: otp.metadata.label.clone(),
467 profile: otp.metadata.profile.clone(),
468 created_at: otp.metadata.created_at,
469 expires_at: otp.metadata.expires_at,
470 max_uses: otp.metadata.max_uses,
471 current_uses: 0,
472 revoked: false,
473 };
474
475 store.insert(record).await.unwrap();
476
477 let found = store.find_by_hash(&otp.token_hash).await.unwrap();
478 assert!(found.is_some());
479 assert_eq!(found.unwrap().entity_id, "host.example.com");
480 }
481
482 #[tokio::test]
483 async fn cleanup_removes_expired() {
484 let store = InMemoryOtpStore::new();
485 let expired = OtpRecord {
486 id: Uuid::new_v4(),
487 token_hash: vec![0u8; 32],
488 entity_id: "expired.example.com".into(),
489 label: "expired".into(),
490 profile: "default".into(),
491 created_at: Utc::now() - chrono::Duration::hours(2),
492 expires_at: Utc::now() - chrono::Duration::hours(1),
493 max_uses: 1,
494 current_uses: 0,
495 revoked: false,
496 };
497 store.insert(expired).await.unwrap();
498
499 let removed = store.cleanup_expired().await.unwrap();
500 assert_eq!(removed, 1);
501 }
502}