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