Files
meezi/src/Meezi.Admin.API/Services/AdminAuthService.cs
T

234 lines
9.8 KiB
C#
Raw Normal View History

2026-05-27 21:33:48 +03:30
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);
2026-05-27 21:33:48 +03:30
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<AdminAuthService> _logger;
public AdminAuthService(
AppDbContext db,
IConnectionMultiplexer redis,
ISmsService smsService,
IAdminJwtTokenService jwtTokenService,
IRefreshTokenStore refreshTokenStore,
IConfiguration configuration,
ILogger<AdminAuthService> 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);
2026-05-27 21:33:48 +03:30
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);
}
2026-05-27 21:33:48 +03:30
private async Task<AuthTokenResponse> IssueTokensAsync(
Core.Entities.SystemAdmin admin,
CancellationToken cancellationToken,
string? existingRefreshToken = null)
2026-05-27 21:33:48 +03:30
{
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();
2026-05-27 21:33:48 +03:30
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);
}
}