Skip to main content

haste_server/auth_n/global/
email_code.rs

1//! One-time email codes for sign-up and login, kept in the visitor's session.
2//! Nothing touches the tenant or user tables until the address is verified,
3//! and a code only works in the browser that asked for it.
4
5use 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;
18/// How long a verified address may still pick or create a tenant.
19const 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/// Which form asked for the code.
25#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
26pub enum Purpose {
27    Signup,
28    Login,
29}
30
31/// The code a session is waiting on. Only a salted hash of it is stored. The
32/// count of wrong guesses is not kept here: a session is a per-request copy, so
33/// a counter in it is lost under concurrent guesses. [`check`] counts guesses
34/// in the shared, serialized rate limiter instead.
35#[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/// An address whose code was entered correctly.
45#[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    /// No code was requested in this session.
60    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
97/// Starts (or restarts) verification for `email` and returns the code to send.
98pub 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
121/// The code this session is waiting on, expired or not, so the page can still
122/// offer a resend.
123pub 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
130/// A correct code consumes the pending entry and marks the address verified.
131///
132/// Every guess spends one point of a shared [`MAX_ATTEMPTS`] budget before the
133/// code is compared, so a code can be compared at most [`MAX_ATTEMPTS`] times
134/// however many requests run at once. The budget is counted in `rate_limit`
135/// with [`RateLimit::check_uncached`], which serializes concurrent spends in
136/// the database; a counter kept in the session would be lost under a burst,
137/// because each request holds its own copy. The key is the code's salted hash,
138/// so a fresh code (a resend) starts over, and the key is never guessable or
139/// shared between addresses.
140pub 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
199/// The address verified in this session, while still fresh.
200pub 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
209/// Forgets the verified address once it has been used.
210pub 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}