a967e5d211
CI/CD / CI · API (dotnet build + test) (push) Successful in 39s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 29s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m6s
CI/CD / CI · Admin Web (tsc) (push) Successful in 38s
CI/CD / CI · Website (tsc) (push) Successful in 44s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 2m35s
Prod diag showed every /api/admin/* call returning 401 with "IDX10223: token expired, ValidTo 06/09" — the admin access token was 6 days dead and nothing renewed it, so cafes/tickets/integrations/settings all loaded empty. The admin web (unlike the café dashboard) had NO refresh logic at all: it only ever sent the access token, and its 401 handler early-returned on any error code before the login redirect, so the admin wasn't even bounced to login — pages just showed no data. Client (admin-client.ts): add a silent refresh-on-401 mirroring the dashboard — one shared in-flight POST /api/admin/auth/refresh for a burst of 401s, replay the original request on success, force-logout only on a definitive 4xx, and ride out a transient failure (API restarting during deploy) without logging out. Backend (AdminAuthService): make refresh non-rotating + sliding (reuse the presented refresh token and re-store it) instead of revoke-and-mint, so the dashboard's many concurrent refreshes don't race the rotated token — same fix already applied to the main API. Also bump admin tokens 7d/30d → 30d/365d to match the main API, so the session is long-lived even before the first refresh round-trip. tsc clean; Admin.API builds clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
234 lines
9.8 KiB
C#
234 lines
9.8 KiB
C#
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<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);
|
|
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<AuthTokenResponse> 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);
|
|
}
|
|
}
|