feat(backend): Implement email verification flow - Phase 2

Add complete email verification system with token-based verification.

Changes:
- Created EmailVerificationToken domain entity with expiration and verification tracking
- Created EmailVerifiedEvent domain event for audit trail
- Updated User entity with IsEmailVerified property and VerifyEmail method
- Created IEmailVerificationTokenRepository interface and implementation
- Created SecurityTokenService for secure token generation and SHA-256 hashing
- Created EmailVerificationTokenConfiguration for EF Core mapping
- Updated IdentityDbContext to include EmailVerificationTokens DbSet
- Created SendVerificationEmailCommand and handler for sending verification emails
- Created VerifyEmailCommand and handler for email verification
- Added POST /api/auth/verify-email endpoint to AuthController
- Integrated email verification into RegisterTenantCommandHandler
- Registered all new services in DependencyInjection
- Created and applied AddEmailVerification database migration
- Build successful with no compilation errors

Database Schema:
- email_verification_tokens table with indexes on token_hash and user_id
- 24-hour token expiration
- One-time use tokens with verification tracking

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Yaojia Wang
2025-11-03 21:30:40 +01:00
parent 921990a043
commit 3dcecc656f
20 changed files with 2823 additions and 3 deletions

View File

@@ -1,8 +1,10 @@
using ColaFlow.Modules.Identity.Application.Commands.SendVerificationEmail;
using ColaFlow.Modules.Identity.Application.Services;
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
using ColaFlow.Modules.Identity.Domain.Repositories;
using MediatR;
using Microsoft.Extensions.Configuration;
namespace ColaFlow.Modules.Identity.Application.Commands.RegisterTenant;
@@ -12,7 +14,9 @@ public class RegisterTenantCommandHandler(
IJwtService jwtService,
IPasswordHasher passwordHasher,
IRefreshTokenService refreshTokenService,
IUserTenantRoleRepository userTenantRoleRepository)
IUserTenantRoleRepository userTenantRoleRepository,
IMediator mediator,
IConfiguration configuration)
: IRequestHandler<RegisterTenantCommand, RegisterTenantResult>
{
public async Task<RegisterTenantResult> Handle(
@@ -64,7 +68,17 @@ public class RegisterTenantCommandHandler(
userAgent: null,
cancellationToken);
// 6. Return result
// 7. Send verification email (non-blocking)
var baseUrl = configuration["App:BaseUrl"] ?? "http://localhost:3000";
var sendEmailCommand = new SendVerificationEmailCommand(
adminUser.Id,
request.AdminEmail,
baseUrl);
// Fire and forget - don't wait for email to send
_ = mediator.Send(sendEmailCommand, cancellationToken);
// 8. Return result
return new RegisterTenantResult(
new Dtos.TenantDto
{

View File

@@ -0,0 +1,9 @@
using MediatR;
namespace ColaFlow.Modules.Identity.Application.Commands.SendVerificationEmail;
public sealed record SendVerificationEmailCommand(
Guid UserId,
string Email,
string BaseUrl
) : IRequest<Unit>;

View File

@@ -0,0 +1,95 @@
using ColaFlow.Modules.Identity.Application.Services;
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
using ColaFlow.Modules.Identity.Domain.Entities;
using ColaFlow.Modules.Identity.Domain.Repositories;
using ColaFlow.Modules.Identity.Domain.Services;
using MediatR;
using Microsoft.Extensions.Logging;
namespace ColaFlow.Modules.Identity.Application.Commands.SendVerificationEmail;
public class SendVerificationEmailCommandHandler : IRequestHandler<SendVerificationEmailCommand, Unit>
{
private readonly IUserRepository _userRepository;
private readonly IEmailVerificationTokenRepository _tokenRepository;
private readonly ISecurityTokenService _tokenService;
private readonly IEmailService _emailService;
private readonly IEmailTemplateService _templateService;
private readonly ILogger<SendVerificationEmailCommandHandler> _logger;
public SendVerificationEmailCommandHandler(
IUserRepository userRepository,
IEmailVerificationTokenRepository tokenRepository,
ISecurityTokenService tokenService,
IEmailService emailService,
IEmailTemplateService templateService,
ILogger<SendVerificationEmailCommandHandler> logger)
{
_userRepository = userRepository;
_tokenRepository = tokenRepository;
_tokenService = tokenService;
_emailService = emailService;
_templateService = templateService;
_logger = logger;
}
public async Task<Unit> Handle(SendVerificationEmailCommand request, CancellationToken cancellationToken)
{
var userId = UserId.Create(request.UserId);
var user = await _userRepository.GetByIdAsync(userId, cancellationToken);
if (user == null)
{
_logger.LogWarning("User {UserId} not found, cannot send verification email", request.UserId);
return Unit.Value;
}
// If already verified, no need to send email
if (user.IsEmailVerified)
{
_logger.LogInformation("User {UserId} email already verified, skipping verification email", request.UserId);
return Unit.Value;
}
// Generate token
var token = _tokenService.GenerateToken();
var tokenHash = _tokenService.HashToken(token);
// Create verification token entity
var verificationToken = EmailVerificationToken.Create(
userId,
tokenHash,
DateTime.UtcNow.AddHours(24));
await _tokenRepository.AddAsync(verificationToken, cancellationToken);
// Send email (non-blocking)
var verificationLink = $"{request.BaseUrl}/verify-email?token={token}";
var htmlBody = _templateService.RenderVerificationEmail(user.FullName.Value, verificationLink);
var emailMessage = new EmailMessage(
To: request.Email,
Subject: "Verify your email address - ColaFlow",
HtmlBody: htmlBody,
PlainTextBody: $"Click the link to verify your email: {verificationLink}");
var success = await _emailService.SendEmailAsync(emailMessage, cancellationToken);
if (!success)
{
_logger.LogWarning(
"Failed to send verification email to {Email} for user {UserId}",
request.Email,
request.UserId);
}
else
{
_logger.LogInformation(
"Verification email sent to {Email} for user {UserId}",
request.Email,
request.UserId);
}
return Unit.Value;
}
}

View File

@@ -0,0 +1,5 @@
using MediatR;
namespace ColaFlow.Modules.Identity.Application.Commands.VerifyEmail;
public sealed record VerifyEmailCommand(string Token) : IRequest<bool>;

View File

@@ -0,0 +1,68 @@
using ColaFlow.Modules.Identity.Application.Services;
using ColaFlow.Modules.Identity.Domain.Repositories;
using MediatR;
using Microsoft.Extensions.Logging;
namespace ColaFlow.Modules.Identity.Application.Commands.VerifyEmail;
public class VerifyEmailCommandHandler : IRequestHandler<VerifyEmailCommand, bool>
{
private readonly IEmailVerificationTokenRepository _tokenRepository;
private readonly IUserRepository _userRepository;
private readonly ISecurityTokenService _tokenService;
private readonly ILogger<VerifyEmailCommandHandler> _logger;
public VerifyEmailCommandHandler(
IEmailVerificationTokenRepository tokenRepository,
IUserRepository userRepository,
ISecurityTokenService tokenService,
ILogger<VerifyEmailCommandHandler> logger)
{
_tokenRepository = tokenRepository;
_userRepository = userRepository;
_tokenService = tokenService;
_logger = logger;
}
public async Task<bool> Handle(VerifyEmailCommand request, CancellationToken cancellationToken)
{
// Hash the token to look it up
var tokenHash = _tokenService.HashToken(request.Token);
var verificationToken = await _tokenRepository.GetByTokenHashAsync(tokenHash, cancellationToken);
if (verificationToken == null)
{
_logger.LogWarning("Email verification token not found");
return false;
}
if (!verificationToken.IsValid)
{
_logger.LogWarning(
"Email verification token is invalid. IsExpired: {IsExpired}, IsVerified: {IsVerified}",
verificationToken.IsExpired,
verificationToken.IsVerified);
return false;
}
// Get user and mark email as verified
var user = await _userRepository.GetByIdAsync(verificationToken.UserId, cancellationToken);
if (user == null)
{
_logger.LogError("User {UserId} not found for email verification", verificationToken.UserId);
return false;
}
// Mark token as verified
verificationToken.MarkAsVerified();
await _tokenRepository.UpdateAsync(verificationToken, cancellationToken);
// Mark user email as verified (will emit domain event)
user.VerifyEmail();
await _userRepository.UpdateAsync(user, cancellationToken);
_logger.LogInformation("Email verified for user {UserId}", user.Id);
return true;
}
}

View File

@@ -0,0 +1,22 @@
namespace ColaFlow.Modules.Identity.Application.Services;
/// <summary>
/// Service for generating and hashing security tokens
/// </summary>
public interface ISecurityTokenService
{
/// <summary>
/// Generate a cryptographically secure random token (256-bit, base64url-encoded)
/// </summary>
string GenerateToken();
/// <summary>
/// Hash a token using SHA-256
/// </summary>
string HashToken(string token);
/// <summary>
/// Verify a token against a hash
/// </summary>
bool VerifyToken(string token, string hash);
}