191 lines
7.2 KiB
C#
191 lines
7.2 KiB
C#
|
|
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<ConsumerAuthService> _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<ConsumerAuthService> 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<ConsumerAuthTokenResponse> 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);
|
||
|
|
}
|
||
|
|
}
|