diff --git a/services/iam-service-net/src/IamService.API/Application/Commands/Verifications/RequestVerificationCommandHandler.cs b/services/iam-service-net/src/IamService.API/Application/Commands/Verifications/RequestVerificationCommandHandler.cs index b46b4ec2..6ece0ff4 100644 --- a/services/iam-service-net/src/IamService.API/Application/Commands/Verifications/RequestVerificationCommandHandler.cs +++ b/services/iam-service-net/src/IamService.API/Application/Commands/Verifications/RequestVerificationCommandHandler.cs @@ -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 { private readonly IIdentityVerificationRepository _verificationRepository; + private readonly IVerificationOtpDispatcher _otpDispatcher; private readonly ILogger _logger; public RequestPhoneVerificationCommandHandler( IIdentityVerificationRepository verificationRepository, + IVerificationOtpDispatcher otpDispatcher, ILogger 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 { private readonly IIdentityVerificationRepository _verificationRepository; + private readonly IVerificationOtpDispatcher _otpDispatcher; private readonly ILogger _logger; public RequestEmailVerificationCommandHandler( IIdentityVerificationRepository verificationRepository, + IVerificationOtpDispatcher otpDispatcher, ILogger 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, diff --git a/services/iam-service-net/src/IamService.API/Application/Services/VerificationOtpDispatcher.cs b/services/iam-service-net/src/IamService.API/Application/Services/VerificationOtpDispatcher.cs new file mode 100644 index 00000000..7c6b0c30 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Services/VerificationOtpDispatcher.cs @@ -0,0 +1,91 @@ +using IamService.Infrastructure.Email; +using System.Linq; + +namespace IamService.API.Application.Services; + +/// +/// EN: OTP dispatcher abstraction for verification workflows. +/// VI: Abstraction gửi OTP cho các luồng xác thực. +/// +public interface IVerificationOtpDispatcher +{ + /// + /// EN: Send OTP to phone number. + /// VI: Gửi OTP đến số điện thoại. + /// + Task SendPhoneOtpAsync(Guid userId, string phoneNumber, string otp, CancellationToken cancellationToken = default); + + /// + /// EN: Send OTP to email address. + /// VI: Gửi OTP đến địa chỉ email. + /// + Task SendEmailOtpAsync(Guid userId, string email, string otp, CancellationToken cancellationToken = default); +} + +/// +/// EN: Default OTP dispatcher implementation. +/// VI: Implementation mặc định của OTP dispatcher. +/// +public class VerificationOtpDispatcher : IVerificationOtpDispatcher +{ + private readonly IEmailService _emailService; + private readonly ILogger _logger; + + public VerificationOtpDispatcher( + IEmailService emailService, + ILogger 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..]}"; + } +} diff --git a/services/iam-service-net/src/IamService.API/Program.cs b/services/iam-service-net/src/IamService.API/Program.cs index 01b74ec3..292ecc32 100644 --- a/services/iam-service-net/src/IamService.API/Program.cs +++ b/services/iam-service-net/src/IamService.API/Program.cs @@ -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(); // EN: Add Authorization Policies for Backoffice APIs // VI: Thêm Authorization Policies cho các API Backoffice diff --git a/services/iam-service-net/src/IamService.Domain/AggregatesModel/VerificationAggregate/IdentityVerification.cs b/services/iam-service-net/src/IamService.Domain/AggregatesModel/VerificationAggregate/IdentityVerification.cs index fdab75dc..83fa4285 100644 --- a/services/iam-service-net/src/IamService.Domain/AggregatesModel/VerificationAggregate/IdentityVerification.cs +++ b/services/iam-service-net/src/IamService.Domain/AggregatesModel/VerificationAggregate/IdentityVerification.cs @@ -176,6 +176,35 @@ public class IdentityVerification : Entity, IAggregateRoot return (verification, otp); } + /// + /// EN: Create email verification with OTP. + /// VI: Tạo xác thực email với OTP. + /// + /// Tuple of (IdentityVerification, plainTextOtp) + 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); + } + /// /// EN: Create document verification for KYC. /// VI: Tạo xác thực tài liệu cho KYC.