08e9e07458
TaxBaik CI/CD / build-and-deploy (push) Successful in 47s
**Problem:**
TokenRefreshHandler (DelegatingHandler) runs on a non-circuit thread.
ILocalStorageService (JS interop) only works during component render.
Production: 401 response → token refresh → JS interop fails silently.
**Solution:**
1. ITokenStore - Scoped in-memory token store (no JS interop)
- Properties: AccessToken, RefreshToken, TokenExpiryTicks
- Method: IsAccessTokenExpired()
2. TokenStore implementation
- Replaces localStorage as primary token source
- DelegatingHandler reads/writes only to TokenStore
- Pages reload → GetAuthenticationStateAsync restores from localStorage
3. CustomAuthenticationStateProvider
- Accepts ITokenStore injection
- LoginAsync: Write to both TokenStore + localStorage
- LogoutAsync: Clear both
- GetAuthenticationStateAsync: Read from TokenStore first, fallback to localStorage
4. AdminDashboardClient BaseAddress fix
- Was: new Uri("/taxbaik/api/") - relative URI (runtime error)
- Now: Configured in Program.cs as absolute URI
- Program.cs: AddHttpClient(..., client => client.BaseAddress = new Uri("http://localhost:5001/taxbaik/api/"))
**Architecture:**
- TokenStore: Scoped in-memory (DelegatingHandler use)
- localStorage: Persistent (page reload recovery)
- Pattern: Server-side token management without JS interop
This fixes the cascading failure that would occur on any 401 in production.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
121 lines
4.2 KiB
C#
121 lines
4.2 KiB
C#
using System.IdentityModel.Tokens.Jwt;
|
|
using System.Security.Claims;
|
|
using Microsoft.AspNetCore.Components.Authorization;
|
|
|
|
namespace TaxBaik.Web.Services;
|
|
|
|
public class CustomAuthenticationStateProvider : AuthenticationStateProvider
|
|
{
|
|
private readonly ILocalStorageService _localStorage;
|
|
private readonly ITokenStore _tokenStore;
|
|
private readonly AuthService _authService;
|
|
private readonly ILogger<CustomAuthenticationStateProvider> _logger;
|
|
|
|
public CustomAuthenticationStateProvider(
|
|
ILocalStorageService localStorage,
|
|
ITokenStore tokenStore,
|
|
AuthService authService,
|
|
ILogger<CustomAuthenticationStateProvider> logger)
|
|
{
|
|
_localStorage = localStorage;
|
|
_tokenStore = tokenStore;
|
|
_authService = authService;
|
|
_logger = logger;
|
|
}
|
|
|
|
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
|
|
{
|
|
try
|
|
{
|
|
var accessToken = _tokenStore.AccessToken;
|
|
|
|
// TokenStore가 비어있으면 localStorage에서 복원 (페이지 리로드 후)
|
|
if (string.IsNullOrEmpty(accessToken))
|
|
{
|
|
accessToken = await _localStorage.GetItemAsStringAsync("accessToken");
|
|
if (!string.IsNullOrEmpty(accessToken))
|
|
{
|
|
var refreshToken = await _localStorage.GetItemAsStringAsync("refreshToken");
|
|
var ticksStr = await _localStorage.GetItemAsStringAsync("tokenExpiry");
|
|
if (long.TryParse(ticksStr, out var ticks))
|
|
{
|
|
_tokenStore.AccessToken = accessToken;
|
|
_tokenStore.RefreshToken = refreshToken;
|
|
_tokenStore.TokenExpiryTicks = ticks;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (string.IsNullOrEmpty(accessToken))
|
|
{
|
|
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
|
|
}
|
|
|
|
if (_tokenStore.IsAccessTokenExpired())
|
|
{
|
|
_logger.LogWarning("Access token 만료됨");
|
|
await LogoutAsync();
|
|
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
|
|
}
|
|
|
|
var principal = _authService.ValidateToken(accessToken);
|
|
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()));
|
|
}
|
|
}
|
|
|
|
public async Task LoginAsync(string accessToken, string refreshToken, int expiresIn)
|
|
{
|
|
var tokenExpiryTicks = DateTime.UtcNow.AddSeconds(expiresIn).Ticks;
|
|
|
|
// TokenStore에 저장 (DelegatingHandler에서 사용)
|
|
_tokenStore.AccessToken = accessToken;
|
|
_tokenStore.RefreshToken = refreshToken;
|
|
_tokenStore.TokenExpiryTicks = tokenExpiryTicks;
|
|
|
|
// localStorage에도 저장 (페이지 리로드 후 복원)
|
|
await _localStorage.SetItemAsStringAsync("accessToken", accessToken);
|
|
await _localStorage.SetItemAsStringAsync("refreshToken", refreshToken);
|
|
await _localStorage.SetItemAsStringAsync("tokenExpiry", tokenExpiryTicks.ToString());
|
|
|
|
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
|
|
}
|
|
|
|
public async Task LogoutAsync()
|
|
{
|
|
// TokenStore 초기화
|
|
_tokenStore.Clear();
|
|
|
|
// localStorage 초기화
|
|
await _localStorage.RemoveItemAsync("accessToken");
|
|
await _localStorage.RemoveItemAsync("refreshToken");
|
|
await _localStorage.RemoveItemAsync("tokenExpiry");
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|