using Meezi.Admin.API.Models; using Meezi.Core.Constants; using Meezi.Core.Enums; using Meezi.Core.Interfaces; using Meezi.Core.Utilities; using Meezi.Infrastructure.Data; using Microsoft.EntityFrameworkCore; using StackExchange.Redis; namespace Meezi.Admin.API.Services; public interface IAdminAuthService { Task<(bool Success, SendOtpResponse? Data, string? ErrorCode, string? ErrorMessage)> SendOtpAsync( SendOtpRequest request, CancellationToken cancellationToken = default); Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> VerifyOtpAsync( VerifyOtpRequest request, CancellationToken cancellationToken = default); Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> LoginWithPasswordAsync( LoginWithPasswordRequest request, CancellationToken cancellationToken = default); Task<(bool Success, string? ErrorCode, string? ErrorMessage)> ChangePasswordAsync( string adminId, ChangePasswordRequest request, CancellationToken cancellationToken = default); Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> RefreshAsync( RefreshTokenRequest request, CancellationToken cancellationToken = default); } public class AdminAuthService : IAdminAuthService { private const int OtpTtlSeconds = 300; private const int DefaultMaxOtpAttemptsPerHour = 5; private readonly AppDbContext _db; private readonly IConnectionMultiplexer _redis; private readonly ISmsService _smsService; private readonly IAdminJwtTokenService _jwtTokenService; private readonly IRefreshTokenStore _refreshTokenStore; private readonly IConfiguration _configuration; private readonly ILogger _logger; public AdminAuthService( AppDbContext db, IConnectionMultiplexer redis, ISmsService smsService, IAdminJwtTokenService jwtTokenService, IRefreshTokenStore refreshTokenStore, IConfiguration configuration, ILogger logger) { _db = db; _redis = redis; _smsService = smsService; _jwtTokenService = jwtTokenService; _refreshTokenStore = refreshTokenStore; _configuration = configuration; _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 admin = await _db.SystemAdmins .FirstOrDefaultAsync(a => a.Phone == phone && a.IsActive && a.DeletedAt == null, cancellationToken); if (admin is null) return (false, null, "NOT_FOUND", "No system admin account for this phone."); var redis = _redis.GetDatabase(); var maxAttempts = _configuration.GetValue("Auth:MaxOtpAttemptsPerHour", DefaultMaxOtpAttemptsPerHour); var attemptsKey = $"otp:admin:{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($"otp:admin:{phone}", otp, TimeSpan.FromSeconds(OtpTtlSeconds)); try { await _smsService.SendOtpAsync(phone, otp, cancellationToken); } catch (Exception ex) { _logger.LogError(ex, "Failed to send admin 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, AuthTokenResponse? 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($"otp:admin:{phone}"); if (storedOtp.IsNullOrEmpty || storedOtp.ToString() != code) return (false, null, "INVALID_OTP", "Invalid or expired verification code."); var admin = await _db.SystemAdmins .FirstOrDefaultAsync(a => a.Phone == phone && a.IsActive && a.DeletedAt == null, cancellationToken); if (admin is null) return (false, null, "NOT_FOUND", "No system admin account for this phone."); await redis.KeyDeleteAsync($"otp:admin:{phone}"); var tokens = await IssueTokensAsync(admin, cancellationToken); return (true, tokens, null, null); } public async Task<(bool Success, AuthTokenResponse? 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.SystemAdmin) return (false, null, "INVALID_TOKEN", "Refresh token is invalid or expired."); var admin = await _db.SystemAdmins .FirstOrDefaultAsync(a => a.Id == payload.UserId && a.IsActive && a.DeletedAt == null, cancellationToken); if (admin is null) return (false, null, "NOT_FOUND", "Admin no longer exists."); // Non-rotating sliding refresh: reuse the presented token (re-stored to // slide its TTL) instead of revoking + minting a new one. Rotation here // raced across the admin dashboard's many concurrent calls and logged // the admin out; reuse makes concurrent refreshes idempotent. var tokens = await IssueTokensAsync(admin, cancellationToken, existingRefreshToken: request.RefreshToken); return (true, tokens, null, null); } public async Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> LoginWithPasswordAsync( LoginWithPasswordRequest request, CancellationToken cancellationToken = default) { var username = request.Username.Trim(); var admin = await _db.SystemAdmins .FirstOrDefaultAsync(a => a.Username == username && a.IsActive && a.DeletedAt == null, cancellationToken); if (admin is null || string.IsNullOrWhiteSpace(admin.PasswordHash)) return (false, null, "INVALID_CREDENTIALS", "Invalid username or password."); if (!PasswordHasher.Verify(request.Password, admin.PasswordHash)) return (false, null, "INVALID_CREDENTIALS", "Invalid username or password."); var tokens = await IssueTokensAsync(admin, cancellationToken); return (true, tokens, null, null); } public async Task<(bool Success, string? ErrorCode, string? ErrorMessage)> ChangePasswordAsync( string adminId, ChangePasswordRequest request, CancellationToken cancellationToken = default) { var admin = await _db.SystemAdmins .FirstOrDefaultAsync(a => a.Id == adminId && a.IsActive && a.DeletedAt == null, cancellationToken); if (admin is null) return (false, "NOT_FOUND", "Admin not found."); // If a password is already set, require the current one if (!string.IsNullOrWhiteSpace(admin.PasswordHash)) { if (!PasswordHasher.Verify(request.CurrentPassword, admin.PasswordHash)) return (false, "INVALID_CREDENTIALS", "Current password is incorrect."); } if (string.IsNullOrWhiteSpace(request.NewPassword) || request.NewPassword.Length < 8) return (false, "VALIDATION_ERROR", "New password must be at least 8 characters."); admin.PasswordHash = PasswordHasher.Hash(request.NewPassword); await _db.SaveChangesAsync(cancellationToken); return (true, null, null); } private async Task IssueTokensAsync( Core.Entities.SystemAdmin admin, CancellationToken cancellationToken, string? existingRefreshToken = null) { var accessToken = _jwtTokenService.CreateAdminAccessToken(admin); // Mint a fresh token only on a real login (existingRefreshToken == null); // a refresh reuses + re-stores the presented token to slide its TTL. var refreshToken = existingRefreshToken ?? _jwtTokenService.CreateRefreshToken(); var refreshDays = _configuration.GetValue("Jwt:RefreshTokenExpiryDays", 30); await _refreshTokenStore.StoreAsync( refreshToken, new RefreshTokenPayload( admin.Id, string.Empty, "SystemAdmin", PlanTier.Enterprise.ToString(), "fa", MeeziActorKinds.SystemAdmin), TimeSpan.FromDays(refreshDays), cancellationToken); return new AuthTokenResponse( accessToken, refreshToken, _jwtTokenService.GetAccessTokenExpiry(), admin.Id, string.Empty, "SystemAdmin", PlanTier.Enterprise.ToString(), "fa", MeeziActorKinds.SystemAdmin); } }