feat: implement IAM OTP dispatch abstractions

Co-authored-by: Velik <hongochai10@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-02-23 11:43:30 +00:00
parent 82421efea7
commit a414d7d528
4 changed files with 139 additions and 15 deletions

View File

@@ -1,4 +1,5 @@
using MediatR;
using IamService.API.Application.Services;
using IamService.Domain.AggregatesModel.VerificationAggregate;
using IamService.Domain.Exceptions;
@@ -12,13 +13,16 @@ public class RequestPhoneVerificationCommandHandler
: IRequestHandler<RequestPhoneVerificationCommand, RequestVerificationCommandResult>
{
private readonly IIdentityVerificationRepository _verificationRepository;
private readonly IVerificationOtpDispatcher _otpDispatcher;
private readonly ILogger<RequestPhoneVerificationCommandHandler> _logger;
public RequestPhoneVerificationCommandHandler(
IIdentityVerificationRepository verificationRepository,
IVerificationOtpDispatcher otpDispatcher,
ILogger<RequestPhoneVerificationCommandHandler> logger)
{
_verificationRepository = verificationRepository;
_otpDispatcher = otpDispatcher;
_logger = logger;
}
@@ -50,12 +54,11 @@ public class RequestPhoneVerificationCommandHandler
await _verificationRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
// EN: TODO: Send OTP via SMS service
// VI: TODO: Gửi OTP qua SMS service
_logger.LogInformation(
"Phone verification created for user {UserId}. OTP: {OTP} (in production, send via SMS)",
await _otpDispatcher.SendPhoneOtpAsync(
request.UserId,
otp); // EN: Remove OTP from logs in production!
request.PhoneNumber,
otp,
cancellationToken);
return new RequestVerificationCommandResult(
verification.Id,
@@ -73,13 +76,16 @@ public class RequestEmailVerificationCommandHandler
: IRequestHandler<RequestEmailVerificationCommand, RequestVerificationCommandResult>
{
private readonly IIdentityVerificationRepository _verificationRepository;
private readonly IVerificationOtpDispatcher _otpDispatcher;
private readonly ILogger<RequestEmailVerificationCommandHandler> _logger;
public RequestEmailVerificationCommandHandler(
IIdentityVerificationRepository verificationRepository,
IVerificationOtpDispatcher otpDispatcher,
ILogger<RequestEmailVerificationCommandHandler> logger)
{
_verificationRepository = verificationRepository;
_otpDispatcher = otpDispatcher;
_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.");
}
// 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
var (verification, otp) = IdentityVerification.CreatePhoneVerification(
var (verification, otp) = IdentityVerification.CreateEmailVerification(
request.UserId,
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);
await _verificationRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
// EN: TODO: Send OTP via Email service
// VI: TODO: Gửi OTP qua Email service
_logger.LogInformation(
"Email verification created for user {UserId}. OTP: {OTP} (in production, send via Email)",
await _otpDispatcher.SendEmailOtpAsync(
request.UserId,
otp);
request.Email,
otp,
cancellationToken);
return new RequestVerificationCommandResult(
verification.Id,

View File

@@ -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..]}";
}
}

View File

@@ -1,6 +1,7 @@
using Asp.Versioning;
using FluentValidation;
using Hellang.Middleware.ProblemDetails;
using IamService.API.Application.Services;
using IamService.API.Application.Behaviors;
using IamService.API.Swagger;
using IamService.Infrastructure;
@@ -21,6 +22,7 @@ builder.Host.UseSerilog((context, services, configuration) => configuration
// EN: Add 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.AddScoped<IVerificationOtpDispatcher, VerificationOtpDispatcher>();
// EN: Add Authorization Policies for Backoffice APIs
// VI: Thêm Authorization Policies cho các API Backoffice

View File

@@ -176,6 +176,35 @@ public class IdentityVerification : Entity, IAggregateRoot
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>
/// EN: Create document verification for KYC.
/// VI: Tạo xác thực tài liệu cho KYC.