From 1cf0ef0d9cea3dee13f06f20ddd0d0316e1c6b25 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Mon, 3 Nov 2025 21:47:26 +0100 Subject: [PATCH] feat(backend): Implement password reset flow (Phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Controllers/AuthController.cs | 47 ++ .../ForgotPassword/ForgotPasswordCommand.cs | 14 + .../ForgotPasswordCommandHandler.cs | 161 +++++++ .../ResetPassword/ResetPasswordCommand.cs | 10 + .../ResetPasswordCommandHandler.cs | 101 +++++ .../Services/IRateLimitService.cs | 21 + .../Events/PasswordResetCompletedEvent.cs | 10 + .../Events/PasswordResetRequestedEvent.cs | 12 + .../Entities/PasswordResetToken.cs | 81 ++++ .../IPasswordResetTokenRepository.cs | 39 ++ .../DependencyInjection.cs | 5 + .../PasswordResetTokenConfiguration.cs | 64 +++ .../Persistence/IdentityDbContext.cs | 1 + ...03204505_AddPasswordResetToken.Designer.cs | 423 ++++++++++++++++++ .../20251103204505_AddPasswordResetToken.cs | 55 +++ .../IdentityDbContextModelSnapshot.cs | 53 +++ .../PasswordResetTokenRepository.cs | 54 +++ .../Services/MemoryRateLimitService.cs | 48 ++ colaflow-api/test-password-reset.ps1 | 77 ++++ 19 files changed, 1276 insertions(+) create mode 100644 colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/ForgotPassword/ForgotPasswordCommand.cs create mode 100644 colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/ForgotPassword/ForgotPasswordCommandHandler.cs create mode 100644 colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/ResetPassword/ResetPasswordCommand.cs create mode 100644 colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/ResetPassword/ResetPasswordCommandHandler.cs create mode 100644 colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Services/IRateLimitService.cs create mode 100644 colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Aggregates/Users/Events/PasswordResetCompletedEvent.cs create mode 100644 colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Aggregates/Users/Events/PasswordResetRequestedEvent.cs create mode 100644 colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Entities/PasswordResetToken.cs create mode 100644 colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Repositories/IPasswordResetTokenRepository.cs create mode 100644 colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Configurations/PasswordResetTokenConfiguration.cs create mode 100644 colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/20251103204505_AddPasswordResetToken.Designer.cs create mode 100644 colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/20251103204505_AddPasswordResetToken.cs create mode 100644 colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Repositories/PasswordResetTokenRepository.cs create mode 100644 colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Services/MemoryRateLimitService.cs create mode 100644 colaflow-api/test-password-reset.ps1 diff --git a/colaflow-api/src/ColaFlow.API/Controllers/AuthController.cs b/colaflow-api/src/ColaFlow.API/Controllers/AuthController.cs index 27bcb3d..806f256 100644 --- a/colaflow-api/src/ColaFlow.API/Controllers/AuthController.cs +++ b/colaflow-api/src/ColaFlow.API/Controllers/AuthController.cs @@ -1,5 +1,7 @@ using ColaFlow.API.Models; +using ColaFlow.Modules.Identity.Application.Commands.ForgotPassword; using ColaFlow.Modules.Identity.Application.Commands.Login; +using ColaFlow.Modules.Identity.Application.Commands.ResetPassword; using ColaFlow.Modules.Identity.Application.Commands.VerifyEmail; using ColaFlow.Modules.Identity.Application.Services; using MediatR; @@ -165,6 +167,47 @@ public class AuthController( return Ok(new { message = "Email verified successfully" }); } + + /// + /// Initiate password reset flow (sends email with reset link) + /// Always returns success to prevent email enumeration attacks + /// + [HttpPost("forgot-password")] + [AllowAnonymous] + public async Task ForgotPassword([FromBody] ForgotPasswordRequest request) + { + var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + var userAgent = HttpContext.Request.Headers["User-Agent"].ToString(); + var baseUrl = $"{Request.Scheme}://{Request.Host}"; + + var command = new ForgotPasswordCommand( + request.Email, + request.TenantSlug, + ipAddress, + userAgent, + baseUrl); + + await mediator.Send(command); + + // Always return success to prevent email enumeration + return Ok(new { message = "If the email exists, a password reset link has been sent" }); + } + + /// + /// Reset password using valid reset token + /// + [HttpPost("reset-password")] + [AllowAnonymous] + public async Task ResetPassword([FromBody] ResetPasswordRequest request) + { + var command = new ResetPasswordCommand(request.Token, request.NewPassword); + var success = await mediator.Send(command); + + if (!success) + return BadRequest(new { message = "Invalid or expired reset token" }); + + return Ok(new { message = "Password reset successfully. Please login with your new password." }); + } } public record LoginRequest( @@ -174,3 +217,7 @@ public record LoginRequest( ); public record VerifyEmailRequest(string Token); + +public record ForgotPasswordRequest(string Email, string TenantSlug); + +public record ResetPasswordRequest(string Token, string NewPassword); diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/ForgotPassword/ForgotPasswordCommand.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/ForgotPassword/ForgotPasswordCommand.cs new file mode 100644 index 0000000..b1ae693 --- /dev/null +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/ForgotPassword/ForgotPasswordCommand.cs @@ -0,0 +1,14 @@ +using MediatR; + +namespace ColaFlow.Modules.Identity.Application.Commands.ForgotPassword; + +/// +/// Command to initiate password reset flow. +/// Always returns success to prevent email enumeration attacks. +/// +public sealed record ForgotPasswordCommand( + string Email, + string TenantSlug, + string IpAddress, + string UserAgent, + string BaseUrl) : IRequest; diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/ForgotPassword/ForgotPasswordCommandHandler.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/ForgotPassword/ForgotPasswordCommandHandler.cs new file mode 100644 index 0000000..a1ad0f2 --- /dev/null +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/ForgotPassword/ForgotPasswordCommandHandler.cs @@ -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 +{ + 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 _logger; + + public ForgotPasswordCommandHandler( + IUserRepository userRepository, + ITenantRepository tenantRepository, + IPasswordResetTokenRepository tokenRepository, + ISecurityTokenService tokenService, + IEmailService emailService, + IEmailTemplateService emailTemplateService, + IRateLimitService rateLimitService, + ILogger logger) + { + _userRepository = userRepository; + _tenantRepository = tenantRepository; + _tokenRepository = tokenRepository; + _tokenService = tokenService; + _emailService = emailService; + _emailTemplateService = emailTemplateService; + _rateLimitService = rateLimitService; + _logger = logger; + } + + public async Task 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; + } +} diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/ResetPassword/ResetPasswordCommand.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/ResetPassword/ResetPasswordCommand.cs new file mode 100644 index 0000000..efaf653 --- /dev/null +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/ResetPassword/ResetPasswordCommand.cs @@ -0,0 +1,10 @@ +using MediatR; + +namespace ColaFlow.Modules.Identity.Application.Commands.ResetPassword; + +/// +/// Command to reset user password using a valid reset token. +/// +public sealed record ResetPasswordCommand( + string Token, + string NewPassword) : IRequest; diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/ResetPassword/ResetPasswordCommandHandler.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/ResetPassword/ResetPasswordCommandHandler.cs new file mode 100644 index 0000000..637fcbf --- /dev/null +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/ResetPassword/ResetPasswordCommandHandler.cs @@ -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 +{ + private readonly IPasswordResetTokenRepository _tokenRepository; + private readonly IUserRepository _userRepository; + private readonly IRefreshTokenRepository _refreshTokenRepository; + private readonly ISecurityTokenService _tokenService; + private readonly IPasswordHasher _passwordHasher; + private readonly ILogger _logger; + private readonly IPublisher _publisher; + + public ResetPasswordCommandHandler( + IPasswordResetTokenRepository tokenRepository, + IUserRepository userRepository, + IRefreshTokenRepository refreshTokenRepository, + ISecurityTokenService tokenService, + IPasswordHasher passwordHasher, + ILogger logger, + IPublisher publisher) + { + _tokenRepository = tokenRepository; + _userRepository = userRepository; + _refreshTokenRepository = refreshTokenRepository; + _tokenService = tokenService; + _passwordHasher = passwordHasher; + _logger = logger; + _publisher = publisher; + } + + public async Task 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; + } +} diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Services/IRateLimitService.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Services/IRateLimitService.cs new file mode 100644 index 0000000..8db1660 --- /dev/null +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Services/IRateLimitService.cs @@ -0,0 +1,21 @@ +namespace ColaFlow.Modules.Identity.Application.Services; + +/// +/// Rate limiting service to prevent brute force attacks +/// +public interface IRateLimitService +{ + /// + /// Check if an action is allowed based on rate limit + /// + /// Unique key for the rate limit (e.g., "forgot-password:user@example.com") + /// Maximum number of attempts allowed + /// Time window for rate limiting + /// Cancellation token + /// True if allowed, false if rate limit exceeded + Task IsAllowedAsync( + string key, + int maxAttempts, + TimeSpan window, + CancellationToken cancellationToken = default); +} diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Aggregates/Users/Events/PasswordResetCompletedEvent.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Aggregates/Users/Events/PasswordResetCompletedEvent.cs new file mode 100644 index 0000000..81fed19 --- /dev/null +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Aggregates/Users/Events/PasswordResetCompletedEvent.cs @@ -0,0 +1,10 @@ +using ColaFlow.Shared.Kernel.Events; + +namespace ColaFlow.Modules.Identity.Domain.Aggregates.Users.Events; + +/// +/// Domain event raised when a user successfully completes password reset. +/// +public sealed record PasswordResetCompletedEvent( + Guid UserId, + string? IpAddress) : DomainEvent; diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Aggregates/Users/Events/PasswordResetRequestedEvent.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Aggregates/Users/Events/PasswordResetRequestedEvent.cs new file mode 100644 index 0000000..0b315bf --- /dev/null +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Aggregates/Users/Events/PasswordResetRequestedEvent.cs @@ -0,0 +1,12 @@ +using ColaFlow.Shared.Kernel.Events; + +namespace ColaFlow.Modules.Identity.Domain.Aggregates.Users.Events; + +/// +/// Domain event raised when a user requests a password reset. +/// +public sealed record PasswordResetRequestedEvent( + Guid UserId, + string Email, + string? IpAddress, + string? UserAgent) : DomainEvent; diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Entities/PasswordResetToken.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Entities/PasswordResetToken.cs new file mode 100644 index 0000000..be71702 --- /dev/null +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Entities/PasswordResetToken.cs @@ -0,0 +1,81 @@ +using ColaFlow.Shared.Kernel.Common; +using ColaFlow.Modules.Identity.Domain.Aggregates.Users; + +namespace ColaFlow.Modules.Identity.Domain.Entities; + +/// +/// Password reset token entity with enhanced security. +/// Lifetime: 1 hour (short expiration for security). +/// Single-use only (cannot be reused). +/// +public sealed class PasswordResetToken : Entity +{ + public UserId UserId { get; private set; } = null!; + public string TokenHash { get; private set; } = string.Empty; + public DateTime ExpiresAt { get; private set; } + public DateTime? UsedAt { get; private set; } + public string? IpAddress { get; private set; } + public string? UserAgent { get; private set; } + public DateTime CreatedAt { get; private set; } + + // Private constructor for EF Core + private PasswordResetToken() : base() + { + } + + /// + /// Factory method to create new password reset token. + /// + /// User ID requesting password reset + /// SHA-256 hash of the reset token + /// Expiration time (typically 1 hour from creation) + /// IP address of the requester + /// User agent of the requester + /// New password reset token instance + public static PasswordResetToken Create( + UserId userId, + string tokenHash, + DateTime expiresAt, + string? ipAddress = null, + string? userAgent = null) + { + return new PasswordResetToken + { + Id = Guid.NewGuid(), + UserId = userId, + TokenHash = tokenHash, + ExpiresAt = expiresAt, + IpAddress = ipAddress, + UserAgent = userAgent, + CreatedAt = DateTime.UtcNow + }; + } + + /// + /// Check if token has expired. + /// + public bool IsExpired => DateTime.UtcNow > ExpiresAt; + + /// + /// Check if token has been used. + /// + public bool IsUsed => UsedAt.HasValue; + + /// + /// Check if token is valid (not expired and not used). + /// + public bool IsValid => !IsExpired && !IsUsed; + + /// + /// Mark the token as used. + /// Can only be used once for security. + /// + /// Thrown if token is not valid + public void MarkAsUsed() + { + if (!IsValid) + throw new InvalidOperationException("Token is not valid for password reset"); + + UsedAt = DateTime.UtcNow; + } +} diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Repositories/IPasswordResetTokenRepository.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Repositories/IPasswordResetTokenRepository.cs new file mode 100644 index 0000000..553fa50 --- /dev/null +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Repositories/IPasswordResetTokenRepository.cs @@ -0,0 +1,39 @@ +using ColaFlow.Modules.Identity.Domain.Aggregates.Users; +using ColaFlow.Modules.Identity.Domain.Entities; + +namespace ColaFlow.Modules.Identity.Domain.Repositories; + +/// +/// Repository interface for PasswordResetToken entity +/// +public interface IPasswordResetTokenRepository +{ + /// + /// Get password reset token by token hash + /// + Task GetByTokenHashAsync( + string tokenHash, + CancellationToken cancellationToken = default); + + /// + /// Add a new password reset token + /// + Task AddAsync( + PasswordResetToken token, + CancellationToken cancellationToken = default); + + /// + /// Update an existing password reset token + /// + Task UpdateAsync( + PasswordResetToken token, + CancellationToken cancellationToken = default); + + /// + /// Invalidate all active password reset tokens for a user + /// This is called when a new reset request is made + /// + Task InvalidateAllForUserAsync( + UserId userId, + CancellationToken cancellationToken = default); +} diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/DependencyInjection.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/DependencyInjection.cs index 6f3638c..e35af0d 100644 --- a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/DependencyInjection.cs +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/DependencyInjection.cs @@ -38,6 +38,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // Application Services services.AddScoped(); @@ -45,6 +46,10 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); + // Memory cache for rate limiting + services.AddMemoryCache(); + services.AddSingleton(); + // Email Services var emailProvider = configuration["Email:Provider"] ?? "Mock"; if (emailProvider.Equals("Mock", StringComparison.OrdinalIgnoreCase)) diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Configurations/PasswordResetTokenConfiguration.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Configurations/PasswordResetTokenConfiguration.cs new file mode 100644 index 0000000..46ac959 --- /dev/null +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Configurations/PasswordResetTokenConfiguration.cs @@ -0,0 +1,64 @@ +using ColaFlow.Modules.Identity.Domain.Aggregates.Users; +using ColaFlow.Modules.Identity.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Configurations; + +public class PasswordResetTokenConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("password_reset_tokens"); + + // Primary Key + builder.HasKey(t => t.Id); + builder.Property(t => t.Id).HasColumnName("id"); + + // User ID (foreign key) - stored as Guid, mapped to UserId value object + builder.Property(t => t.UserId) + .HasConversion( + userId => (Guid)userId, + value => UserId.Create(value)) + .IsRequired() + .HasColumnName("user_id"); + + // Token hash (SHA-256) + builder.Property(t => t.TokenHash) + .HasMaxLength(64) + .IsRequired() + .HasColumnName("token_hash"); + + // Timestamps + builder.Property(t => t.ExpiresAt) + .IsRequired() + .HasColumnName("expires_at"); + + builder.Property(t => t.CreatedAt) + .IsRequired() + .HasColumnName("created_at"); + + builder.Property(t => t.UsedAt) + .HasColumnName("used_at"); + + // Security audit fields + builder.Property(t => t.IpAddress) + .HasMaxLength(45) // IPv6 max length + .HasColumnName("ip_address"); + + builder.Property(t => t.UserAgent) + .HasMaxLength(500) + .HasColumnName("user_agent"); + + // Indexes for performance + builder.HasIndex(t => t.TokenHash) + .HasDatabaseName("ix_password_reset_tokens_token_hash"); + + builder.HasIndex(t => t.UserId) + .HasDatabaseName("ix_password_reset_tokens_user_id"); + + // Composite index for finding active tokens + builder.HasIndex(t => new { t.UserId, t.ExpiresAt, t.UsedAt }) + .HasDatabaseName("ix_password_reset_tokens_user_active"); + } +} diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/IdentityDbContext.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/IdentityDbContext.cs index db895ef..a113add 100644 --- a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/IdentityDbContext.cs +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/IdentityDbContext.cs @@ -19,6 +19,7 @@ public class IdentityDbContext( public DbSet RefreshTokens => Set(); public DbSet UserTenantRoles => Set(); public DbSet EmailVerificationTokens => Set(); + public DbSet PasswordResetTokens => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/20251103204505_AddPasswordResetToken.Designer.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/20251103204505_AddPasswordResetToken.Designer.cs new file mode 100644 index 0000000..1fc0144 --- /dev/null +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/20251103204505_AddPasswordResetToken.Designer.cs @@ -0,0 +1,423 @@ +// +using System; +using ColaFlow.Modules.Identity.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(IdentityDbContext))] + [Migration("20251103204505_AddPasswordResetToken")] + partial class AddPasswordResetToken + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ColaFlow.Modules.Identity.Domain.Aggregates.Tenants.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("MaxProjects") + .HasColumnType("integer") + .HasColumnName("max_projects"); + + b.Property("MaxStorageGB") + .HasColumnType("integer") + .HasColumnName("max_storage_gb"); + + b.Property("MaxUsers") + .HasColumnType("integer") + .HasColumnName("max_users"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Plan") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("plan"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("slug"); + + b.Property("SsoConfig") + .HasColumnType("jsonb") + .HasColumnName("sso_config"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("status"); + + b.Property("SuspendedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("suspended_at"); + + b.Property("SuspensionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("suspension_reason"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_tenants_slug"); + + b.ToTable("tenants", (string)null); + }); + + modelBuilder.Entity("ColaFlow.Modules.Identity.Domain.Aggregates.Users.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeviceInfo") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("device_info"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("ip_address"); + + b.Property("ReplacedByToken") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("replaced_by_token"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at"); + + b.Property("RevokedReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("revoked_reason"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("token_hash"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("ix_refresh_tokens_expires_at"); + + b.HasIndex("TenantId") + .HasDatabaseName("ix_refresh_tokens_tenant_id"); + + b.HasIndex("TokenHash") + .IsUnique() + .HasDatabaseName("ix_refresh_tokens_token_hash"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_refresh_tokens_user_id"); + + b.ToTable("refresh_tokens", "identity"); + }); + + modelBuilder.Entity("ColaFlow.Modules.Identity.Domain.Aggregates.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AuthProvider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("auth_provider"); + + b.Property("AvatarUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("avatar_url"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("email"); + + b.Property("EmailVerificationToken") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("email_verification_token"); + + b.Property("EmailVerifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("email_verified_at"); + + b.Property("ExternalEmail") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("external_email"); + + b.Property("ExternalUserId") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("external_user_id"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("full_name"); + + b.Property("JobTitle") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("job_title"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_login_at"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("password_hash"); + + b.Property("PasswordResetToken") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("password_reset_token"); + + b.Property("PasswordResetTokenExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("password_reset_token_expires_at"); + + b.Property("PhoneNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("phone_number"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Email") + .IsUnique() + .HasDatabaseName("ix_users_tenant_id_email"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("ColaFlow.Modules.Identity.Domain.Aggregates.Users.UserTenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AssignedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("assigned_at"); + + b.Property("AssignedByUserId") + .HasColumnType("uuid") + .HasColumnName("assigned_by_user_id"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("role"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("Role") + .HasDatabaseName("ix_user_tenant_roles_role"); + + b.HasIndex("TenantId") + .HasDatabaseName("ix_user_tenant_roles_tenant_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_tenant_roles_user_id"); + + b.HasIndex("UserId", "TenantId") + .IsUnique() + .HasDatabaseName("uq_user_tenant_roles_user_tenant"); + + b.ToTable("user_tenant_roles", "identity"); + }); + + modelBuilder.Entity("ColaFlow.Modules.Identity.Domain.Entities.EmailVerificationToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("token_hash"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("VerifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("verified_at"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .HasDatabaseName("ix_email_verification_tokens_token_hash"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_email_verification_tokens_user_id"); + + b.ToTable("email_verification_tokens", (string)null); + }); + + modelBuilder.Entity("ColaFlow.Modules.Identity.Domain.Entities.PasswordResetToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("character varying(45)") + .HasColumnName("ip_address"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("token_hash"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("used_at"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .HasDatabaseName("ix_password_reset_tokens_token_hash"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_password_reset_tokens_user_id"); + + b.HasIndex("UserId", "ExpiresAt", "UsedAt") + .HasDatabaseName("ix_password_reset_tokens_user_active"); + + b.ToTable("password_reset_tokens", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/20251103204505_AddPasswordResetToken.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/20251103204505_AddPasswordResetToken.cs new file mode 100644 index 0000000..1e9ca1a --- /dev/null +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/20251103204505_AddPasswordResetToken.cs @@ -0,0 +1,55 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddPasswordResetToken : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "password_reset_tokens", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + user_id = table.Column(type: "uuid", nullable: false), + token_hash = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + expires_at = table.Column(type: "timestamp with time zone", nullable: false), + used_at = table.Column(type: "timestamp with time zone", nullable: true), + ip_address = table.Column(type: "character varying(45)", maxLength: 45, nullable: true), + user_agent = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_password_reset_tokens", x => x.id); + }); + + migrationBuilder.CreateIndex( + name: "ix_password_reset_tokens_token_hash", + table: "password_reset_tokens", + column: "token_hash"); + + migrationBuilder.CreateIndex( + name: "ix_password_reset_tokens_user_active", + table: "password_reset_tokens", + columns: new[] { "user_id", "expires_at", "used_at" }); + + migrationBuilder.CreateIndex( + name: "ix_password_reset_tokens_user_id", + table: "password_reset_tokens", + column: "user_id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "password_reset_tokens"); + } + } +} diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/IdentityDbContextModelSnapshot.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/IdentityDbContextModelSnapshot.cs index 19c2d7d..959bbc2 100644 --- a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/IdentityDbContextModelSnapshot.cs +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/IdentityDbContextModelSnapshot.cs @@ -361,6 +361,59 @@ namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Migrations b.ToTable("email_verification_tokens", (string)null); }); + + modelBuilder.Entity("ColaFlow.Modules.Identity.Domain.Entities.PasswordResetToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("character varying(45)") + .HasColumnName("ip_address"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("token_hash"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("used_at"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .HasDatabaseName("ix_password_reset_tokens_token_hash"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_password_reset_tokens_user_id"); + + b.HasIndex("UserId", "ExpiresAt", "UsedAt") + .HasDatabaseName("ix_password_reset_tokens_user_active"); + + b.ToTable("password_reset_tokens", (string)null); + }); #pragma warning restore 612, 618 } } diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Repositories/PasswordResetTokenRepository.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Repositories/PasswordResetTokenRepository.cs new file mode 100644 index 0000000..fd7991e --- /dev/null +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Repositories/PasswordResetTokenRepository.cs @@ -0,0 +1,54 @@ +using ColaFlow.Modules.Identity.Domain.Aggregates.Users; +using ColaFlow.Modules.Identity.Domain.Entities; +using ColaFlow.Modules.Identity.Domain.Repositories; +using Microsoft.EntityFrameworkCore; + +namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Repositories; + +public class PasswordResetTokenRepository(IdentityDbContext context) : IPasswordResetTokenRepository +{ + public async Task GetByTokenHashAsync( + string tokenHash, + CancellationToken cancellationToken = default) + { + return await context.PasswordResetTokens + .FirstOrDefaultAsync(t => t.TokenHash == tokenHash, cancellationToken); + } + + public async Task AddAsync( + PasswordResetToken token, + CancellationToken cancellationToken = default) + { + await context.PasswordResetTokens.AddAsync(token, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + } + + public async Task UpdateAsync( + PasswordResetToken token, + CancellationToken cancellationToken = default) + { + context.PasswordResetTokens.Update(token); + await context.SaveChangesAsync(cancellationToken); + } + + public async Task InvalidateAllForUserAsync( + UserId userId, + CancellationToken cancellationToken = default) + { + // Mark all active (unused and non-expired) tokens as expired + var activeTokens = await context.PasswordResetTokens + .Where(t => t.UserId == userId && t.UsedAt == null && t.ExpiresAt > DateTime.UtcNow) + .ToListAsync(cancellationToken); + + foreach (var token in activeTokens) + { + // Force expire by setting expiration to past + context.Entry(token).Property("ExpiresAt").CurrentValue = DateTime.UtcNow.AddMinutes(-1); + } + + if (activeTokens.Count > 0) + { + await context.SaveChangesAsync(cancellationToken); + } + } +} diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Services/MemoryRateLimitService.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Services/MemoryRateLimitService.cs new file mode 100644 index 0000000..d5a3aa8 --- /dev/null +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Services/MemoryRateLimitService.cs @@ -0,0 +1,48 @@ +using ColaFlow.Modules.Identity.Application.Services; +using Microsoft.Extensions.Caching.Memory; + +namespace ColaFlow.Modules.Identity.Infrastructure.Services; + +/// +/// In-memory rate limiting service implementation. +/// For production, consider using Redis for distributed rate limiting. +/// +public class MemoryRateLimitService : IRateLimitService +{ + private readonly IMemoryCache _cache; + + public MemoryRateLimitService(IMemoryCache cache) + { + _cache = cache; + } + + public Task IsAllowedAsync( + string key, + int maxAttempts, + TimeSpan window, + CancellationToken cancellationToken = default) + { + var cacheKey = $"ratelimit:{key}"; + + // Get current attempt count from cache + var attempts = _cache.GetOrCreate(cacheKey, entry => + { + entry.AbsoluteExpirationRelativeToNow = window; + return 0; + }); + + // Check if limit exceeded + if (attempts >= maxAttempts) + { + return Task.FromResult(false); + } + + // Increment attempt count + _cache.Set(cacheKey, attempts + 1, new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = window + }); + + return Task.FromResult(true); + } +} diff --git a/colaflow-api/test-password-reset.ps1 b/colaflow-api/test-password-reset.ps1 new file mode 100644 index 0000000..85e67dd --- /dev/null +++ b/colaflow-api/test-password-reset.ps1 @@ -0,0 +1,77 @@ +# Test Password Reset Flow +# This script tests the complete password reset functionality + +$baseUrl = "http://localhost:5266/api" + +Write-Host "========================================" -ForegroundColor Cyan +Write-Host "Testing Password Reset Flow" -ForegroundColor Cyan +Write-Host "========================================" -ForegroundColor Cyan +Write-Host "" + +# Step 1: Request password reset +Write-Host "[Step 1] Requesting password reset for test user..." -ForegroundColor Yellow +$forgotPasswordRequest = @{ + email = "test@example.com" + tenantSlug = "acme" +} | ConvertTo-Json + +try { + $forgotPasswordResponse = Invoke-RestMethod -Uri "$baseUrl/Auth/forgot-password" ` + -Method Post ` + -Body $forgotPasswordRequest ` + -ContentType "application/json" ` + -ErrorAction Stop + + Write-Host "SUCCESS: Password reset email requested" -ForegroundColor Green + Write-Host "Response: $($forgotPasswordResponse.message)" -ForegroundColor Gray + Write-Host "" +} catch { + Write-Host "FAILED: Password reset request failed" -ForegroundColor Red + Write-Host "Error: $($_.Exception.Message)" -ForegroundColor Red + exit 1 +} + +# Step 2: Note about email +Write-Host "[Step 2] Check email for reset token" -ForegroundColor Yellow +Write-Host "In a real scenario, you would:" -ForegroundColor Gray +Write-Host " 1. Check your email inbox" -ForegroundColor Gray +Write-Host " 2. Click the password reset link" -ForegroundColor Gray +Write-Host " 3. Enter a new password" -ForegroundColor Gray +Write-Host "" +Write-Host "Since we're using MockEmailService, check the console logs." -ForegroundColor Gray +Write-Host "" + +# Step 3: Test with invalid token (for security verification) +Write-Host "[Step 3] Testing with invalid reset token (security check)..." -ForegroundColor Yellow +$invalidResetRequest = @{ + token = "invalid-token-12345" + newPassword = "NewPassword123!" +} | ConvertTo-Json + +try { + $invalidResetResponse = Invoke-RestMethod -Uri "$baseUrl/Auth/reset-password" ` + -Method Post ` + -Body $invalidResetRequest ` + -ContentType "application/json" ` + -ErrorAction Stop + + Write-Host "UNEXPECTED: Invalid token should have been rejected" -ForegroundColor Red +} catch { + $statusCode = $_.Exception.Response.StatusCode.value__ + if ($statusCode -eq 400) { + Write-Host "SUCCESS: Invalid token correctly rejected (400 Bad Request)" -ForegroundColor Green + } else { + Write-Host "FAILED: Unexpected status code: $statusCode" -ForegroundColor Red + } +} + +Write-Host "" +Write-Host "========================================" -ForegroundColor Cyan +Write-Host "Test Summary" -ForegroundColor Cyan +Write-Host "========================================" -ForegroundColor Cyan +Write-Host "1. Forgot password request: SUCCESS" -ForegroundColor Green +Write-Host "2. Invalid token handling: SUCCESS" -ForegroundColor Green +Write-Host "3. To complete the test:" -ForegroundColor Yellow +Write-Host " - Extract the token from email logs" -ForegroundColor Gray +Write-Host " - Call POST /api/Auth/reset-password with valid token" -ForegroundColor Gray +Write-Host ""