haste_server/auth_n/global/
email_code.rs1use haste_fhir_model::r4::generated::terminology::IssueType;
6use haste_fhir_operation_error::OperationOutcomeError;
7use haste_rate_limit::{RateLimit, RateLimitError};
8use haste_repository::utilities::generate_id;
9use rand::Rng;
10use serde::{Deserialize, Serialize};
11use sha2::{Digest, Sha256};
12use std::time::{Duration, SystemTime, UNIX_EPOCH};
13use tower_sessions::Session;
14
15pub const CODE_DIGITS: usize = 6;
16pub const CODE_VALID_FOR: Duration = Duration::from_secs(10 * 60);
17pub const MAX_ATTEMPTS: u8 = 5;
18const VERIFIED_VALID_FOR: Duration = Duration::from_secs(10 * 60);
20
21const PENDING_KEY: &str = "email_code_pending";
22const VERIFIED_KEY: &str = "email_code_verified";
23
24#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
26pub enum Purpose {
27 Signup,
28 Login,
29}
30
31#[derive(Serialize, Deserialize)]
36pub struct PendingCode {
37 pub email: String,
38 pub purpose: Purpose,
39 salt: String,
40 code_hash: String,
41 expires_at: u64,
42}
43
44#[derive(Clone, Serialize, Deserialize)]
46pub struct VerifiedEmail {
47 pub email: String,
48 pub purpose: Purpose,
49 expires_at: u64,
50}
51
52pub enum CheckOutcome {
53 Verified(VerifiedEmail),
54 Incorrect {
55 attempts_left: u8,
56 },
57 Expired,
58 TooManyAttempts,
59 NotPending,
61}
62
63fn now() -> u64 {
64 SystemTime::now()
65 .duration_since(UNIX_EPOCH)
66 .unwrap_or_default()
67 .as_secs()
68}
69
70fn session_error(what: &str) -> OperationOutcomeError {
71 OperationOutcomeError::fatal(IssueType::exception(), format!("Session error: {what}."))
72}
73
74fn hash_code(salt: &str, code: &str) -> String {
75 format!("{:x}", Sha256::digest(format!("{salt}:{code}")))
76}
77
78fn generate_code() -> String {
79 let max = 10u32.pow(CODE_DIGITS as u32);
80 format!(
81 "{:0width$}",
82 rand::thread_rng().gen_range(0..max),
83 width = CODE_DIGITS
84 )
85}
86
87async fn store_pending(
88 session: &Session,
89 pending: &PendingCode,
90) -> Result<(), OperationOutcomeError> {
91 session
92 .insert(PENDING_KEY, pending)
93 .await
94 .map_err(|_| session_error("failed to store the pending code"))
95}
96
97pub async fn start(
99 session: &Session,
100 email: &str,
101 purpose: Purpose,
102) -> Result<String, OperationOutcomeError> {
103 let code = generate_code();
104 let salt = generate_id(Some(16));
105
106 store_pending(
107 session,
108 &PendingCode {
109 email: email.to_string(),
110 purpose,
111 code_hash: hash_code(&salt, &code),
112 salt,
113 expires_at: now() + CODE_VALID_FOR.as_secs(),
114 },
115 )
116 .await?;
117
118 Ok(code)
119}
120
121pub async fn pending(session: &Session) -> Result<Option<PendingCode>, OperationOutcomeError> {
124 session
125 .get::<PendingCode>(PENDING_KEY)
126 .await
127 .map_err(|_| session_error("failed to read the pending code"))
128}
129
130pub async fn check(
141 session: &Session,
142 rate_limit: &dyn RateLimit,
143 submitted: &str,
144) -> Result<CheckOutcome, OperationOutcomeError> {
145 let Some(pending) = pending(session).await? else {
146 return Ok(CheckOutcome::NotPending);
147 };
148
149 if now() >= pending.expires_at {
150 return Ok(CheckOutcome::Expired);
151 }
152
153 let attempt_key = format!("email_code_attempt:{}", pending.code_hash);
154 let spent = match rate_limit
155 .check_uncached(
156 &attempt_key,
157 MAX_ATTEMPTS as i32,
158 1,
159 CODE_VALID_FOR.as_secs() as i32,
160 )
161 .await
162 {
163 Ok(spent) => spent,
164 Err(RateLimitError::Exceeded) => return Ok(CheckOutcome::TooManyAttempts),
165 Err(RateLimitError::Error(error)) => {
166 tracing::error!(error, "email code attempt limit check failed");
167 return Err(session_error("failed to check the attempt limit"));
168 }
169 };
170
171 if hash_code(&pending.salt, submitted.trim()) != pending.code_hash {
172 return Ok(match MAX_ATTEMPTS as i32 - spent {
173 left if left <= 0 => CheckOutcome::TooManyAttempts,
174 left => CheckOutcome::Incorrect {
175 attempts_left: left as u8,
176 },
177 });
178 }
179
180 session
181 .remove::<PendingCode>(PENDING_KEY)
182 .await
183 .map_err(|_| session_error("failed to clear the pending code"))?;
184
185 let verified = VerifiedEmail {
186 email: pending.email,
187 purpose: pending.purpose,
188 expires_at: now() + VERIFIED_VALID_FOR.as_secs(),
189 };
190
191 session
192 .insert(VERIFIED_KEY, &verified)
193 .await
194 .map_err(|_| session_error("failed to store the verified address"))?;
195
196 Ok(CheckOutcome::Verified(verified))
197}
198
199pub async fn verified(session: &Session) -> Result<Option<VerifiedEmail>, OperationOutcomeError> {
201 let verified = session
202 .get::<VerifiedEmail>(VERIFIED_KEY)
203 .await
204 .map_err(|_| session_error("failed to read the verified address"))?;
205
206 Ok(verified.filter(|verified| now() < verified.expires_at))
207}
208
209pub async fn clear_verified(session: &Session) -> Result<(), OperationOutcomeError> {
211 session
212 .remove::<VerifiedEmail>(VERIFIED_KEY)
213 .await
214 .map(|_| ())
215 .map_err(|_| session_error("failed to clear the verified address"))
216}