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::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}