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 ""