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:
@@ -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
|
||||
{
|
||||
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
using MediatR;
|
||||
|
||||
namespace ColaFlow.Modules.Identity.Application.Commands.VerifyEmail;
|
||||
|
||||
public sealed record VerifyEmailCommand(string Token) : IRequest<bool>;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user