use crate::auth::storage::VerificationStore;
use crate::error::{Result, TidewayError};
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use sha2::{Digest, Sha256};
use std::time::{Duration, SystemTime};
use super::types::{EmailVerifyRequest, ResendVerificationRequest};
pub struct EmailVerificationFlow<S: VerificationStore> {
store: S,
token_ttl: Duration,
}
impl<S: VerificationStore> EmailVerificationFlow<S> {
pub fn new(store: S) -> Self {
Self {
store,
token_ttl: Duration::from_secs(24 * 60 * 60), }
}
pub fn with_ttl(mut self, ttl: Duration) -> Self {
self.token_ttl = ttl;
self
}
pub async fn send_verification(&self, user_id: &str, email: &str) -> Result<()> {
let token = generate_verification_token();
let token_hash = hash_token(&token);
let expires = SystemTime::now() + self.token_ttl;
self.store
.store_verification_token(user_id, &token_hash, expires)
.await?;
self.store
.send_verification_email(user_id, email, &token, self.token_ttl)
.await?;
tracing::info!(
target: "auth.email.verification_sent",
user_id = %user_id,
email = %email,
expires_in_secs = self.token_ttl.as_secs(),
"Verification email sent"
);
Ok(())
}
pub async fn verify(&self, req: EmailVerifyRequest) -> Result<()> {
let token_hash = hash_token(&req.token);
let user_id = match self.store.consume_verification_token(&token_hash).await? {
Some(id) => id,
None => {
tracing::warn!(
target: "auth.email.verification_failed",
reason = "invalid_token",
"Email verification failed: invalid or expired token"
);
return Err(TidewayError::BadRequest(
"Invalid or expired verification token".into(),
));
}
};
self.store.mark_user_verified(&user_id).await?;
tracing::info!(
target: "auth.email.verified",
user_id = %user_id,
"Email verified successfully"
);
Ok(())
}
pub async fn resend(&self, req: ResendVerificationRequest, user_id: &str) -> Result<()> {
tracing::info!(
target: "auth.email.verification_resent",
user_id = %user_id,
email = %req.email,
"Verification email resend requested"
);
self.send_verification(user_id, &req.email).await
}
}
fn generate_verification_token() -> String {
use rand::RngCore;
let mut bytes = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut bytes);
URL_SAFE_NO_PAD.encode(bytes)
}
fn hash_token(token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
let result = hasher.finalize();
URL_SAFE_NO_PAD.encode(result)
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use std::collections::HashMap;
use std::sync::RwLock;
struct TestVerificationStore {
tokens: RwLock<HashMap<String, (String, SystemTime)>>,
verified_users: RwLock<Vec<String>>,
emails_sent: RwLock<Vec<(String, String, String)>>, }
impl TestVerificationStore {
fn new() -> Self {
Self {
tokens: RwLock::new(HashMap::new()),
verified_users: RwLock::new(vec![]),
emails_sent: RwLock::new(vec![]),
}
}
fn get_sent_emails(&self) -> Vec<(String, String, String)> {
self.emails_sent.read().unwrap().clone()
}
fn get_verified_users(&self) -> Vec<String> {
self.verified_users.read().unwrap().clone()
}
fn is_user_verified(&self, user_id: &str) -> bool {
self.verified_users
.read()
.unwrap()
.contains(&user_id.to_string())
}
}
#[async_trait]
impl VerificationStore for TestVerificationStore {
async fn store_verification_token(
&self,
user_id: &str,
token_hash: &str,
expires: SystemTime,
) -> Result<()> {
let mut tokens = self.tokens.write().unwrap();
tokens.insert(token_hash.to_string(), (user_id.to_string(), expires));
Ok(())
}
async fn consume_verification_token(&self, token_hash: &str) -> Result<Option<String>> {
let mut tokens = self.tokens.write().unwrap();
if let Some((user_id, expires)) = tokens.remove(token_hash) {
if SystemTime::now() < expires {
return Ok(Some(user_id));
}
}
Ok(None)
}
async fn mark_user_verified(&self, user_id: &str) -> Result<()> {
let mut verified = self.verified_users.write().unwrap();
verified.push(user_id.to_string());
Ok(())
}
async fn send_verification_email(
&self,
user_id: &str,
email: &str,
token: &str,
_expires_in: Duration,
) -> Result<()> {
let mut emails = self.emails_sent.write().unwrap();
emails.push((user_id.to_string(), email.to_string(), token.to_string()));
Ok(())
}
}
#[tokio::test]
async fn test_send_verification() {
let store = TestVerificationStore::new();
let flow = EmailVerificationFlow::new(store);
let result = flow.send_verification("user-123", "test@example.com").await;
assert!(result.is_ok());
let emails = flow.store.get_sent_emails();
assert_eq!(emails.len(), 1);
assert_eq!(emails[0].0, "user-123");
assert_eq!(emails[0].1, "test@example.com");
assert!(!emails[0].2.is_empty()); }
#[tokio::test]
async fn test_verify_valid_token() {
let store = TestVerificationStore::new();
let flow = EmailVerificationFlow::new(store);
flow.send_verification("user-123", "test@example.com")
.await
.unwrap();
let emails = flow.store.get_sent_emails();
let token = &emails[0].2;
let result = flow
.verify(EmailVerifyRequest {
token: token.clone(),
})
.await;
assert!(result.is_ok());
assert!(flow.store.is_user_verified("user-123"));
}
#[tokio::test]
async fn test_verify_invalid_token() {
let store = TestVerificationStore::new();
let flow = EmailVerificationFlow::new(store);
let result = flow
.verify(EmailVerifyRequest {
token: "invalid-token".to_string(),
})
.await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("Invalid or expired"));
}
#[tokio::test]
async fn test_verify_token_consumed() {
let store = TestVerificationStore::new();
let flow = EmailVerificationFlow::new(store);
flow.send_verification("user-123", "test@example.com")
.await
.unwrap();
let emails = flow.store.get_sent_emails();
let token = &emails[0].2;
flow.verify(EmailVerifyRequest {
token: token.clone(),
})
.await
.unwrap();
let result = flow
.verify(EmailVerifyRequest {
token: token.clone(),
})
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_resend_verification() {
let store = TestVerificationStore::new();
let flow = EmailVerificationFlow::new(store);
flow.send_verification("user-123", "test@example.com")
.await
.unwrap();
flow.resend(
ResendVerificationRequest {
email: "test@example.com".to_string(),
},
"user-123",
)
.await
.unwrap();
let emails = flow.store.get_sent_emails();
assert_eq!(emails.len(), 2);
}
#[tokio::test]
async fn test_custom_ttl() {
let store = TestVerificationStore::new();
let flow = EmailVerificationFlow::new(store).with_ttl(Duration::from_secs(60));
let result = flow.send_verification("user-123", "test@example.com").await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_token_uniqueness() {
let store = TestVerificationStore::new();
let flow = EmailVerificationFlow::new(store);
flow.send_verification("user-1", "user1@example.com")
.await
.unwrap();
flow.send_verification("user-2", "user2@example.com")
.await
.unwrap();
let emails = flow.store.get_sent_emails();
let token1 = &emails[0].2;
let token2 = &emails[1].2;
assert_ne!(token1, token2);
}
#[tokio::test]
async fn test_verify_correct_user() {
let store = TestVerificationStore::new();
let flow = EmailVerificationFlow::new(store);
flow.send_verification("user-1", "user1@example.com")
.await
.unwrap();
flow.send_verification("user-2", "user2@example.com")
.await
.unwrap();
let emails = flow.store.get_sent_emails();
let token1 = &emails[0].2;
flow.verify(EmailVerifyRequest {
token: token1.clone(),
})
.await
.unwrap();
let verified = flow.store.get_verified_users();
assert!(verified.contains(&"user-1".to_string()));
assert!(!verified.contains(&"user-2".to_string()));
}
}