feat(backend): Implement password reset flow (Phase 3)

Complete implementation of secure password reset functionality per DAY7-PRD.md specifications.

Changes:
- Domain: PasswordResetToken entity with 1-hour expiration and single-use constraint
- Domain Events: PasswordResetRequestedEvent and PasswordResetCompletedEvent
- Repository: IPasswordResetTokenRepository with token management and invalidation
- Commands: ForgotPasswordCommand and ResetPasswordCommand with handlers
- Security: MemoryRateLimitService (3 requests/hour) and IRateLimitService interface
- API: POST /api/Auth/forgot-password and POST /api/Auth/reset-password endpoints
- Infrastructure: EF Core configuration and database migration for password_reset_tokens table
- Features: Email enumeration prevention, SHA-256 token hashing, refresh token revocation on password reset
- Test: PowerShell test script for password reset flow verification

Security Enhancements:
- Rate limiting: 3 forgot-password requests per hour per email
- Token security: SHA-256 hashing, 1-hour expiration, single-use only
- Privacy: Always return success message to prevent email enumeration
- Audit trail: IP address and User Agent logging for security monitoring
- Session revocation: All refresh tokens revoked after successful password reset

Database:
- New table: password_reset_tokens with indexes for performance
- Columns: id, user_id, token_hash, expires_at, used_at, ip_address, user_agent, created_at

🤖 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:47:26 +01:00
parent 3dcecc656f
commit 1cf0ef0d9c
19 changed files with 1276 additions and 0 deletions

View File

@@ -0,0 +1,14 @@
using MediatR;
namespace ColaFlow.Modules.Identity.Application.Commands.ForgotPassword;
/// <summary>
/// Command to initiate password reset flow.
/// Always returns success to prevent email enumeration attacks.
/// </summary>
public sealed record ForgotPasswordCommand(
string Email,
string TenantSlug,
string IpAddress,
string UserAgent,
string BaseUrl) : IRequest<Unit>;

View File

@@ -0,0 +1,161 @@
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.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.ForgotPassword;
public class ForgotPasswordCommandHandler : IRequestHandler<ForgotPasswordCommand, Unit>
{
private readonly IUserRepository _userRepository;
private readonly ITenantRepository _tenantRepository;
private readonly IPasswordResetTokenRepository _tokenRepository;
private readonly ISecurityTokenService _tokenService;
private readonly IEmailService _emailService;
private readonly IEmailTemplateService _emailTemplateService;
private readonly IRateLimitService _rateLimitService;
private readonly ILogger<ForgotPasswordCommandHandler> _logger;
public ForgotPasswordCommandHandler(
IUserRepository userRepository,
ITenantRepository tenantRepository,
IPasswordResetTokenRepository tokenRepository,
ISecurityTokenService tokenService,
IEmailService emailService,
IEmailTemplateService emailTemplateService,
IRateLimitService rateLimitService,
ILogger<ForgotPasswordCommandHandler> logger)
{
_userRepository = userRepository;
_tenantRepository = tenantRepository;
_tokenRepository = tokenRepository;
_tokenService = tokenService;
_emailService = emailService;
_emailTemplateService = emailTemplateService;
_rateLimitService = rateLimitService;
_logger = logger;
}
public async Task<Unit> Handle(ForgotPasswordCommand request, CancellationToken cancellationToken)
{
// Rate limiting: 3 requests per hour per email
var rateLimitKey = $"forgot-password:{request.Email.ToLowerInvariant()}";
var isAllowed = await _rateLimitService.IsAllowedAsync(
rateLimitKey,
3,
TimeSpan.FromHours(1),
cancellationToken);
if (!isAllowed)
{
_logger.LogWarning(
"Rate limit exceeded for forgot password. Email: {Email}, IP: {IpAddress}",
request.Email,
request.IpAddress);
// Still return success to prevent email enumeration
return Unit.Value;
}
// Get tenant by slug
TenantSlug tenantSlug;
try
{
tenantSlug = TenantSlug.Create(request.TenantSlug);
}
catch (ArgumentException ex)
{
_logger.LogWarning("Invalid tenant slug: {TenantSlug} - {Error}", request.TenantSlug, ex.Message);
// Return success to prevent enumeration
return Unit.Value;
}
var tenant = await _tenantRepository.GetBySlugAsync(tenantSlug, cancellationToken);
if (tenant == null)
{
_logger.LogWarning("Tenant not found: {TenantSlug}", request.TenantSlug);
// Return success to prevent enumeration
return Unit.Value;
}
// Get user by email
Email email;
try
{
email = Email.Create(request.Email);
}
catch (ArgumentException ex)
{
_logger.LogWarning("Invalid email: {Email} - {Error}", request.Email, ex.Message);
// Return success to prevent enumeration
return Unit.Value;
}
var user = await _userRepository.GetByEmailAsync(TenantId.Create(tenant.Id), email, cancellationToken);
if (user == null)
{
_logger.LogWarning(
"User not found for password reset. Email: {Email}, Tenant: {TenantSlug}",
request.Email,
request.TenantSlug);
// Return success to prevent email enumeration attack
return Unit.Value;
}
// Invalidate all existing password reset tokens for this user
await _tokenRepository.InvalidateAllForUserAsync(UserId.Create(user.Id), cancellationToken);
// Generate new password reset token (1-hour expiration)
var token = _tokenService.GenerateToken();
var tokenHash = _tokenService.HashToken(token);
var expiresAt = DateTime.UtcNow.AddHours(1);
var resetToken = PasswordResetToken.Create(
UserId.Create(user.Id),
tokenHash,
expiresAt,
request.IpAddress,
request.UserAgent);
await _tokenRepository.AddAsync(resetToken, cancellationToken);
// Construct reset URL
var resetUrl = $"{request.BaseUrl}/reset-password?token={token}";
// Send password reset email
var emailBody = _emailTemplateService.RenderPasswordResetEmail(
user.FullName.ToString(),
resetUrl);
var emailMessage = new EmailMessage(
To: user.Email,
Subject: "Reset Your Password - ColaFlow",
HtmlBody: emailBody
);
var emailSent = await _emailService.SendEmailAsync(emailMessage, cancellationToken);
if (emailSent)
{
_logger.LogInformation(
"Password reset email sent. UserId: {UserId}, Email: {Email}",
user.Id,
user.Email);
}
else
{
_logger.LogError(
"Failed to send password reset email. UserId: {UserId}, Email: {Email}",
user.Id,
user.Email);
}
// Always return success to prevent email enumeration
return Unit.Value;
}
}

