allowthem_core/
email_verification.rs1use 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}