use base64ct::{Base64UrlUnpadded, Encoding};
use chrono::{Duration, Utc};
use rand::TryRngCore;
use rand::rngs::OsRng;
use sha2::{Digest, Sha256};
use crate::db::Db;
use crate::error::AuthError;
use crate::types::{UserId, VerificationTokenId};
const VERIFICATION_TTL_HOURS: i64 = 24;
fn generate_verification_token() -> String {
let mut bytes = [0u8; 32];
OsRng
.try_fill_bytes(&mut bytes)
.expect("OS RNG unavailable");
Base64UrlUnpadded::encode_string(&bytes)
}
fn hash_verification_token(token: &str) -> String {
let digest = Sha256::digest(token.as_bytes());
format!("{digest:x}")
}
impl Db {
pub async fn create_email_verification(&self, user_id: UserId) -> Result<String, AuthError> {
let raw_token = generate_verification_token();
let token_hash = hash_verification_token(&raw_token);
let id = VerificationTokenId::new();
let expires_at = Utc::now() + Duration::hours(VERIFICATION_TTL_HOURS);
let expires_at_str = expires_at.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
sqlx::query(
"INSERT INTO allowthem_email_verification_tokens \
(id, user_id, token_hash, expires_at) \
VALUES (?, ?, ?, ?)",
)
.bind(id)
.bind(user_id)
.bind(&token_hash)
.bind(&expires_at_str)
.execute(self.pool())
.await
.map_err(AuthError::Database)?;
Ok(raw_token)
}
pub async fn verify_email(&self, raw_token: &str) -> Result<bool, AuthError> {
let mut tx = self.pool().begin().await.map_err(AuthError::Database)?;
let token_hash = hash_verification_token(raw_token);
let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
let row: Option<(VerificationTokenId, UserId)> = sqlx::query_as(
"SELECT id, user_id FROM allowthem_email_verification_tokens \
WHERE token_hash = ? AND expires_at > ? AND used_at IS NULL",
)
.bind(&token_hash)
.bind(&now)
.fetch_optional(&mut *tx)
.await
.map_err(AuthError::Database)?;
let (token_id, user_id) = match row {
None => return Ok(false),
Some(r) => r,
};
sqlx::query("UPDATE allowthem_email_verification_tokens SET used_at = ? WHERE id = ?")
.bind(&now)
.bind(token_id)
.execute(&mut *tx)
.await
.map_err(AuthError::Database)?;
sqlx::query("UPDATE allowthem_users SET email_verified = 1, updated_at = ? WHERE id = ?")
.bind(&now)
.bind(user_id)
.execute(&mut *tx)
.await
.map_err(AuthError::Database)?;
tx.commit().await.map_err(AuthError::Database)?;
Ok(true)
}
}
#[cfg(test)]
mod tests {
use crate::db::Db;
use crate::email::LogEmailSender;
use crate::handle::AllowThemBuilder;
use crate::types::Email;
async fn test_db() -> Db {
Db::connect("sqlite::memory:").await.expect("in-memory db")
}
async fn make_user(db: &Db) -> (crate::types::UserId, Email) {
let email = Email::new("verify@example.com".to_string()).unwrap();
let user = db
.create_user(email.clone(), "test-password", None, None)
.await
.expect("create user");
(user.id, email)
}
#[tokio::test]
async fn create_verification_returns_token() {
let db = test_db().await;
let (user_id, _) = make_user(&db).await;
let token = db
.create_email_verification(user_id)
.await
.expect("create verification");
assert!(!token.is_empty());
}
#[tokio::test]
async fn verify_email_succeeds_with_valid_token() {
let db = test_db().await;
let (user_id, email) = make_user(&db).await;
let token = db.create_email_verification(user_id).await.expect("create");
let result = db.verify_email(&token).await.expect("verify");
assert!(result, "valid token must verify");
let user = db.get_user_by_email(&email).await.expect("get user");
assert!(user.email_verified, "user must be marked verified");
}
#[tokio::test]
async fn verify_email_fails_with_garbage_token() {
let db = test_db().await;
let _ = make_user(&db).await;
let result = db.verify_email("not-a-real-token").await.expect("verify");
assert!(!result, "garbage token must fail");
}
#[tokio::test]
async fn verify_email_fails_when_already_used() {
let db = test_db().await;
let (user_id, _) = make_user(&db).await;
let token = db.create_email_verification(user_id).await.expect("create");
let first = db.verify_email(&token).await.expect("first verify");
assert!(first);
let second = db.verify_email(&token).await.expect("second verify");
assert!(!second, "used token must not work again");
}
#[tokio::test]
async fn verify_email_fails_with_expired_token() {
let db = test_db().await;
let (user_id, _) = make_user(&db).await;
let token = db.create_email_verification(user_id).await.expect("create");
let past = (chrono::Utc::now() - chrono::Duration::hours(1))
.format("%Y-%m-%dT%H:%M:%S%.3fZ")
.to_string();
sqlx::query(
"UPDATE allowthem_email_verification_tokens SET expires_at = ? WHERE user_id = ?",
)
.bind(&past)
.bind(user_id)
.execute(db.pool())
.await
.expect("backdate token");
let result = db.verify_email(&token).await.expect("verify");
assert!(!result, "expired token must fail");
}
#[tokio::test]
async fn send_verification_email_succeeds() {
let ath = AllowThemBuilder::new("sqlite::memory:")
.cookie_secure(false)
.base_url("https://example.com")
.email_sender(Box::new(LogEmailSender))
.build()
.await
.expect("build AllowThem");
let email = Email::new("verify@example.com".to_string()).unwrap();
let user = ath
.db()
.create_user(email.clone(), "test-password", None, None)
.await
.expect("create user");
let result = ath.send_verification_email(user.id, &email).await;
assert!(result.is_ok(), "send_verification_email must succeed");
}
}