feat(backend): Implement password reset flow (Phase 3)
Complete implementation of secure password reset functionality per DAY7-PRD.md specifications. Changes: - Domain: PasswordResetToken entity with 1-hour expiration and single-use constraint - Domain Events: PasswordResetRequestedEvent and PasswordResetCompletedEvent - Repository: IPasswordResetTokenRepository with token management and invalidation - Commands: ForgotPasswordCommand and ResetPasswordCommand with handlers - Security: MemoryRateLimitService (3 requests/hour) and IRateLimitService interface - API: POST /api/Auth/forgot-password and POST /api/Auth/reset-password endpoints - Infrastructure: EF Core configuration and database migration for password_reset_tokens table - Features: Email enumeration prevention, SHA-256 token hashing, refresh token revocation on password reset - Test: PowerShell test script for password reset flow verification Security Enhancements: - Rate limiting: 3 forgot-password requests per hour per email - Token security: SHA-256 hashing, 1-hour expiration, single-use only - Privacy: Always return success message to prevent email enumeration - Audit trail: IP address and User Agent logging for security monitoring - Session revocation: All refresh tokens revoked after successful password reset Database: - New table: password_reset_tokens with indexes for performance - Columns: id, user_id, token_hash, expires_at, used_at, ip_address, user_agent, created_at 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
using ColaFlow.API.Models;
|
using ColaFlow.API.Models;
|
||||||
|
using ColaFlow.Modules.Identity.Application.Commands.ForgotPassword;
|
||||||
using ColaFlow.Modules.Identity.Application.Commands.Login;
|
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.Commands.VerifyEmail;
|
||||||
using ColaFlow.Modules.Identity.Application.Services;
|
using ColaFlow.Modules.Identity.Application.Services;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
@@ -165,6 +167,47 @@ public class AuthController(
|
|||||||
|
|
||||||
return Ok(new { message = "Email verified successfully" });
|
return Ok(new { message = "Email verified successfully" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initiate password reset flow (sends email with reset link)
|
||||||
|
/// Always returns success to prevent email enumeration attacks
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("forgot-password")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
public async Task<IActionResult> 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" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reset password using valid reset token
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("reset-password")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
public async Task<IActionResult> 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(
|
public record LoginRequest(
|
||||||
@@ -174,3 +217,7 @@ public record LoginRequest(
|
|||||||
);
|
);
|
||||||
|
|
||||||
public record VerifyEmailRequest(string Token);
|
public record VerifyEmailRequest(string Token);
|
||||||
|
|
||||||
|
public record ForgotPasswordRequest(string Email, string TenantSlug);
|
||||||
|
|
||||||
|
public record ResetPasswordRequest(string Token, string NewPassword);
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace ColaFlow.Modules.Identity.Application.Commands.ForgotPassword;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Command to initiate password reset flow.
|
||||||
|
/// Always returns success to prevent email enumeration attacks.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ForgotPasswordCommand(
|
||||||
|
string Email,
|
||||||
|
string TenantSlug,
|
||||||
|
string IpAddress,
|
||||||
|
string UserAgent,
|
||||||
|
string BaseUrl) : IRequest<Unit>;
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
using ColaFlow.Modules.Identity.Application.Services;
|
||||||
|
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
|
||||||
|
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
|
||||||
|
using ColaFlow.Modules.Identity.Domain.Entities;
|
||||||
|
using ColaFlow.Modules.Identity.Domain.Repositories;
|
||||||
|
using ColaFlow.Modules.Identity.Domain.Services;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace ColaFlow.Modules.Identity.Application.Commands.ForgotPassword;
|
||||||
|
|
||||||
|
public class ForgotPasswordCommandHandler : IRequestHandler<ForgotPasswordCommand, Unit>
|
||||||
|
{
|
||||||
|
private readonly IUserRepository _userRepository;
|
||||||
|
private readonly ITenantRepository _tenantRepository;
|
||||||
|
private readonly IPasswordResetTokenRepository _tokenRepository;
|
||||||
|
private readonly ISecurityTokenService _tokenService;
|
||||||
|
private readonly IEmailService _emailService;
|
||||||
|
private readonly IEmailTemplateService _emailTemplateService;
|
||||||
|
private readonly IRateLimitService _rateLimitService;
|
||||||
|
private readonly ILogger<ForgotPasswordCommandHandler> _logger;
|
||||||
|
|
||||||
|
public ForgotPasswordCommandHandler(
|
||||||
|
IUserRepository userRepository,
|
||||||
|
ITenantRepository tenantRepository,
|
||||||
|
IPasswordResetTokenRepository tokenRepository,
|
||||||
|
ISecurityTokenService tokenService,
|
||||||
|
IEmailService emailService,
|
||||||
|
IEmailTemplateService emailTemplateService,
|
||||||
|
IRateLimitService rateLimitService,
|
||||||
|
ILogger<ForgotPasswordCommandHandler> logger)
|
||||||
|
{
|
||||||
|
_userRepository = userRepository;
|
||||||
|
_tenantRepository = tenantRepository;
|
||||||
|
_tokenRepository = tokenRepository;
|
||||||
|
_tokenService = tokenService;
|
||||||
|
_emailService = emailService;
|
||||||
|
_emailTemplateService = emailTemplateService;
|
||||||
|
_rateLimitService = rateLimitService;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Unit> Handle(ForgotPasswordCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// Rate limiting: 3 requests per hour per email
|
||||||
|
var rateLimitKey = $"forgot-password:{request.Email.ToLowerInvariant()}";
|
||||||
|
var isAllowed = await _rateLimitService.IsAllowedAsync(
|
||||||
|
rateLimitKey,
|
||||||
|
3,
|
||||||
|
TimeSpan.FromHours(1),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
if (!isAllowed)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Rate limit exceeded for forgot password. Email: {Email}, IP: {IpAddress}",
|
||||||
|
request.Email,
|
||||||
|
request.IpAddress);
|
||||||
|
|
||||||
|
// Still return success to prevent email enumeration
|
||||||
|
return Unit.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get tenant by slug
|
||||||
|
TenantSlug tenantSlug;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
tenantSlug = TenantSlug.Create(request.TenantSlug);
|
||||||
|
}
|
||||||
|
catch (ArgumentException ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Invalid tenant slug: {TenantSlug} - {Error}", request.TenantSlug, ex.Message);
|
||||||
|
// Return success to prevent enumeration
|
||||||
|
return Unit.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
var tenant = await _tenantRepository.GetBySlugAsync(tenantSlug, cancellationToken);
|
||||||
|
if (tenant == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Tenant not found: {TenantSlug}", request.TenantSlug);
|
||||||
|
// Return success to prevent enumeration
|
||||||
|
return Unit.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user by email
|
||||||
|
Email email;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
email = Email.Create(request.Email);
|
||||||
|
}
|
||||||
|
catch (ArgumentException ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Invalid email: {Email} - {Error}", request.Email, ex.Message);
|
||||||
|
// Return success to prevent enumeration
|
||||||
|
return Unit.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await _userRepository.GetByEmailAsync(TenantId.Create(tenant.Id), email, cancellationToken);
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"User not found for password reset. Email: {Email}, Tenant: {TenantSlug}",
|
||||||
|
request.Email,
|
||||||
|
request.TenantSlug);
|
||||||
|
|
||||||
|
// Return success to prevent email enumeration attack
|
||||||
|
return Unit.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate all existing password reset tokens for this user
|
||||||
|
await _tokenRepository.InvalidateAllForUserAsync(UserId.Create(user.Id), cancellationToken);
|
||||||
|
|
||||||
|
// Generate new password reset token (1-hour expiration)
|
||||||
|
var token = _tokenService.GenerateToken();
|
||||||
|
var tokenHash = _tokenService.HashToken(token);
|
||||||
|
var expiresAt = DateTime.UtcNow.AddHours(1);
|
||||||
|
|
||||||
|
var resetToken = PasswordResetToken.Create(
|
||||||
|
UserId.Create(user.Id),
|
||||||
|
tokenHash,
|
||||||
|
expiresAt,
|
||||||
|
request.IpAddress,
|
||||||
|
request.UserAgent);
|
||||||
|
|
||||||
|
await _tokenRepository.AddAsync(resetToken, cancellationToken);
|
||||||
|
|
||||||
|
// Construct reset URL
|
||||||
|
var resetUrl = $"{request.BaseUrl}/reset-password?token={token}";
|
||||||
|
|
||||||
|
// Send password reset email
|
||||||
|
var emailBody = _emailTemplateService.RenderPasswordResetEmail(
|
||||||
|
user.FullName.ToString(),
|
||||||
|
resetUrl);
|
||||||
|
|
||||||
|
var emailMessage = new EmailMessage(
|
||||||
|
To: user.Email,
|
||||||
|
Subject: "Reset Your Password - ColaFlow",
|
||||||
|
HtmlBody: emailBody
|
||||||
|
);
|
||||||
|
|
||||||
|
var emailSent = await _emailService.SendEmailAsync(emailMessage, cancellationToken);
|
||||||
|
|
||||||
|
if (emailSent)
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Password reset email sent. UserId: {UserId}, Email: {Email}",
|
||||||
|
user.Id,
|
||||||
|
user.Email);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogError(
|
||||||
|
"Failed to send password reset email. UserId: {UserId}, Email: {Email}",
|
||||||
|
user.Id,
|
||||||
|
user.Email);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always return success to prevent email enumeration
|
||||||
|
return Unit.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace ColaFlow.Modules.Identity.Application.Commands.ResetPassword;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Command to reset user password using a valid reset token.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ResetPasswordCommand(
|
||||||
|
string Token,
|
||||||
|
string NewPassword) : IRequest<bool>;
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
using ColaFlow.Modules.Identity.Application.Services;
|
||||||
|
using ColaFlow.Modules.Identity.Domain.Aggregates.Users.Events;
|
||||||
|
using ColaFlow.Modules.Identity.Domain.Repositories;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace ColaFlow.Modules.Identity.Application.Commands.ResetPassword;
|
||||||
|
|
||||||
|
public class ResetPasswordCommandHandler : IRequestHandler<ResetPasswordCommand, bool>
|
||||||
|
{
|
||||||
|
private readonly IPasswordResetTokenRepository _tokenRepository;
|
||||||
|
private readonly IUserRepository _userRepository;
|
||||||
|
private readonly IRefreshTokenRepository _refreshTokenRepository;
|
||||||
|
private readonly ISecurityTokenService _tokenService;
|
||||||
|
private readonly IPasswordHasher _passwordHasher;
|
||||||
|
private readonly ILogger<ResetPasswordCommandHandler> _logger;
|
||||||
|
private readonly IPublisher _publisher;
|
||||||
|
|
||||||
|
public ResetPasswordCommandHandler(
|
||||||
|
IPasswordResetTokenRepository tokenRepository,
|
||||||
|
IUserRepository userRepository,
|
||||||
|
IRefreshTokenRepository refreshTokenRepository,
|
||||||
|
ISecurityTokenService tokenService,
|
||||||
|
IPasswordHasher passwordHasher,
|
||||||
|
ILogger<ResetPasswordCommandHandler> logger,
|
||||||
|
IPublisher publisher)
|
||||||
|
{
|
||||||
|
_tokenRepository = tokenRepository;
|
||||||
|
_userRepository = userRepository;
|
||||||
|
_refreshTokenRepository = refreshTokenRepository;
|
||||||
|
_tokenService = tokenService;
|
||||||
|
_passwordHasher = passwordHasher;
|
||||||
|
_logger = logger;
|
||||||
|
_publisher = publisher;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> Handle(ResetPasswordCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// Validate new password
|
||||||
|
if (string.IsNullOrWhiteSpace(request.NewPassword) || request.NewPassword.Length < 8)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Invalid password provided for reset");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash the token to look it up
|
||||||
|
var tokenHash = _tokenService.HashToken(request.Token);
|
||||||
|
var resetToken = await _tokenRepository.GetByTokenHashAsync(tokenHash, cancellationToken);
|
||||||
|
|
||||||
|
if (resetToken == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Password reset token not found");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resetToken.IsValid)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Password reset token is invalid. IsExpired: {IsExpired}, IsUsed: {IsUsed}",
|
||||||
|
resetToken.IsExpired,
|
||||||
|
resetToken.IsUsed);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user
|
||||||
|
var user = await _userRepository.GetByIdAsync(resetToken.UserId, cancellationToken);
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
_logger.LogError("User {UserId} not found for password reset", resetToken.UserId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash the new password
|
||||||
|
var newPasswordHash = _passwordHasher.HashPassword(request.NewPassword);
|
||||||
|
|
||||||
|
// Update user password (will emit UserPasswordChangedEvent)
|
||||||
|
user.UpdatePassword(newPasswordHash);
|
||||||
|
await _userRepository.UpdateAsync(user, cancellationToken);
|
||||||
|
|
||||||
|
// Mark token as used
|
||||||
|
resetToken.MarkAsUsed();
|
||||||
|
await _tokenRepository.UpdateAsync(resetToken, cancellationToken);
|
||||||
|
|
||||||
|
// Revoke all refresh tokens for security (force re-login on all devices)
|
||||||
|
await _refreshTokenRepository.RevokeAllUserTokensAsync(
|
||||||
|
(Guid)user.Id,
|
||||||
|
"Password reset",
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
// Publish domain event for audit logging
|
||||||
|
await _publisher.Publish(
|
||||||
|
new PasswordResetCompletedEvent((Guid)user.Id, resetToken.IpAddress),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Password reset successfully completed for user {UserId}. All refresh tokens revoked.",
|
||||||
|
(Guid)user.Id);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
namespace ColaFlow.Modules.Identity.Application.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rate limiting service to prevent brute force attacks
|
||||||
|
/// </summary>
|
||||||
|
public interface IRateLimitService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Check if an action is allowed based on rate limit
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="key">Unique key for the rate limit (e.g., "forgot-password:user@example.com")</param>
|
||||||
|
/// <param name="maxAttempts">Maximum number of attempts allowed</param>
|
||||||
|
/// <param name="window">Time window for rate limiting</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token</param>
|
||||||
|
/// <returns>True if allowed, false if rate limit exceeded</returns>
|
||||||
|
Task<bool> IsAllowedAsync(
|
||||||
|
string key,
|
||||||
|
int maxAttempts,
|
||||||
|
TimeSpan window,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using ColaFlow.Shared.Kernel.Events;
|
||||||
|
|
||||||
|
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Users.Events;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Domain event raised when a user successfully completes password reset.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record PasswordResetCompletedEvent(
|
||||||
|
Guid UserId,
|
||||||
|
string? IpAddress) : DomainEvent;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
using ColaFlow.Shared.Kernel.Events;
|
||||||
|
|
||||||
|
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Users.Events;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Domain event raised when a user requests a password reset.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record PasswordResetRequestedEvent(
|
||||||
|
Guid UserId,
|
||||||
|
string Email,
|
||||||
|
string? IpAddress,
|
||||||
|
string? UserAgent) : DomainEvent;
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
using ColaFlow.Shared.Kernel.Common;
|
||||||
|
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
|
||||||
|
|
||||||
|
namespace ColaFlow.Modules.Identity.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Password reset token entity with enhanced security.
|
||||||
|
/// Lifetime: 1 hour (short expiration for security).
|
||||||
|
/// Single-use only (cannot be reused).
|
||||||
|
/// </summary>
|
||||||
|
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()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Factory method to create new password reset token.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userId">User ID requesting password reset</param>
|
||||||
|
/// <param name="tokenHash">SHA-256 hash of the reset token</param>
|
||||||
|
/// <param name="expiresAt">Expiration time (typically 1 hour from creation)</param>
|
||||||
|
/// <param name="ipAddress">IP address of the requester</param>
|
||||||
|
/// <param name="userAgent">User agent of the requester</param>
|
||||||
|
/// <returns>New password reset token instance</returns>
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Check if token has expired.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsExpired => DateTime.UtcNow > ExpiresAt;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Check if token has been used.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsUsed => UsedAt.HasValue;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Check if token is valid (not expired and not used).
|
||||||
|
/// </summary>
|
||||||
|
public bool IsValid => !IsExpired && !IsUsed;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mark the token as used.
|
||||||
|
/// Can only be used once for security.
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="InvalidOperationException">Thrown if token is not valid</exception>
|
||||||
|
public void MarkAsUsed()
|
||||||
|
{
|
||||||
|
if (!IsValid)
|
||||||
|
throw new InvalidOperationException("Token is not valid for password reset");
|
||||||
|
|
||||||
|
UsedAt = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
|
||||||
|
using ColaFlow.Modules.Identity.Domain.Entities;
|
||||||
|
|
||||||
|
namespace ColaFlow.Modules.Identity.Domain.Repositories;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Repository interface for PasswordResetToken entity
|
||||||
|
/// </summary>
|
||||||
|
public interface IPasswordResetTokenRepository
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Get password reset token by token hash
|
||||||
|
/// </summary>
|
||||||
|
Task<PasswordResetToken?> GetByTokenHashAsync(
|
||||||
|
string tokenHash,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Add a new password reset token
|
||||||
|
/// </summary>
|
||||||
|
Task AddAsync(
|
||||||
|
PasswordResetToken token,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Update an existing password reset token
|
||||||
|
/// </summary>
|
||||||
|
Task UpdateAsync(
|
||||||
|
PasswordResetToken token,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Invalidate all active password reset tokens for a user
|
||||||
|
/// This is called when a new reset request is made
|
||||||
|
/// </summary>
|
||||||
|
Task InvalidateAllForUserAsync(
|
||||||
|
UserId userId,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<IRefreshTokenRepository, RefreshTokenRepository>();
|
services.AddScoped<IRefreshTokenRepository, RefreshTokenRepository>();
|
||||||
services.AddScoped<IUserTenantRoleRepository, UserTenantRoleRepository>();
|
services.AddScoped<IUserTenantRoleRepository, UserTenantRoleRepository>();
|
||||||
services.AddScoped<IEmailVerificationTokenRepository, EmailVerificationTokenRepository>();
|
services.AddScoped<IEmailVerificationTokenRepository, EmailVerificationTokenRepository>();
|
||||||
|
services.AddScoped<IPasswordResetTokenRepository, PasswordResetTokenRepository>();
|
||||||
|
|
||||||
// Application Services
|
// Application Services
|
||||||
services.AddScoped<IJwtService, JwtService>();
|
services.AddScoped<IJwtService, JwtService>();
|
||||||
@@ -45,6 +46,10 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<IRefreshTokenService, RefreshTokenService>();
|
services.AddScoped<IRefreshTokenService, RefreshTokenService>();
|
||||||
services.AddScoped<ISecurityTokenService, SecurityTokenService>();
|
services.AddScoped<ISecurityTokenService, SecurityTokenService>();
|
||||||
|
|
||||||
|
// Memory cache for rate limiting
|
||||||
|
services.AddMemoryCache();
|
||||||
|
services.AddSingleton<IRateLimitService, MemoryRateLimitService>();
|
||||||
|
|
||||||
// Email Services
|
// Email Services
|
||||||
var emailProvider = configuration["Email:Provider"] ?? "Mock";
|
var emailProvider = configuration["Email:Provider"] ?? "Mock";
|
||||||
if (emailProvider.Equals("Mock", StringComparison.OrdinalIgnoreCase))
|
if (emailProvider.Equals("Mock", StringComparison.OrdinalIgnoreCase))
|
||||||
|
|||||||
@@ -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<PasswordResetToken>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<PasswordResetToken> 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ public class IdentityDbContext(
|
|||||||
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
||||||
public DbSet<UserTenantRole> UserTenantRoles => Set<UserTenantRole>();
|
public DbSet<UserTenantRole> UserTenantRoles => Set<UserTenantRole>();
|
||||||
public DbSet<EmailVerificationToken> EmailVerificationTokens => Set<EmailVerificationToken>();
|
public DbSet<EmailVerificationToken> EmailVerificationTokens => Set<EmailVerificationToken>();
|
||||||
|
public DbSet<PasswordResetToken> PasswordResetTokens => Set<PasswordResetToken>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,423 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
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
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("created_at");
|
||||||
|
|
||||||
|
b.Property<int>("MaxProjects")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("max_projects");
|
||||||
|
|
||||||
|
b.Property<int>("MaxStorageGB")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("max_storage_gb");
|
||||||
|
|
||||||
|
b.Property<int>("MaxUsers")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("max_users");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)")
|
||||||
|
.HasColumnName("name");
|
||||||
|
|
||||||
|
b.Property<string>("Plan")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)")
|
||||||
|
.HasColumnName("plan");
|
||||||
|
|
||||||
|
b.Property<string>("Slug")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)")
|
||||||
|
.HasColumnName("slug");
|
||||||
|
|
||||||
|
b.Property<string>("SsoConfig")
|
||||||
|
.HasColumnType("jsonb")
|
||||||
|
.HasColumnName("sso_config");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)")
|
||||||
|
.HasColumnName("status");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("SuspendedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("suspended_at");
|
||||||
|
|
||||||
|
b.Property<string>("SuspensionReason")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)")
|
||||||
|
.HasColumnName("suspension_reason");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("created_at");
|
||||||
|
|
||||||
|
b.Property<string>("DeviceInfo")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)")
|
||||||
|
.HasColumnName("device_info");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ExpiresAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("expires_at");
|
||||||
|
|
||||||
|
b.Property<string>("IpAddress")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)")
|
||||||
|
.HasColumnName("ip_address");
|
||||||
|
|
||||||
|
b.Property<string>("ReplacedByToken")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)")
|
||||||
|
.HasColumnName("replaced_by_token");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("RevokedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("revoked_at");
|
||||||
|
|
||||||
|
b.Property<string>("RevokedReason")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)")
|
||||||
|
.HasColumnName("revoked_reason");
|
||||||
|
|
||||||
|
b.Property<Guid>("TenantId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("tenant_id");
|
||||||
|
|
||||||
|
b.Property<string>("TokenHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)")
|
||||||
|
.HasColumnName("token_hash");
|
||||||
|
|
||||||
|
b.Property<string>("UserAgent")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)")
|
||||||
|
.HasColumnName("user_agent");
|
||||||
|
|
||||||
|
b.Property<Guid>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<string>("AuthProvider")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)")
|
||||||
|
.HasColumnName("auth_provider");
|
||||||
|
|
||||||
|
b.Property<string>("AvatarUrl")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)")
|
||||||
|
.HasColumnName("avatar_url");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("created_at");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("character varying(255)")
|
||||||
|
.HasColumnName("email");
|
||||||
|
|
||||||
|
b.Property<string>("EmailVerificationToken")
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("character varying(255)")
|
||||||
|
.HasColumnName("email_verification_token");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("EmailVerifiedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("email_verified_at");
|
||||||
|
|
||||||
|
b.Property<string>("ExternalEmail")
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("character varying(255)")
|
||||||
|
.HasColumnName("external_email");
|
||||||
|
|
||||||
|
b.Property<string>("ExternalUserId")
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("character varying(255)")
|
||||||
|
.HasColumnName("external_user_id");
|
||||||
|
|
||||||
|
b.Property<string>("FullName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)")
|
||||||
|
.HasColumnName("full_name");
|
||||||
|
|
||||||
|
b.Property<string>("JobTitle")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)")
|
||||||
|
.HasColumnName("job_title");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LastLoginAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("last_login_at");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("character varying(255)")
|
||||||
|
.HasColumnName("password_hash");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordResetToken")
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("character varying(255)")
|
||||||
|
.HasColumnName("password_reset_token");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("PasswordResetTokenExpiresAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("password_reset_token_expires_at");
|
||||||
|
|
||||||
|
b.Property<string>("PhoneNumber")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)")
|
||||||
|
.HasColumnName("phone_number");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)")
|
||||||
|
.HasColumnName("status");
|
||||||
|
|
||||||
|
b.Property<Guid>("TenantId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("tenant_id");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<DateTime>("AssignedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("assigned_at");
|
||||||
|
|
||||||
|
b.Property<Guid?>("AssignedByUserId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("assigned_by_user_id");
|
||||||
|
|
||||||
|
b.Property<string>("Role")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)")
|
||||||
|
.HasColumnName("role");
|
||||||
|
|
||||||
|
b.Property<Guid>("TenantId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("tenant_id");
|
||||||
|
|
||||||
|
b.Property<Guid>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("created_at");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ExpiresAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("expires_at");
|
||||||
|
|
||||||
|
b.Property<string>("TokenHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)")
|
||||||
|
.HasColumnName("token_hash");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("user_id");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("created_at");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ExpiresAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("expires_at");
|
||||||
|
|
||||||
|
b.Property<string>("IpAddress")
|
||||||
|
.HasMaxLength(45)
|
||||||
|
.HasColumnType("character varying(45)")
|
||||||
|
.HasColumnName("ip_address");
|
||||||
|
|
||||||
|
b.Property<string>("TokenHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)")
|
||||||
|
.HasColumnName("token_hash");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UsedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("used_at");
|
||||||
|
|
||||||
|
b.Property<string>("UserAgent")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)")
|
||||||
|
.HasColumnName("user_agent");
|
||||||
|
|
||||||
|
b.Property<Guid>("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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddPasswordResetToken : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "password_reset_tokens",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
user_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
token_hash = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||||
|
expires_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
used_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
ip_address = table.Column<string>(type: "character varying(45)", maxLength: 45, nullable: true),
|
||||||
|
user_agent = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||||
|
created_at = table.Column<DateTime>(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");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "password_reset_tokens");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -361,6 +361,59 @@ namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Migrations
|
|||||||
|
|
||||||
b.ToTable("email_verification_tokens", (string)null);
|
b.ToTable("email_verification_tokens", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ColaFlow.Modules.Identity.Domain.Entities.PasswordResetToken", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("created_at");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ExpiresAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("expires_at");
|
||||||
|
|
||||||
|
b.Property<string>("IpAddress")
|
||||||
|
.HasMaxLength(45)
|
||||||
|
.HasColumnType("character varying(45)")
|
||||||
|
.HasColumnName("ip_address");
|
||||||
|
|
||||||
|
b.Property<string>("TokenHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)")
|
||||||
|
.HasColumnName("token_hash");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UsedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("used_at");
|
||||||
|
|
||||||
|
b.Property<string>("UserAgent")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)")
|
||||||
|
.HasColumnName("user_agent");
|
||||||
|
|
||||||
|
b.Property<Guid>("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
|
#pragma warning restore 612, 618
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<PasswordResetToken?> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using ColaFlow.Modules.Identity.Application.Services;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
|
||||||
|
namespace ColaFlow.Modules.Identity.Infrastructure.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// In-memory rate limiting service implementation.
|
||||||
|
/// For production, consider using Redis for distributed rate limiting.
|
||||||
|
/// </summary>
|
||||||
|
public class MemoryRateLimitService : IRateLimitService
|
||||||
|
{
|
||||||
|
private readonly IMemoryCache _cache;
|
||||||
|
|
||||||
|
public MemoryRateLimitService(IMemoryCache cache)
|
||||||
|
{
|
||||||
|
_cache = cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<bool> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
77
colaflow-api/test-password-reset.ps1
Normal file
77
colaflow-api/test-password-reset.ps1
Normal file
@@ -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 ""
|
||||||
Reference in New Issue
Block a user