arcly_http_identity/
ott.rs1use std::collections::HashMap;
10use std::sync::RwLock;
11use std::time::{SystemTime, UNIX_EPOCH};
12
13use async_trait::async_trait;
14use rand::RngCore;
15use sha2::{Digest, Sha256};
16use subtle::ConstantTimeEq;
17
18use crate::error::{IdentityError, Result};
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum OttPurpose {
25 EmailVerification,
26 PhoneVerification,
27 PasswordReset,
28 MagicLink,
29 LoginOtp,
30}
31
32impl OttPurpose {
33 pub fn as_str(self) -> &'static str {
34 match self {
35 Self::EmailVerification => "email_verification",
36 Self::PhoneVerification => "phone_verification",
37 Self::PasswordReset => "password_reset",
38 Self::MagicLink => "magic_link",
39 Self::LoginOtp => "login_otp",
40 }
41 }
42}
43
44#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
46pub struct OttRecord {
47 pub user_id: String,
48 pub purpose: OttPurpose,
49 pub token_hash: [u8; 32],
50 pub expires_at: u64,
51}
52
53#[async_trait]
56pub trait OttStore: Send + Sync + 'static {
57 async fn put(&self, record: OttRecord) -> Result<()>;
58 async fn take(&self, token_hash: &[u8; 32]) -> Result<Option<OttRecord>>;
60}
61
62fn unix_now() -> u64 {
63 SystemTime::now()
64 .duration_since(UNIX_EPOCH)
65 .map(|d| d.as_secs())
66 .unwrap_or(0)
67}
68
69fn hash_token(token: &str) -> [u8; 32] {
70 let mut h = Sha256::new();
71 h.update(token.as_bytes());
72 h.finalize().into()
73}
74
75pub struct OneTimeTokenService {
77 store: std::sync::Arc<dyn OttStore>,
78}
79
80impl OneTimeTokenService {
81 pub fn new(store: std::sync::Arc<dyn OttStore>) -> Self {
82 Self { store }
83 }
84
85 pub async fn issue(&self, user_id: &str, purpose: OttPurpose, ttl_secs: u64) -> Result<String> {
88 let mut bytes = [0u8; 32];
89 rand::thread_rng().fill_bytes(&mut bytes);
90 let token = base64_url(&bytes);
91 let record = OttRecord {
92 user_id: user_id.to_owned(),
93 purpose,
94 token_hash: hash_token(&token),
95 expires_at: unix_now() + ttl_secs,
96 };
97 self.store.put(record).await?;
98 Ok(token)
99 }
100
101 pub async fn issue_numeric(
104 &self,
105 user_id: &str,
106 purpose: OttPurpose,
107 digits: u32,
108 ttl_secs: u64,
109 ) -> Result<String> {
110 let modulo = 10u64.pow(digits);
111 let n = (rand::thread_rng().next_u64()) % modulo;
112 let code = format!("{n:0width$}", width = digits as usize);
113 let record = OttRecord {
114 user_id: user_id.to_owned(),
115 purpose,
116 token_hash: hash_token(&code),
117 expires_at: unix_now() + ttl_secs,
118 };
119 self.store.put(record).await?;
120 Ok(code)
121 }
122
123 pub async fn consume(&self, token: &str, purpose: OttPurpose) -> Result<String> {
126 let presented = hash_token(token);
127 let record = self
128 .store
129 .take(&presented)
130 .await?
131 .ok_or(IdentityError::InvalidToken)?;
132
133 let ok: bool = record.token_hash.ct_eq(&presented).into();
136 if !ok || record.purpose != purpose || unix_now() >= record.expires_at {
137 return Err(IdentityError::InvalidToken);
138 }
139 Ok(record.user_id)
140 }
141}
142
143fn base64_url(bytes: &[u8]) -> String {
144 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
145 use base64::Engine;
146 URL_SAFE_NO_PAD.encode(bytes)
147}
148
149#[derive(Default)]
153pub struct MemoryOttStore {
154 inner: RwLock<HashMap<[u8; 32], OttRecord>>,
155}
156
157impl MemoryOttStore {
158 pub fn new() -> Self {
159 Self::default()
160 }
161}
162
163#[async_trait]
164impl OttStore for MemoryOttStore {
165 async fn put(&self, record: OttRecord) -> Result<()> {
166 self.inner
167 .write()
168 .unwrap()
169 .insert(record.token_hash, record);
170 Ok(())
171 }
172
173 async fn take(&self, token_hash: &[u8; 32]) -> Result<Option<OttRecord>> {
174 Ok(self.inner.write().unwrap().remove(token_hash))
175 }
176}