191 lines
6.5 KiB
C#
191 lines
6.5 KiB
C#
using System.IdentityModel.Tokens.Jwt;
|
|
using System.Security.Claims;
|
|
using Microsoft.AspNetCore.Components.Authorization;
|
|
using TaxBaik.Application.Services;
|
|
|
|
namespace TaxBaik.Web.Client.Components.Admin.Services;
|
|
|
|
public class CustomAuthenticationStateProvider : AuthenticationStateProvider
|
|
{
|
|
private readonly ITokenStore _tokenStore;
|
|
private readonly IApiClient _apiClient;
|
|
private readonly ILogger<CustomAuthenticationStateProvider> _logger;
|
|
|
|
public CustomAuthenticationStateProvider(
|
|
ITokenStore tokenStore,
|
|
IApiClient apiClient,
|
|
ILogger<CustomAuthenticationStateProvider> logger)
|
|
{
|
|
_tokenStore = tokenStore;
|
|
_apiClient = apiClient;
|
|
_logger = logger;
|
|
}
|
|
|
|
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
|
|
{
|
|
try
|
|
{
|
|
var accessToken = _tokenStore.AccessToken;
|
|
|
|
if (string.IsNullOrEmpty(_tokenStore.AccessToken))
|
|
{
|
|
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
|
|
}
|
|
|
|
// 토큰이 만료되면 로그아웃
|
|
if (_tokenStore.IsAccessTokenExpired())
|
|
{
|
|
_logger.LogWarning("Access token 만료됨 - 자동 로그아웃");
|
|
await LogoutAsync();
|
|
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
|
|
}
|
|
|
|
// 토큰이 5분 이내로 만료되면 자동 갱신 시도 (사용자 경험 향상)
|
|
if (!string.IsNullOrEmpty(_tokenStore.RefreshToken) && ShouldRefreshToken())
|
|
{
|
|
_logger.LogInformation("토큰 만료 5분 전 - 자동 갱신 시작");
|
|
var request = new { RefreshToken = _tokenStore.RefreshToken };
|
|
var newTokenPair = await _apiClient.PostAsync<WasmAuthTokenPair>("auth/refresh", request);
|
|
if (newTokenPair != null && !string.IsNullOrEmpty(newTokenPair.AccessToken))
|
|
{
|
|
await LoginAsync(newTokenPair.AccessToken, newTokenPair.RefreshToken, newTokenPair.ExpiresIn);
|
|
_logger.LogInformation("토큰 자동 갱신 성공");
|
|
accessToken = newTokenPair.AccessToken;
|
|
}
|
|
else
|
|
{
|
|
_logger.LogWarning("토큰 자동 갱신 실패 - 로그아웃");
|
|
await LogoutAsync();
|
|
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
|
|
}
|
|
}
|
|
|
|
var principal = ValidateTokenWithoutDb(accessToken ?? string.Empty);
|
|
if (principal == null)
|
|
{
|
|
await LogoutAsync();
|
|
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
|
|
}
|
|
|
|
return new AuthenticationState(principal);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "인증 상태 조회 중 오류 발생");
|
|
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
|
|
}
|
|
}
|
|
|
|
private ClaimsPrincipal? ValidateTokenWithoutDb(string token)
|
|
{
|
|
try
|
|
{
|
|
var handler = new JwtSecurityTokenHandler();
|
|
var jwtToken = handler.ReadJwtToken(token);
|
|
var identity = new ClaimsIdentity(jwtToken.Claims, "jwt");
|
|
return new ClaimsPrincipal(identity);
|
|
}
|
|
catch
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
|
|
public async Task LoginAsync(string accessToken, string refreshToken, int expiresIn)
|
|
{
|
|
// 토큰 만료 시간을 .NET ticks로 계산 (JavaScript와 일치)
|
|
// JavaScript: 621355968000000000 + ((Date.now() + expiresIn*1000) * 10000)
|
|
// 이를 C#로 변환하면 DateTime.UtcNow.AddSeconds(expiresIn).Ticks
|
|
var tokenExpiryTicks = DateTime.UtcNow.AddSeconds(expiresIn).Ticks;
|
|
|
|
// TokenStore에 저장 (DelegatingHandler에서 사용)
|
|
_tokenStore.AccessToken = accessToken;
|
|
_tokenStore.RefreshToken = refreshToken;
|
|
_tokenStore.TokenExpiryTicks = tokenExpiryTicks;
|
|
|
|
// Blazor에 인증 상태 변경을 알림 - 이 호출 자체는 async이지만 fire-and-forget OK
|
|
// (NotifyAuthenticationStateChanged는 내부적으로 Task를 구독함)
|
|
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
|
|
}
|
|
|
|
private static bool TryNormalizeExpiryTicks(string? rawValue, out long ticks)
|
|
{
|
|
ticks = 0;
|
|
if (!long.TryParse(rawValue, out var parsed))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// Support both legacy Unix-millisecond storage and .NET ticks.
|
|
if (parsed > 10_000_000_000_000L && parsed < 100_000_000_000_000_000L)
|
|
{
|
|
ticks = parsed;
|
|
return true;
|
|
}
|
|
|
|
if (parsed > 1_000_000_000_000L && parsed < 100_000_000_000_000L)
|
|
{
|
|
ticks = DateTimeOffset.FromUnixTimeMilliseconds(parsed).UtcDateTime.Ticks;
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private bool ShouldRefreshToken()
|
|
{
|
|
// 토큰이 5분 이내로 만료되면 갱신 (300초 = 5분)
|
|
if (!_tokenStore.TokenExpiryTicks.HasValue || _tokenStore.TokenExpiryTicks.Value <= 0)
|
|
return false;
|
|
|
|
const int refreshThresholdSeconds = 300;
|
|
try
|
|
{
|
|
var expiryTime = new DateTime(_tokenStore.TokenExpiryTicks.Value, DateTimeKind.Utc);
|
|
var timeUntilExpiry = expiryTime - DateTime.UtcNow;
|
|
return timeUntilExpiry.TotalSeconds <= refreshThresholdSeconds && timeUntilExpiry.TotalSeconds > 0;
|
|
}
|
|
catch
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public async Task LogoutAsync()
|
|
{
|
|
// TokenStore 초기화
|
|
_tokenStore.Clear();
|
|
|
|
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
|
|
}
|
|
|
|
private bool IsTokenExpired(string token)
|
|
{
|
|
try
|
|
{
|
|
var handler = new JwtSecurityTokenHandler();
|
|
var jwtToken = handler.ReadJwtToken(token);
|
|
return jwtToken.ValidTo < DateTime.UtcNow;
|
|
}
|
|
catch
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
public class WasmAuthTokenPair
|
|
{
|
|
public WasmAuthTokenPair() { }
|
|
public WasmAuthTokenPair(string accessToken, string refreshToken, int expiresIn)
|
|
{
|
|
AccessToken = accessToken;
|
|
RefreshToken = refreshToken;
|
|
ExpiresIn = expiresIn;
|
|
}
|
|
public string AccessToken { get; set; } = "";
|
|
public string RefreshToken { get; set; } = "";
|
|
public int ExpiresIn { get; set; }
|
|
}
|