feat(backend): Implement email verification flow - Phase 2
Add complete email verification system with token-based verification. Changes: - Created EmailVerificationToken domain entity with expiration and verification tracking - Created EmailVerifiedEvent domain event for audit trail - Updated User entity with IsEmailVerified property and VerifyEmail method - Created IEmailVerificationTokenRepository interface and implementation - Created SecurityTokenService for secure token generation and SHA-256 hashing - Created EmailVerificationTokenConfiguration for EF Core mapping - Updated IdentityDbContext to include EmailVerificationTokens DbSet - Created SendVerificationEmailCommand and handler for sending verification emails - Created VerifyEmailCommand and handler for email verification - Added POST /api/auth/verify-email endpoint to AuthController - Integrated email verification into RegisterTenantCommandHandler - Registered all new services in DependencyInjection - Created and applied AddEmailVerification database migration - Build successful with no compilation errors Database Schema: - email_verification_tokens table with indexes on token_hash and user_id - 24-hour token expiration - One-time use tokens with verification tracking 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
1893
colaflow-api/DAY7-ARCHITECTURE.md
Normal file
1893
colaflow-api/DAY7-ARCHITECTURE.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
|||||||
using ColaFlow.API.Models;
|
using ColaFlow.API.Models;
|
||||||
using ColaFlow.Modules.Identity.Application.Commands.Login;
|
using ColaFlow.Modules.Identity.Application.Commands.Login;
|
||||||
|
using ColaFlow.Modules.Identity.Application.Commands.VerifyEmail;
|
||||||
using ColaFlow.Modules.Identity.Application.Services;
|
using ColaFlow.Modules.Identity.Application.Services;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
@@ -148,6 +149,22 @@ public class AuthController(
|
|||||||
return BadRequest(new { message = "Logout failed" });
|
return BadRequest(new { message = "Logout failed" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verify email address using token
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("verify-email")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
public async Task<IActionResult> VerifyEmail([FromBody] VerifyEmailRequest request)
|
||||||
|
{
|
||||||
|
var command = new VerifyEmailCommand(request.Token);
|
||||||
|
var success = await mediator.Send(command);
|
||||||
|
|
||||||
|
if (!success)
|
||||||
|
return BadRequest(new { message = "Invalid or expired verification token" });
|
||||||
|
|
||||||
|
return Ok(new { message = "Email verified successfully" });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public record LoginRequest(
|
public record LoginRequest(
|
||||||
@@ -155,3 +172,5 @@ public record LoginRequest(
|
|||||||
string Email,
|
string Email,
|
||||||
string Password
|
string Password
|
||||||
);
|
);
|
||||||
|
|
||||||
|
public record VerifyEmailRequest(string Token);
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
using ColaFlow.Modules.Identity.Application.Commands.SendVerificationEmail;
|
||||||
using ColaFlow.Modules.Identity.Application.Services;
|
using ColaFlow.Modules.Identity.Application.Services;
|
||||||
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
|
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
|
||||||
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
|
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
|
||||||
using ColaFlow.Modules.Identity.Domain.Repositories;
|
using ColaFlow.Modules.Identity.Domain.Repositories;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
namespace ColaFlow.Modules.Identity.Application.Commands.RegisterTenant;
|
namespace ColaFlow.Modules.Identity.Application.Commands.RegisterTenant;
|
||||||
|
|
||||||
@@ -12,7 +14,9 @@ public class RegisterTenantCommandHandler(
|
|||||||
IJwtService jwtService,
|
IJwtService jwtService,
|
||||||
IPasswordHasher passwordHasher,
|
IPasswordHasher passwordHasher,
|
||||||
IRefreshTokenService refreshTokenService,
|
IRefreshTokenService refreshTokenService,
|
||||||
IUserTenantRoleRepository userTenantRoleRepository)
|
IUserTenantRoleRepository userTenantRoleRepository,
|
||||||
|
IMediator mediator,
|
||||||
|
IConfiguration configuration)
|
||||||
: IRequestHandler<RegisterTenantCommand, RegisterTenantResult>
|
: IRequestHandler<RegisterTenantCommand, RegisterTenantResult>
|
||||||
{
|
{
|
||||||
public async Task<RegisterTenantResult> Handle(
|
public async Task<RegisterTenantResult> Handle(
|
||||||
@@ -64,7 +68,17 @@ public class RegisterTenantCommandHandler(
|
|||||||
userAgent: null,
|
userAgent: null,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
// 6. Return result
|
// 7. Send verification email (non-blocking)
|
||||||
|
var baseUrl = configuration["App:BaseUrl"] ?? "http://localhost:3000";
|
||||||
|
var sendEmailCommand = new SendVerificationEmailCommand(
|
||||||
|
adminUser.Id,
|
||||||
|
request.AdminEmail,
|
||||||
|
baseUrl);
|
||||||
|
|
||||||
|
// Fire and forget - don't wait for email to send
|
||||||
|
_ = mediator.Send(sendEmailCommand, cancellationToken);
|
||||||
|
|
||||||
|
// 8. Return result
|
||||||
return new RegisterTenantResult(
|
return new RegisterTenantResult(
|
||||||
new Dtos.TenantDto
|
new Dtos.TenantDto
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace ColaFlow.Modules.Identity.Application.Commands.SendVerificationEmail;
|
||||||
|
|
||||||
|
public sealed record SendVerificationEmailCommand(
|
||||||
|
Guid UserId,
|
||||||
|
string Email,
|
||||||
|
string BaseUrl
|
||||||
|
) : IRequest<Unit>;
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
using ColaFlow.Modules.Identity.Application.Services;
|
||||||
|
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
|
||||||
|
using ColaFlow.Modules.Identity.Domain.Entities;
|
||||||
|
using ColaFlow.Modules.Identity.Domain.Repositories;
|
||||||
|
using ColaFlow.Modules.Identity.Domain.Services;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace ColaFlow.Modules.Identity.Application.Commands.SendVerificationEmail;
|
||||||
|
|
||||||
|
public class SendVerificationEmailCommandHandler : IRequestHandler<SendVerificationEmailCommand, Unit>
|
||||||
|
{
|
||||||
|
private readonly IUserRepository _userRepository;
|
||||||
|
private readonly IEmailVerificationTokenRepository _tokenRepository;
|
||||||
|
private readonly ISecurityTokenService _tokenService;
|
||||||
|
private readonly IEmailService _emailService;
|
||||||
|
private readonly IEmailTemplateService _templateService;
|
||||||
|
private readonly ILogger<SendVerificationEmailCommandHandler> _logger;
|
||||||
|
|
||||||
|
public SendVerificationEmailCommandHandler(
|
||||||
|
IUserRepository userRepository,
|
||||||
|
IEmailVerificationTokenRepository tokenRepository,
|
||||||
|
ISecurityTokenService tokenService,
|
||||||
|
IEmailService emailService,
|
||||||
|
IEmailTemplateService templateService,
|
||||||
|
ILogger<SendVerificationEmailCommandHandler> logger)
|
||||||
|
{
|
||||||
|
_userRepository = userRepository;
|
||||||
|
_tokenRepository = tokenRepository;
|
||||||
|
_tokenService = tokenService;
|
||||||
|
_emailService = emailService;
|
||||||
|
_templateService = templateService;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Unit> Handle(SendVerificationEmailCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var userId = UserId.Create(request.UserId);
|
||||||
|
var user = await _userRepository.GetByIdAsync(userId, cancellationToken);
|
||||||
|
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("User {UserId} not found, cannot send verification email", request.UserId);
|
||||||
|
return Unit.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If already verified, no need to send email
|
||||||
|
if (user.IsEmailVerified)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("User {UserId} email already verified, skipping verification email", request.UserId);
|
||||||
|
return Unit.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate token
|
||||||
|
var token = _tokenService.GenerateToken();
|
||||||
|
var tokenHash = _tokenService.HashToken(token);
|
||||||
|
|
||||||
|
// Create verification token entity
|
||||||
|
var verificationToken = EmailVerificationToken.Create(
|
||||||
|
userId,
|
||||||
|
tokenHash,
|
||||||
|
DateTime.UtcNow.AddHours(24));
|
||||||
|
|
||||||
|
await _tokenRepository.AddAsync(verificationToken, cancellationToken);
|
||||||
|
|
||||||
|
// Send email (non-blocking)
|
||||||
|
var verificationLink = $"{request.BaseUrl}/verify-email?token={token}";
|
||||||
|
var htmlBody = _templateService.RenderVerificationEmail(user.FullName.Value, verificationLink);
|
||||||
|
|
||||||
|
var emailMessage = new EmailMessage(
|
||||||
|
To: request.Email,
|
||||||
|
Subject: "Verify your email address - ColaFlow",
|
||||||
|
HtmlBody: htmlBody,
|
||||||
|
PlainTextBody: $"Click the link to verify your email: {verificationLink}");
|
||||||
|
|
||||||
|
var success = await _emailService.SendEmailAsync(emailMessage, cancellationToken);
|
||||||
|
|
||||||
|
if (!success)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Failed to send verification email to {Email} for user {UserId}",
|
||||||
|
request.Email,
|
||||||
|
request.UserId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Verification email sent to {Email} for user {UserId}",
|
||||||
|
request.Email,
|
||||||
|
request.UserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Unit.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace ColaFlow.Modules.Identity.Application.Commands.VerifyEmail;
|
||||||
|
|
||||||
|
public sealed record VerifyEmailCommand(string Token) : IRequest<bool>;
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
using ColaFlow.Modules.Identity.Application.Services;
|
||||||
|
using ColaFlow.Modules.Identity.Domain.Repositories;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace ColaFlow.Modules.Identity.Application.Commands.VerifyEmail;
|
||||||
|
|
||||||
|
public class VerifyEmailCommandHandler : IRequestHandler<VerifyEmailCommand, bool>
|
||||||
|
{
|
||||||
|
private readonly IEmailVerificationTokenRepository _tokenRepository;
|
||||||
|
private readonly IUserRepository _userRepository;
|
||||||
|
private readonly ISecurityTokenService _tokenService;
|
||||||
|
private readonly ILogger<VerifyEmailCommandHandler> _logger;
|
||||||
|
|
||||||
|
public VerifyEmailCommandHandler(
|
||||||
|
IEmailVerificationTokenRepository tokenRepository,
|
||||||
|
IUserRepository userRepository,
|
||||||
|
ISecurityTokenService tokenService,
|
||||||
|
ILogger<VerifyEmailCommandHandler> logger)
|
||||||
|
{
|
||||||
|
_tokenRepository = tokenRepository;
|
||||||
|
_userRepository = userRepository;
|
||||||
|
_tokenService = tokenService;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> Handle(VerifyEmailCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// Hash the token to look it up
|
||||||
|
var tokenHash = _tokenService.HashToken(request.Token);
|
||||||
|
var verificationToken = await _tokenRepository.GetByTokenHashAsync(tokenHash, cancellationToken);
|
||||||
|
|
||||||
|
if (verificationToken == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Email verification token not found");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!verificationToken.IsValid)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Email verification token is invalid. IsExpired: {IsExpired}, IsVerified: {IsVerified}",
|
||||||
|
verificationToken.IsExpired,
|
||||||
|
verificationToken.IsVerified);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user and mark email as verified
|
||||||
|
var user = await _userRepository.GetByIdAsync(verificationToken.UserId, cancellationToken);
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
_logger.LogError("User {UserId} not found for email verification", verificationToken.UserId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark token as verified
|
||||||
|
verificationToken.MarkAsVerified();
|
||||||
|
await _tokenRepository.UpdateAsync(verificationToken, cancellationToken);
|
||||||
|
|
||||||
|
// Mark user email as verified (will emit domain event)
|
||||||
|
user.VerifyEmail();
|
||||||
|
await _userRepository.UpdateAsync(user, cancellationToken);
|
||||||
|
|
||||||
|
_logger.LogInformation("Email verified for user {UserId}", user.Id);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace ColaFlow.Modules.Identity.Application.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service for generating and hashing security tokens
|
||||||
|
/// </summary>
|
||||||
|
public interface ISecurityTokenService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Generate a cryptographically secure random token (256-bit, base64url-encoded)
|
||||||
|
/// </summary>
|
||||||
|
string GenerateToken();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hash a token using SHA-256
|
||||||
|
/// </summary>
|
||||||
|
string HashToken(string token);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verify a token against a hash
|
||||||
|
/// </summary>
|
||||||
|
bool VerifyToken(string token, string hash);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
using ColaFlow.Shared.Kernel.Events;
|
||||||
|
|
||||||
|
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Users.Events;
|
||||||
|
|
||||||
|
public sealed record EmailVerifiedEvent(Guid UserId, string Email) : DomainEvent;
|
||||||
@@ -34,7 +34,10 @@ public sealed class User : AggregateRoot
|
|||||||
public DateTime? LastLoginAt { get; private set; }
|
public DateTime? LastLoginAt { get; private set; }
|
||||||
public DateTime? EmailVerifiedAt { get; private set; }
|
public DateTime? EmailVerifiedAt { get; private set; }
|
||||||
|
|
||||||
// Security
|
// Email verification status
|
||||||
|
public bool IsEmailVerified => EmailVerifiedAt.HasValue;
|
||||||
|
|
||||||
|
// Security (deprecated - moved to separate token entities)
|
||||||
public string? EmailVerificationToken { get; private set; }
|
public string? EmailVerificationToken { get; private set; }
|
||||||
public string? PasswordResetToken { get; private set; }
|
public string? PasswordResetToken { get; private set; }
|
||||||
public DateTime? PasswordResetTokenExpiresAt { get; private set; }
|
public DateTime? PasswordResetTokenExpiresAt { get; private set; }
|
||||||
@@ -159,9 +162,14 @@ public sealed class User : AggregateRoot
|
|||||||
|
|
||||||
public void VerifyEmail()
|
public void VerifyEmail()
|
||||||
{
|
{
|
||||||
|
if (IsEmailVerified)
|
||||||
|
return; // Already verified, idempotent
|
||||||
|
|
||||||
EmailVerifiedAt = DateTime.UtcNow;
|
EmailVerifiedAt = DateTime.UtcNow;
|
||||||
EmailVerificationToken = null;
|
EmailVerificationToken = null;
|
||||||
UpdatedAt = DateTime.UtcNow;
|
UpdatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
AddDomainEvent(new EmailVerifiedEvent(Id, Email));
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Suspend(string reason)
|
public void Suspend(string reason)
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
using ColaFlow.Shared.Kernel.Common;
|
||||||
|
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
|
||||||
|
|
||||||
|
namespace ColaFlow.Modules.Identity.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Email verification token entity.
|
||||||
|
/// Lifetime: 24 hours.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class EmailVerificationToken : Entity
|
||||||
|
{
|
||||||
|
public UserId UserId { get; private set; } = null!;
|
||||||
|
public string TokenHash { get; private set; } = string.Empty;
|
||||||
|
public DateTime ExpiresAt { get; private set; }
|
||||||
|
public DateTime? VerifiedAt { get; private set; }
|
||||||
|
public DateTime CreatedAt { get; private set; }
|
||||||
|
|
||||||
|
// Private constructor for EF Core
|
||||||
|
private EmailVerificationToken() : base()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Factory method to create new verification token.
|
||||||
|
/// </summary>
|
||||||
|
public static EmailVerificationToken Create(
|
||||||
|
UserId userId,
|
||||||
|
string tokenHash,
|
||||||
|
DateTime expiresAt)
|
||||||
|
{
|
||||||
|
return new EmailVerificationToken
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = userId,
|
||||||
|
TokenHash = tokenHash,
|
||||||
|
ExpiresAt = expiresAt,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsExpired => DateTime.UtcNow > ExpiresAt;
|
||||||
|
public bool IsVerified => VerifiedAt.HasValue;
|
||||||
|
public bool IsValid => !IsExpired && !IsVerified;
|
||||||
|
|
||||||
|
public void MarkAsVerified()
|
||||||
|
{
|
||||||
|
if (!IsValid)
|
||||||
|
throw new InvalidOperationException("Token is not valid for verification");
|
||||||
|
|
||||||
|
VerifiedAt = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
|
||||||
|
using ColaFlow.Modules.Identity.Domain.Entities;
|
||||||
|
|
||||||
|
namespace ColaFlow.Modules.Identity.Domain.Repositories;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Repository interface for EmailVerificationToken entity
|
||||||
|
/// </summary>
|
||||||
|
public interface IEmailVerificationTokenRepository
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Get verification token by token hash
|
||||||
|
/// </summary>
|
||||||
|
Task<EmailVerificationToken?> GetByTokenHashAsync(
|
||||||
|
string tokenHash,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get active verification token by user ID
|
||||||
|
/// </summary>
|
||||||
|
Task<EmailVerificationToken?> GetActiveByUserIdAsync(
|
||||||
|
UserId userId,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Add a new verification token
|
||||||
|
/// </summary>
|
||||||
|
Task AddAsync(
|
||||||
|
EmailVerificationToken token,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Update an existing verification token
|
||||||
|
/// </summary>
|
||||||
|
Task UpdateAsync(
|
||||||
|
EmailVerificationToken token,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -37,11 +37,13 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<IUserRepository, UserRepository>();
|
services.AddScoped<IUserRepository, UserRepository>();
|
||||||
services.AddScoped<IRefreshTokenRepository, RefreshTokenRepository>();
|
services.AddScoped<IRefreshTokenRepository, RefreshTokenRepository>();
|
||||||
services.AddScoped<IUserTenantRoleRepository, UserTenantRoleRepository>();
|
services.AddScoped<IUserTenantRoleRepository, UserTenantRoleRepository>();
|
||||||
|
services.AddScoped<IEmailVerificationTokenRepository, EmailVerificationTokenRepository>();
|
||||||
|
|
||||||
// Application Services
|
// Application Services
|
||||||
services.AddScoped<IJwtService, JwtService>();
|
services.AddScoped<IJwtService, JwtService>();
|
||||||
services.AddScoped<IPasswordHasher, PasswordHasher>();
|
services.AddScoped<IPasswordHasher, PasswordHasher>();
|
||||||
services.AddScoped<IRefreshTokenService, RefreshTokenService>();
|
services.AddScoped<IRefreshTokenService, RefreshTokenService>();
|
||||||
|
services.AddScoped<ISecurityTokenService, SecurityTokenService>();
|
||||||
|
|
||||||
// Email Services
|
// Email Services
|
||||||
var emailProvider = configuration["Email:Provider"] ?? "Mock";
|
var emailProvider = configuration["Email:Provider"] ?? "Mock";
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
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 EmailVerificationTokenConfiguration : IEntityTypeConfiguration<EmailVerificationToken>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<EmailVerificationToken> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("email_verification_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
|
||||||
|
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.VerifiedAt)
|
||||||
|
.HasColumnName("verified_at");
|
||||||
|
|
||||||
|
// Indexes
|
||||||
|
builder.HasIndex(t => t.TokenHash)
|
||||||
|
.HasDatabaseName("ix_email_verification_tokens_token_hash");
|
||||||
|
|
||||||
|
builder.HasIndex(t => t.UserId)
|
||||||
|
.HasDatabaseName("ix_email_verification_tokens_user_id");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
|
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
|
||||||
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
|
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
|
||||||
|
using ColaFlow.Modules.Identity.Domain.Entities;
|
||||||
using ColaFlow.Modules.Identity.Infrastructure.Services;
|
using ColaFlow.Modules.Identity.Infrastructure.Services;
|
||||||
using ColaFlow.Shared.Kernel.Common;
|
using ColaFlow.Shared.Kernel.Common;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
@@ -17,6 +18,7 @@ public class IdentityDbContext(
|
|||||||
public DbSet<User> Users => Set<User>();
|
public DbSet<User> Users => Set<User>();
|
||||||
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>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,370 @@
|
|||||||
|
// <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("20251103202856_AddEmailVerification")]
|
||||||
|
partial class AddEmailVerification
|
||||||
|
{
|
||||||
|
/// <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);
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddEmailVerification : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "email_verification_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),
|
||||||
|
verified_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_email_verification_tokens", x => x.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_email_verification_tokens_token_hash",
|
||||||
|
table: "email_verification_tokens",
|
||||||
|
column: "token_hash");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_email_verification_tokens_user_id",
|
||||||
|
table: "email_verification_tokens",
|
||||||
|
column: "user_id");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "email_verification_tokens");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -321,6 +321,46 @@ namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Migrations
|
|||||||
|
|
||||||
b.ToTable("user_tenant_roles", "identity");
|
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);
|
||||||
|
});
|
||||||
#pragma warning restore 612, 618
|
#pragma warning restore 612, 618
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
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 EmailVerificationTokenRepository(IdentityDbContext context) : IEmailVerificationTokenRepository
|
||||||
|
{
|
||||||
|
public async Task<EmailVerificationToken?> GetByTokenHashAsync(
|
||||||
|
string tokenHash,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await context.EmailVerificationTokens
|
||||||
|
.FirstOrDefaultAsync(t => t.TokenHash == tokenHash, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<EmailVerificationToken?> GetActiveByUserIdAsync(
|
||||||
|
UserId userId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await context.EmailVerificationTokens
|
||||||
|
.Where(t => t.UserId == userId && t.VerifiedAt == null && t.ExpiresAt > DateTime.UtcNow)
|
||||||
|
.OrderByDescending(t => t.CreatedAt)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task AddAsync(
|
||||||
|
EmailVerificationToken token,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await context.EmailVerificationTokens.AddAsync(token, cancellationToken);
|
||||||
|
await context.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateAsync(
|
||||||
|
EmailVerificationToken token,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
context.EmailVerificationTokens.Update(token);
|
||||||
|
await context.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using ColaFlow.Modules.Identity.Application.Services;
|
||||||
|
|
||||||
|
namespace ColaFlow.Modules.Identity.Infrastructure.Services;
|
||||||
|
|
||||||
|
public class SecurityTokenService : ISecurityTokenService
|
||||||
|
{
|
||||||
|
public string GenerateToken()
|
||||||
|
{
|
||||||
|
var tokenBytes = new byte[32]; // 256 bits
|
||||||
|
using var rng = RandomNumberGenerator.Create();
|
||||||
|
rng.GetBytes(tokenBytes);
|
||||||
|
|
||||||
|
// Base64URL encoding (URL-safe, no padding)
|
||||||
|
return Convert.ToBase64String(tokenBytes)
|
||||||
|
.Replace("+", "-")
|
||||||
|
.Replace("/", "_")
|
||||||
|
.TrimEnd('=');
|
||||||
|
}
|
||||||
|
|
||||||
|
public string HashToken(string token)
|
||||||
|
{
|
||||||
|
using var sha256 = SHA256.Create();
|
||||||
|
var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(token));
|
||||||
|
return Convert.ToBase64String(hashBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool VerifyToken(string token, string hash)
|
||||||
|
{
|
||||||
|
var computedHash = HashToken(token);
|
||||||
|
return CryptographicOperations.FixedTimeEquals(
|
||||||
|
Encoding.UTF8.GetBytes(computedHash),
|
||||||
|
Encoding.UTF8.GetBytes(hash));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user