Skip to main content

allowthem_core/
email_verification.rs

1use base64ct::{Base64UrlUnpadded, Encoding};
2use chrono::{Duration, Utc};
3use rand::TryRngCore;
4use rand::rngs::OsRng;
5use sha2::{Digest, Sha256};
6
7use crate::db::Db;
8use crate::email::{EmailMessage, EmailSender};
9use crate::error::AuthError;
10use crate::types::{Email, UserId, VerificationTokenId};
11
12const VERIFICATION_TTL_HOURS: i64 = 24;
13
14fn generate_verification_token() -> String {
15    let mut bytes = [0u8; 32];
16    OsRng
17        .try_fill_bytes(&mut bytes)
18        .expect("OS RNG unavailable");
19    Base64UrlUnpadded::encode_string(&bytes)
20}
21
22fn hash_verification_token(token: &str) -> String {
23    let digest = Sha256::digest(token.as_bytes());
24    format!("{digest:x}")
25}
26
27impl Db {
28    pub async fn create_email_verification(&self, user_id: UserId) -> Result<String, AuthError> {
29        let raw_token = generate_verification_token();
30        let token_hash = hash_verification_token(&raw_token);
31        let id = VerificationTokenId::new();
32        let expires_at = Utc::now() + Duration::hours(VERIFICATION_TTL_HOURS);
33        let expires_at_str = expires_at.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
34
35        sqlx::query(
36            "INSERT INTO allowthem_email_verification_tokens \
37             (id, user_id, token_hash, expires_at) \
38             VALUES (?, ?, ?, ?)",
39        )
40        .bind(id)
41        .bind(user_id)
42        .bind(&token_hash)
43        .bind(&expires_at_str)
44        .execute(self.pool())
45        .await
46        .map_err(AuthError::Database)?;
47
48        Ok(raw_token)
49    }
50
51    pub async fn verify_email(&self, raw_token: &str) -> Result<bool, AuthError> {
52        let mut tx = self.pool().begin().await.map_err(AuthError::Database)?;
53
54        let token_hash = hash_verification_token(raw_token);
55        let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
56
57        let row: Option<(VerificationTokenId, UserId)> = sqlx::query_as(
58            "SELECT id, user_id FROM allowthem_email_verification_tokens \
59             WHERE token_hash = ? AND expires_at > ? AND used_at IS NULL",
60        )
61        .bind(&token_hash)
62        .bind(&now)
63        .fetch_optional(&mut *tx)
64        .await
65        .map_err(AuthError::Database)?;
66
67        let (token_id, user_id) = match row {
68            None => return Ok(false),
69            Some(r) => r,
70        };
71
72        sqlx::query("UPDATE allowthem_email_verification_tokens SET used_at = ? WHERE id = ?")
73            .bind(&now)
74            .bind(token_id)
75            .execute(&mut *tx)
76            .await
77            .map_err(AuthError::Database)?;
78
79        sqlx::query("UPDATE allowthem_users SET email_verified = 1, updated_at = ? WHERE id = ?")
80            .bind(&now)
81            .bind(user_id)
82            .execute(&mut *tx)
83            .await
84            .map_err(AuthError::Database)?;
85
86        tx.commit().await.map_err(AuthError::Database)?;
87
88        Ok(true)
89    }
90
91    pub async fn send_verification_email(
92        &self,
93        user_id: UserId,
94        email: &Email,
95        base_url: &str,
96        sender: &dyn EmailSender,
97    ) -> Result<(), AuthError> {
98        let raw_token = self.create_email_verification(user_id).await?;
99
100        let verify_url = format!("{}/auth/verify-email?token={}", base_url, raw_token);
101        let body = format!(
102            "Please verify your email address by clicking the link below:\n\n{}\n\nThis link expires in {} hours.",
103            verify_url, VERIFICATION_TTL_HOURS,
104        );
105        let html = format!(
106            "<p>Please verify your email address. <a href=\"{}\">Click here to verify</a>.</p>\
107             <p>This link expires in {} hours.</p>",
108            verify_url, VERIFICATION_TTL_HOURS,
109        );
110
111        let message = EmailMessage {
112            to: email.as_str(),
113            subject: "Verify your email address",
114            body: &body,
115            html: Some(&html),
116        };
117
118        sender
119            .send(message)
120            .await
121            .map_err(|e| AuthError::Email(e.to_string()))
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use crate::db::Db;
128    use crate::email::LogEmailSender;
129    use crate::types::Email;
130
131    async fn test_db() -> Db {
132        Db::connect("sqlite::memory:").await.expect("in-memory db")
133    }
134
135    async fn make_user(db: &Db) -> (crate::types::UserId, Email) {
136        let email = Email::new("verify@example.com".to_string()).unwrap();
137        let user = db
138            .create_user(email.clone(), "test-password", None, None)
139            .await
140            .expect("create user");
141        (user.id, email)
142    }
143
144    #[tokio::test]
145    async fn create_verification_returns_token() {
146        let db = test_db().await;
147        let (user_id, _) = make_user(&db).await;
148        let token = db
149            .create_email_verification(user_id)
150            .await
151            .expect("create verification");
152        assert!(!token.is_empty());
153    }
154
155    #[tokio::test]
156    async fn verify_email_succeeds_with_valid_token() {
157        let db = test_db().await;
158        let (user_id, email) = make_user(&db).await;
159        let token = db.create_email_verification(user_id).await.expect("create");
160        let result = db.verify_email(&token).await.expect("verify");
161        assert!(result, "valid token must verify");
162
163        let user = db.get_user_by_email(&email).await.expect("get user");
164        assert!(user.email_verified, "user must be marked verified");
165    }
166
167    #[tokio::test]
168    async fn verify_email_fails_with_garbage_token() {
169        let db = test_db().await;
170        let _ = make_user(&db).await;
171        let result = db.verify_email("not-a-real-token").await.expect("verify");
172        assert!(!result, "garbage token must fail");
173    }
174
175    #[tokio::test]
176    async fn verify_email_fails_when_already_used() {
177        let db = test_db().await;
178        let (user_id, _) = make_user(&db).await;
179        let token = db.create_email_verification(user_id).await.expect("create");
180        let first = db.verify_email(&token).await.expect("first verify");
181        assert!(first);
182        let second = db.verify_email(&token).await.expect("second verify");
183        assert!(!second, "used token must not work again");
184    }
185
186    #[tokio::test]
187    async fn verify_email_fails_with_expired_token() {
188        let db = test_db().await;
189        let (user_id, _) = make_user(&db).await;
190        let token = db.create_email_verification(user_id).await.expect("create");
191
192        let past = (chrono::Utc::now() - chrono::Duration::hours(1))
193            .format("%Y-%m-%dT%H:%M:%S%.3fZ")
194            .to_string();
195        sqlx::query(
196            "UPDATE allowthem_email_verification_tokens SET expires_at = ? WHERE user_id = ?",
197        )
198        .bind(&past)
199        .bind(user_id)
200        .execute(db.pool())
201        .await
202        .expect("backdate token");
203
204        let result = db.verify_email(&token).await.expect("verify");
205        assert!(!result, "expired token must fail");
206    }
207
208    #[tokio::test]
209    async fn send_verification_email_succeeds() {
210        let db = test_db().await;
211        let (user_id, email) = make_user(&db).await;
212        let sender = LogEmailSender;
213        let result = db
214            .send_verification_email(user_id, &email, "https://example.com", &sender)
215            .await;
216        assert!(result.is_ok());
217    }
218}