View File

@@ -0,0 +1,10 @@
using MediatR;
namespace ColaFlow.Modules.Identity.Application.Commands.ResetPassword;
/// <summary>
/// Command to reset user password using a valid reset token.
/// </summary>
public sealed record ResetPasswordCommand(
string Token,
string NewPassword) : IRequest<bool>;

View File

@@ -0,0 +1,101 @@
using ColaFlow.Modules.Identity.Application.Services;
using ColaFlow.Modules.Identity.Domain.Aggregates.Users.Events;
using ColaFlow.Modules.Identity.Domain.Repositories;
using MediatR;
using Microsoft.Extensions.Logging;
namespace ColaFlow.Modules.Identity.Application.Commands.ResetPassword;
public class ResetPasswordCommandHandler : IRequestHandler<ResetPasswordCommand, bool>
{
private readonly IPasswordResetTokenRepository _tokenRepository;
private readonly IUserRepository _userRepository;
private readonly IRefreshTokenRepository _refreshTokenRepository;
private readonly ISecurityTokenService _tokenService;
private readonly IPasswordHasher _passwordHasher;
private readonly ILogger<ResetPasswordCommandHandler> _logger;
private readonly IPublisher _publisher;
public ResetPasswordCommandHandler(
IPasswordResetTokenRepository tokenRepository,
IUserRepository userRepository,
IRefreshTokenRepository refreshTokenRepository,
ISecurityTokenService tokenService,
IPasswordHasher passwordHasher,
ILogger<ResetPasswordCommandHandler> logger,
IPublisher publisher)
{
_tokenRepository = tokenRepository;
_userRepository = userRepository;
_refreshTokenRepository = refreshTokenRepository;
_tokenService = tokenService;
_passwordHasher = passwordHasher;
_logger = logger;
_publisher = publisher;
}
public async Task<bool> Handle(ResetPasswordCommand request, CancellationToken cancellationToken)
{
// Validate new password
if (string.IsNullOrWhiteSpace(request.NewPassword) || request.NewPassword.Length < 8)
{
_logger.LogWarning("Invalid password provided for reset");
return false;
}
// Hash the token to look it up
var tokenHash = _tokenService.HashToken(request.Token);
var resetToken = await _tokenRepository.GetByTokenHashAsync(tokenHash, cancellationToken);
if (resetToken == null)
{
_logger.LogWarning("Password reset token not found");
return false;
}
if (!resetToken.IsValid)
{
_logger.LogWarning(
"Password reset token is invalid. IsExpired: {IsExpired}, IsUsed: {IsUsed}",
resetToken.IsExpired,
resetToken.IsUsed);
return false;
}
// Get user
var user = await _userRepository.GetByIdAsync(resetToken.UserId, cancellationToken);
if (user == null)
{
_logger.LogError("User {UserId} not found for password reset", resetToken.UserId);
return false;
}
// Hash the new password
var newPasswordHash = _passwordHasher.HashPassword(request.NewPassword);
// Update user password (will emit UserPasswordChangedEvent)
user.UpdatePassword(newPasswordHash);
await _userRepository.UpdateAsync(user, cancellationToken);
// Mark token as used
resetToken.MarkAsUsed();
await _tokenRepository.UpdateAsync(resetToken, cancellationToken);
// Revoke all refresh tokens for security (force re-login on all devices)
await _refreshTokenRepository.RevokeAllUserTokensAsync(
(Guid)user.Id,
"Password reset",
cancellationToken);
// Publish domain event for audit logging
await _publisher.Publish(
new PasswordResetCompletedEvent((Guid)user.Id, resetToken.IpAddress),
cancellationToken);
_logger.LogInformation(
"Password reset successfully completed for user {UserId}. All refresh tokens revoked.",
(Guid)user.Id);
return true;
}
}

View File

@@ -0,0 +1,21 @@
namespace ColaFlow.Modules.Identity.Application.Services;
/// <summary>
/// Rate limiting service to prevent brute force attacks
/// </summary>
public interface IRateLimitService
{
/// <summary>
/// Check if an action is allowed based on rate limit
/// </summary>
/// <param name="key">Unique key for the rate limit (e.g., "forgot-password:user@example.com")</param>
/// <param name="maxAttempts">Maximum number of attempts allowed</param>
/// <param name="window">Time window for rate limiting</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>True if allowed, false if rate limit exceeded</returns>
Task<bool> IsAllowedAsync(
string key,
int maxAttempts,
TimeSpan window,
CancellationToken cancellationToken = default);
}