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 _logger; public CustomAuthenticationStateProvider( ITokenStore tokenStore, IApiClient apiClient, ILogger logger) { _tokenStore = tokenStore; _apiClient = apiClient; _logger = logger; } public override async Task 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("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; } }