feat: implement IAM OTP dispatch abstractions
Co-authored-by: Velik <hongochai10@users.noreply.github.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
using MediatR;
|
using MediatR;
|
||||||
|
using IamService.API.Application.Services;
|
||||||
using IamService.Domain.AggregatesModel.VerificationAggregate;
|
using IamService.Domain.AggregatesModel.VerificationAggregate;
|
||||||
using IamService.Domain.Exceptions;
|
using IamService.Domain.Exceptions;
|
||||||
|
|
||||||
@@ -12,13 +13,16 @@ public class RequestPhoneVerificationCommandHandler
|
|||||||
: IRequestHandler<RequestPhoneVerificationCommand, RequestVerificationCommandResult>
|
: IRequestHandler<RequestPhoneVerificationCommand, RequestVerificationCommandResult>
|
||||||
{
|
{
|
||||||
private readonly IIdentityVerificationRepository _verificationRepository;
|
private readonly IIdentityVerificationRepository _verificationRepository;
|
||||||
|
private readonly IVerificationOtpDispatcher _otpDispatcher;
|
||||||
private readonly ILogger<RequestPhoneVerificationCommandHandler> _logger;
|
private readonly ILogger<RequestPhoneVerificationCommandHandler> _logger;
|
||||||
|
|
||||||
public RequestPhoneVerificationCommandHandler(
|
public RequestPhoneVerificationCommandHandler(
|
||||||
IIdentityVerificationRepository verificationRepository,
|
IIdentityVerificationRepository verificationRepository,
|
||||||
|
IVerificationOtpDispatcher otpDispatcher,
|
||||||
ILogger<RequestPhoneVerificationCommandHandler> logger)
|
ILogger<RequestPhoneVerificationCommandHandler> logger)
|
||||||
{
|
{
|
||||||
_verificationRepository = verificationRepository;
|
_verificationRepository = verificationRepository;
|
||||||
|
_otpDispatcher = otpDispatcher;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,12 +54,11 @@ public class RequestPhoneVerificationCommandHandler
|
|||||||
|
|
||||||
await _verificationRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
await _verificationRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
||||||
|
|
||||||
// EN: TODO: Send OTP via SMS service
|
await _otpDispatcher.SendPhoneOtpAsync(
|
||||||
// VI: TODO: Gửi OTP qua SMS service
|
|
||||||
_logger.LogInformation(
|
|
||||||
"Phone verification created for user {UserId}. OTP: {OTP} (in production, send via SMS)",
|
|
||||||
request.UserId,
|
request.UserId,
|
||||||
otp); // EN: Remove OTP from logs in production!
|
request.PhoneNumber,
|
||||||
|
otp,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
return new RequestVerificationCommandResult(
|
return new RequestVerificationCommandResult(
|
||||||
verification.Id,
|
verification.Id,
|
||||||
@@ -73,13 +76,16 @@ public class RequestEmailVerificationCommandHandler
|
|||||||
: IRequestHandler<RequestEmailVerificationCommand, RequestVerificationCommandResult>
|
: IRequestHandler<RequestEmailVerificationCommand, RequestVerificationCommandResult>
|
||||||
{
|
{
|
||||||
private readonly IIdentityVerificationRepository _verificationRepository;
|
private readonly IIdentityVerificationRepository _verificationRepository;
|
||||||
|
private readonly IVerificationOtpDispatcher _otpDispatcher;
|
||||||
private readonly ILogger<RequestEmailVerificationCommandHandler> _logger;
|
private readonly ILogger<RequestEmailVerificationCommandHandler> _logger;
|
||||||
|
|
||||||
public RequestEmailVerificationCommandHandler(
|
public RequestEmailVerificationCommandHandler(
|
||||||
IIdentityVerificationRepository verificationRepository,
|
IIdentityVerificationRepository verificationRepository,
|
||||||
|
IVerificationOtpDispatcher otpDispatcher,
|
||||||
ILogger<RequestEmailVerificationCommandHandler> logger)
|
ILogger<RequestEmailVerificationCommandHandler> logger)
|
||||||
{
|
{
|
||||||
_verificationRepository = verificationRepository;
|
_verificationRepository = verificationRepository;
|
||||||
|
_otpDispatcher = otpDispatcher;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,25 +107,21 @@ public class RequestEmailVerificationCommandHandler
|
|||||||
throw new DomainException("An email verification is already pending. Please wait for it to expire or complete it.");
|
throw new DomainException("An email verification is already pending. Please wait for it to expire or complete it.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// EN: Create new email verification (using phone verification method with email type)
|
// EN: Create new email verification
|
||||||
// VI: Tạo xác thực email mới
|
// VI: Tạo xác thực email mới
|
||||||
var (verification, otp) = IdentityVerification.CreatePhoneVerification(
|
var (verification, otp) = IdentityVerification.CreateEmailVerification(
|
||||||
request.UserId,
|
request.UserId,
|
||||||
request.Email);
|
request.Email);
|
||||||
|
|
||||||
// EN: Update type to Email
|
|
||||||
// VI: Cập nhật loại thành Email
|
|
||||||
// Note: In real implementation, we would have a separate factory method for email
|
|
||||||
_verificationRepository.Add(verification);
|
_verificationRepository.Add(verification);
|
||||||
|
|
||||||
await _verificationRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
await _verificationRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
||||||
|
|
||||||
// EN: TODO: Send OTP via Email service
|
await _otpDispatcher.SendEmailOtpAsync(
|
||||||
// VI: TODO: Gửi OTP qua Email service
|
|
||||||
_logger.LogInformation(
|
|
||||||
"Email verification created for user {UserId}. OTP: {OTP} (in production, send via Email)",
|
|
||||||
request.UserId,
|
request.UserId,
|
||||||
otp);
|
request.Email,
|
||||||
|
otp,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
return new RequestVerificationCommandResult(
|
return new RequestVerificationCommandResult(
|
||||||
verification.Id,
|
verification.Id,
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
using IamService.Infrastructure.Email;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace IamService.API.Application.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// EN: OTP dispatcher abstraction for verification workflows.
|
||||||
|
/// VI: Abstraction gửi OTP cho các luồng xác thực.
|
||||||
|
/// </summary>
|
||||||
|
public interface IVerificationOtpDispatcher
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// EN: Send OTP to phone number.
|
||||||
|
/// VI: Gửi OTP đến số điện thoại.
|
||||||
|
/// </summary>
|
||||||
|
Task SendPhoneOtpAsync(Guid userId, string phoneNumber, string otp, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// EN: Send OTP to email address.
|
||||||
|
/// VI: Gửi OTP đến địa chỉ email.
|
||||||
|
/// </summary>
|
||||||
|
Task SendEmailOtpAsync(Guid userId, string email, string otp, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// EN: Default OTP dispatcher implementation.
|
||||||
|
/// VI: Implementation mặc định của OTP dispatcher.
|
||||||
|
/// </summary>
|
||||||
|
public class VerificationOtpDispatcher : IVerificationOtpDispatcher
|
||||||
|
{
|
||||||
|
private readonly IEmailService _emailService;
|
||||||
|
private readonly ILogger<VerificationOtpDispatcher> _logger;
|
||||||
|
|
||||||
|
public VerificationOtpDispatcher(
|
||||||
|
IEmailService emailService,
|
||||||
|
ILogger<VerificationOtpDispatcher> logger)
|
||||||
|
{
|
||||||
|
_emailService = emailService;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task SendPhoneOtpAsync(
|
||||||
|
Guid userId,
|
||||||
|
string phoneNumber,
|
||||||
|
string otp,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Phone OTP dispatch requested for user {UserId} to {MaskedPhone}.",
|
||||||
|
userId,
|
||||||
|
MaskPhoneNumber(phoneNumber));
|
||||||
|
|
||||||
|
// EN: Placeholder SMS dispatch point (provider integration can be plugged in later).
|
||||||
|
// VI: Điểm tích hợp gửi SMS (có thể cắm provider sau).
|
||||||
|
_ = otp;
|
||||||
|
_ = cancellationToken;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SendEmailOtpAsync(
|
||||||
|
Guid userId,
|
||||||
|
string email,
|
||||||
|
string otp,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await _emailService.Send2FACodeAsync(email, otp, cancellationToken);
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Email OTP dispatched for user {UserId} to {MaskedEmail}.",
|
||||||
|
userId,
|
||||||
|
MaskEmail(email));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string MaskEmail(string email)
|
||||||
|
{
|
||||||
|
var parts = email.Split('@');
|
||||||
|
if (parts.Length != 2 || parts[0].Length < 2)
|
||||||
|
return "***";
|
||||||
|
|
||||||
|
var localPart = parts[0];
|
||||||
|
var maskedLocal = $"{localPart[0]}***{localPart[^1]}";
|
||||||
|
return $"{maskedLocal}@{parts[1]}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string MaskPhoneNumber(string phoneNumber)
|
||||||
|
{
|
||||||
|
var digits = new string(phoneNumber.Where(char.IsDigit).ToArray());
|
||||||
|
if (digits.Length <= 4)
|
||||||
|
return "****";
|
||||||
|
return $"***{digits[^4..]}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using Asp.Versioning;
|
using Asp.Versioning;
|
||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Hellang.Middleware.ProblemDetails;
|
using Hellang.Middleware.ProblemDetails;
|
||||||
|
using IamService.API.Application.Services;
|
||||||
using IamService.API.Application.Behaviors;
|
using IamService.API.Application.Behaviors;
|
||||||
using IamService.API.Swagger;
|
using IamService.API.Swagger;
|
||||||
using IamService.Infrastructure;
|
using IamService.Infrastructure;
|
||||||
@@ -21,6 +22,7 @@ builder.Host.UseSerilog((context, services, configuration) => configuration
|
|||||||
// EN: Add Infrastructure services (Identity, Duende IdentityServer, Repositories)
|
// EN: Add Infrastructure services (Identity, Duende IdentityServer, Repositories)
|
||||||
// VI: Thêm Infrastructure services (Identity, Duende IdentityServer, Repositories)
|
// VI: Thêm Infrastructure services (Identity, Duende IdentityServer, Repositories)
|
||||||
builder.Services.AddInfrastructure(builder.Configuration, builder.Environment.EnvironmentName);
|
builder.Services.AddInfrastructure(builder.Configuration, builder.Environment.EnvironmentName);
|
||||||
|
builder.Services.AddScoped<IVerificationOtpDispatcher, VerificationOtpDispatcher>();
|
||||||
|
|
||||||
// EN: Add Authorization Policies for Backoffice APIs
|
// EN: Add Authorization Policies for Backoffice APIs
|
||||||
// VI: Thêm Authorization Policies cho các API Backoffice
|
// VI: Thêm Authorization Policies cho các API Backoffice
|
||||||
|
|||||||
@@ -176,6 +176,35 @@ public class IdentityVerification : Entity, IAggregateRoot
|
|||||||
return (verification, otp);
|
return (verification, otp);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// EN: Create email verification with OTP.
|
||||||
|
/// VI: Tạo xác thực email với OTP.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Tuple of (IdentityVerification, plainTextOtp)</returns>
|
||||||
|
public static (IdentityVerification Verification, string Otp) CreateEmailVerification(
|
||||||
|
Guid userId,
|
||||||
|
string email)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(email))
|
||||||
|
throw new ArgumentException("Email cannot be empty", nameof(email));
|
||||||
|
|
||||||
|
var verification = new IdentityVerification(
|
||||||
|
userId,
|
||||||
|
VerificationType.Email,
|
||||||
|
email.Trim(),
|
||||||
|
OtpExpirationMinutes);
|
||||||
|
|
||||||
|
var otp = GenerateOtp();
|
||||||
|
verification._verificationCodeHash = HashOtp(otp);
|
||||||
|
|
||||||
|
verification.AddDomainEvent(new VerificationRequestedEvent(
|
||||||
|
verification.Id,
|
||||||
|
userId,
|
||||||
|
VerificationType.Email.Name));
|
||||||
|
|
||||||
|
return (verification, otp);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// EN: Create document verification for KYC.
|
/// EN: Create document verification for KYC.
|
||||||
/// VI: Tạo xác thực tài liệu cho KYC.
|
/// VI: Tạo xác thực tài liệu cho KYC.
|
||||||
|
|||||||
Reference in New Issue
Block a user