using Meezi.API.Models.Auth; using Meezi.API.Models.Consumer; using Meezi.API.Security; using Meezi.Core.Constants; using Meezi.Core.Entities; using Meezi.Core.Interfaces; using Meezi.Core.Utilities; using Meezi.Infrastructure.Data; using Microsoft.EntityFrameworkCore; using StackExchange.Redis; namespace Meezi.API.Services; public interface IConsumerAuthService { Task<(bool Success, SendOtpResponse? Data, string? ErrorCode, string? ErrorMessage)> SendOtpAsync( SendOtpRequest request, CancellationToken cancellationToken = default); Task<(bool Success, ConsumerAuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> VerifyOtpAsync( VerifyOtpRequest request, CancellationToken cancellationToken = default); Task<(bool Success, ConsumerAuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> RefreshAsync( RefreshTokenRequest request, CancellationToken cancellationToken = default); } public class ConsumerAuthService : IConsumerAuthService { private const int OtpTtlSeconds = 300; private const int DefaultMaxOtpAttemptsPerHour = 5; private readonly AppDbContext _db; private readonly IConnectionMultiplexer _redis; private readonly ISmsService _smsService; private readonly IJwtTokenService _jwtTokenService; private readonly IRefreshTokenStore _refreshTokenStore; private readonly IConfiguration _configuration; private readonly ILogger _logger; private readonly IAbuseProtectionService _abuse; private readonly IHttpContextAccessor _http; public ConsumerAuthService( AppDbContext db, IConnectionMultiplexer redis, ISmsService smsService, IJwtTokenService jwtTokenService, IRefreshTokenStore refreshTokenStore, IConfiguration configuration, IAbuseProtectionService abuse, IHttpContextAccessor http, ILogger logger) { _db = db; _redis = redis; _smsService = smsService; _jwtTokenService = jwtTokenService; _refreshTokenStore = refreshTokenStore; _configuration = configuration; _abuse = abuse; _http = http; _logger = logger; } public async Task<(bool Success, SendOtpResponse? Data, string? ErrorCode, string? ErrorMessage)> SendOtpAsync( SendOtpRequest request, CancellationToken cancellationToken = default) { var phone = PhoneNormalizer.Normalize(request.Phone); var redis = _redis.GetDatabase(); var maxAttempts = _configuration.GetValue("Auth:MaxOtpAttemptsPerHour", DefaultMaxOtpAttemptsPerHour); if (_http.HttpContext is not null) { var ip = ClientIpResolver.GetClientIp(_http.HttpContext); var ipCheck = await _abuse.CheckAuthOtpByIpAsync(ip, cancellationToken); if (!ipCheck.Allowed) return (false, null, ipCheck.ErrorCode, ipCheck.Message); } var attemptsKey = $"consumer-otp:attempts:{phone}"; if (maxAttempts > 0) { var attempts = await redis.StringGetAsync(attemptsKey); if (attempts.HasValue && (int)attempts >= maxAttempts) return (false, null, "RATE_LIMITED", "Too many OTP requests. Try again later."); } var otp = Random.Shared.Next(100000, 999999).ToString(); await redis.StringSetAsync($"consumer-otp:{phone}", otp, TimeSpan.FromSeconds(OtpTtlSeconds)); try { await _smsService.SendOtpAsync(phone, otp, cancellationToken); } catch (Exception ex) { _logger.LogError(ex, "Failed to send consumer OTP SMS"); return (false, null, "SMS_FAILED", "Could not send verification code."); } if (maxAttempts > 0) { var newAttempts = await redis.StringIncrementAsync(attemptsKey); if (newAttempts == 1) await redis.KeyExpireAsync(attemptsKey, TimeSpan.FromHours(1)); } return (true, new SendOtpResponse(true, OtpTtlSeconds), null, null); } public async Task<(bool Success, ConsumerAuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> VerifyOtpAsync( VerifyOtpRequest request, CancellationToken cancellationToken = default) { var phone = PhoneNormalizer.Normalize(request.Phone); var code = OtpNormalizer.Normalize(request.Code); if (!OtpNormalizer.IsValidSixDigitCode(code)) return (false, null, "INVALID_OTP", "Invalid or expired verification code."); var redis = _redis.GetDatabase(); var storedOtp = await redis.StringGetAsync($"consumer-otp:{phone}"); if (storedOtp.IsNullOrEmpty || storedOtp.ToString() != code) return (false, null, "INVALID_OTP", "Invalid or expired verification code."); var account = await _db.ConsumerAccounts .FirstOrDefaultAsync(a => a.Phone == phone, cancellationToken); if (account is null) { account = new ConsumerAccount { Phone = phone }; _db.ConsumerAccounts.Add(account); await _db.SaveChangesAsync(cancellationToken); } await redis.KeyDeleteAsync($"consumer-otp:{phone}"); var tokens = await IssueTokensAsync(account, cancellationToken); return (true, tokens, null, null); } public async Task<(bool Success, ConsumerAuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> RefreshAsync( RefreshTokenRequest request, CancellationToken cancellationToken = default) { var payload = await _refreshTokenStore.GetAsync(request.RefreshToken, cancellationToken); if (payload is null || payload.Actor != MeeziActorKinds.Consumer) return (false, null, "INVALID_TOKEN", "Refresh token is invalid or expired."); var account = await _db.ConsumerAccounts .FirstOrDefaultAsync(a => a.Id == payload.UserId, cancellationToken); if (account is null) return (false, null, "NOT_FOUND", "Account no longer exists."); await _refreshTokenStore.RevokeAsync(request.RefreshToken, cancellationToken); var tokens = await IssueTokensAsync(account, cancellationToken); return (true, tokens, null, null); } private async Task IssueTokensAsync( ConsumerAccount account, CancellationToken cancellationToken) { var lang = "fa"; var accessToken = _jwtTokenService.CreateConsumerAccessToken(account, lang); var refreshToken = _jwtTokenService.CreateRefreshToken(); var refreshDays = _configuration.GetValue("Jwt:RefreshTokenExpiryDays", 30); await _refreshTokenStore.StoreAsync( refreshToken, new RefreshTokenPayload( account.Id, string.Empty, MeeziRoles.Customer, string.Empty, lang, MeeziActorKinds.Consumer), TimeSpan.FromDays(refreshDays), cancellationToken); return new ConsumerAuthTokenResponse( accessToken, refreshToken, _jwtTokenService.GetAccessTokenExpiry(), account.Id, account.Phone, lang); } }