using FrameworkBLL.EIPConversation.EIPHandlers.EIPEngine.ResponseEngine; using FrameworkDAL.CustomCode.EIPConversation.EIPOtp; using FrameworkDAL.DTO.EIPConversation; using Microsoft.Extensions.Logging; using System; using System.Security.Cryptography; using System.Text; using System.Threading; using System.Threading.Tasks; namespace FrameworkBLL.EIPConversation.EIPHandlers.EIPOTP { // ============================================================ // EIPOtpService — real OTP issue/verify. // // The OTP itself is never stored in plaintext: only its SHA-256 hash // (matching DirectActionTokenService's own hashing convention) is // persisted, with a short expiry and an attempt-count lockout. // Delivery reuses IEIPResponseEngine — the same channel-handler // resolution, rate limiting, and phase trace already used for every // other outbound EIP message, rather than a parallel send path. // ============================================================ public class EIPOtpService : IEIPOtpService { private const int OtpValidityMinutes = 5; private const int MaxAttempts = 5; private readonly IEIPOtpDAL _otpDAL; private readonly IEIPResponseEngine _responseEngine; private readonly ILogger _logger; public EIPOtpService( IEIPOtpDAL otpDAL, IEIPResponseEngine responseEngine, ILogger logger) { _otpDAL = otpDAL ?? throw new ArgumentNullException(nameof(otpDAL)); _responseEngine = responseEngine ?? throw new ArgumentNullException(nameof(responseEngine)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task HasPendingOtpAsync( string capabilityCode, EIPExecutionContextDTO context, CancellationToken cancellationToken) { var tenantId = ResolveTenantId(context); var pending = await _otpDAL.GetPendingAsync( tenantId, context.UserIdentifier, capabilityCode, context.LoginDTO, cancellationToken) .ConfigureAwait(false); return pending is not null; } public async Task GenerateOtpAsync( string capabilityCode, EIPExecutionContextDTO context, CancellationToken cancellationToken) { try { var otp = RandomNumberGenerator.GetInt32(100000, 1000000).ToString(); var hash = HashOtp(otp); var tenantId = ResolveTenantId(context); await _otpDAL.IssueAsync( tenantId, context.UserIdentifier, capabilityCode, hash, DateTime.UtcNow.AddMinutes(OtpValidityMinutes), context.LoginDTO, cancellationToken).ConfigureAwait(false); _logger.LogInformation( "OTP issued | User={User} | Capability={Capability}", context.UserIdentifier, capabilityCode); var responseContext = new EIPResponseContext { Channel = context.Channel, ChannelType = context.ChannelType, Recipient = context.UserIdentifier, UserIdentifier = context.UserIdentifier, TenantId = tenantId, FlowCode = context.FlowCode ?? string.Empty, Message = $"Your verification code is {otp}. It expires in {OtpValidityMinutes} minutes.", // The generic per-recipient anti-spam rate limit has no concept of message // importance -- a completely normal fast conversation turn (e.g. answering // the previous prompt within 2s) would otherwise silently drop the OTP itself. BypassRateLimit = true }; var deliveryResult = await _responseEngine .GenerateAsync(responseContext, context.LoginDTO, cancellationToken) .ConfigureAwait(false); if (deliveryResult?.IsSent != true) { _logger.LogError( "OTP generated but delivery failed | User={User} | Capability={Capability} | Status={Status}", context.UserIdentifier, capabilityCode, deliveryResult?.Status); // Never tell the user a code was sent when delivery genuinely failed -- // the caller (EIPCapabilityEngine) unconditionally returns a "code sent" // pause message otherwise, which would strand the user waiting for an // OTP that never arrived. throw new InvalidOperationException( $"OTP delivery failed: {deliveryResult?.Status ?? "unknown error"}"); } } catch (Exception ex) { _logger.LogError(ex, "Error generating OTP | Capability={Capability}", capabilityCode); throw; } } public async Task VerifyOtpAsync( string capabilityCode, string otp, EIPExecutionContextDTO context, CancellationToken cancellationToken) { try { var tenantId = ResolveTenantId(context); var pending = await _otpDAL.GetPendingAsync( tenantId, context.UserIdentifier, capabilityCode, context.LoginDTO, cancellationToken) .ConfigureAwait(false); if (pending is null) { _logger.LogWarning( "OTP verification attempted with no pending record | User={User} | Capability={Capability}", context.UserIdentifier, capabilityCode); return false; } var suppliedHash = HashOtp(otp?.Trim() ?? string.Empty); var matches = CryptographicOperations.FixedTimeEquals( Encoding.UTF8.GetBytes(suppliedHash), Encoding.UTF8.GetBytes(pending.OtpHash)); if (!matches) { await _otpDAL.IncrementAttemptAsync( pending.OtpRecordId, MaxAttempts, context.LoginDTO, cancellationToken) .ConfigureAwait(false); _logger.LogWarning( "OTP verification failed | User={User} | Capability={Capability} | AttemptCount={AttemptCount}", context.UserIdentifier, capabilityCode, pending.AttemptCount + 1); return false; } await _otpDAL.MarkVerifiedAsync(pending.OtpRecordId, context.LoginDTO, cancellationToken) .ConfigureAwait(false); _logger.LogInformation( "OTP verified | User={User} | Capability={Capability}", context.UserIdentifier, capabilityCode); return true; } catch (Exception ex) { _logger.LogError(ex, "Error verifying OTP | Capability={Capability}", capabilityCode); throw; } } private static int ResolveTenantId(EIPExecutionContextDTO context) => context.TenantId ?? context.LoginDTO.ClientId; private static string HashOtp(string otp) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(otp))).ToLowerInvariant(); } }