diff --git a/TaxBaik.Web/Program.cs b/TaxBaik.Web/Program.cs index 47eda4e..0e65b6c 100644 --- a/TaxBaik.Web/Program.cs +++ b/TaxBaik.Web/Program.cs @@ -67,9 +67,13 @@ builder.Services.AddAuthorization(); builder.Services.AddAuthorizationCore(); // HTTP Client for API (with automatic token refresh) +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddHttpClient(); -builder.Services.AddHttpClient() +builder.Services.AddHttpClient(client => +{ + client.BaseAddress = new Uri("http://localhost:5001/taxbaik/api/"); +}) .AddHttpMessageHandler(); // UI & 캐시 diff --git a/TaxBaik.Web/Services/AdminDashboardClient.cs b/TaxBaik.Web/Services/AdminDashboardClient.cs index 8950692..5ab68b3 100644 --- a/TaxBaik.Web/Services/AdminDashboardClient.cs +++ b/TaxBaik.Web/Services/AdminDashboardClient.cs @@ -26,7 +26,6 @@ public class AdminDashboardClient : IAdminDashboardClient { _http = http; _logger = logger; - _http.BaseAddress = new Uri("/taxbaik/api/"); } public async Task GetSummaryAsync(CancellationToken ct = default) diff --git a/TaxBaik.Web/Services/CustomAuthenticationStateProvider.cs b/TaxBaik.Web/Services/CustomAuthenticationStateProvider.cs index 4417af5..27343cd 100644 --- a/TaxBaik.Web/Services/CustomAuthenticationStateProvider.cs +++ b/TaxBaik.Web/Services/CustomAuthenticationStateProvider.cs @@ -7,12 +7,18 @@ namespace TaxBaik.Web.Services; public class CustomAuthenticationStateProvider : AuthenticationStateProvider { private readonly ILocalStorageService _localStorage; + private readonly ITokenStore _tokenStore; private readonly AuthService _authService; private readonly ILogger _logger; - public CustomAuthenticationStateProvider(ILocalStorageService localStorage, AuthService authService, ILogger logger) + public CustomAuthenticationStateProvider( + ILocalStorageService localStorage, + ITokenStore tokenStore, + AuthService authService, + ILogger logger) { _localStorage = localStorage; + _tokenStore = tokenStore; _authService = authService; _logger = logger; } @@ -21,14 +27,31 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider { try { - var accessToken = await _localStorage.GetItemAsStringAsync("accessToken"); + 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 (IsTokenExpired(accessToken)) + if (_tokenStore.IsAccessTokenExpired()) { _logger.LogWarning("Access token 만료됨"); await LogoutAsync(); @@ -53,18 +76,31 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider 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", - DateTime.UtcNow.AddSeconds(expiresIn).Ticks.ToString()); + 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()); } diff --git a/TaxBaik.Web/Services/ITokenStore.cs b/TaxBaik.Web/Services/ITokenStore.cs new file mode 100644 index 0000000..fd516ac --- /dev/null +++ b/TaxBaik.Web/Services/ITokenStore.cs @@ -0,0 +1,39 @@ +namespace TaxBaik.Web.Services; + +/// +/// Scoped in-memory token store for Blazor Server. +/// SOLID: Single Responsibility - Token lifecycle management +/// Avoids JS interop from DelegatingHandler (which runs on non-circuit thread) +/// +public interface ITokenStore +{ + string? AccessToken { get; set; } + string? RefreshToken { get; set; } + long? TokenExpiryTicks { get; set; } + + bool IsAccessTokenExpired(); + void Clear(); +} + +public class TokenStore : ITokenStore +{ + public string? AccessToken { get; set; } + public string? RefreshToken { get; set; } + public long? TokenExpiryTicks { get; set; } + + public bool IsAccessTokenExpired() + { + if (TokenExpiryTicks == null) + return true; + + var expiryTime = new DateTime(TokenExpiryTicks.Value, DateTimeKind.Utc); + return expiryTime <= DateTime.UtcNow; + } + + public void Clear() + { + AccessToken = null; + RefreshToken = null; + TokenExpiryTicks = null; + } +} diff --git a/TaxBaik.Web/Services/TokenRefreshHandler.cs b/TaxBaik.Web/Services/TokenRefreshHandler.cs index 1bd6dff..a7e450c 100644 --- a/TaxBaik.Web/Services/TokenRefreshHandler.cs +++ b/TaxBaik.Web/Services/TokenRefreshHandler.cs @@ -10,12 +10,12 @@ using System.Text.Json; /// public class TokenRefreshHandler : DelegatingHandler { - private readonly ILocalStorageService _localStorage; + private readonly ITokenStore _tokenStore; private readonly ILogger _logger; - public TokenRefreshHandler(ILocalStorageService localStorage, ILogger logger) + public TokenRefreshHandler(ITokenStore tokenStore, ILogger logger) { - _localStorage = localStorage; + _tokenStore = tokenStore; _logger = logger; } @@ -24,10 +24,9 @@ public class TokenRefreshHandler : DelegatingHandler CancellationToken cancellationToken) { // 요청에 access token 추가 - var accessToken = await _localStorage.GetItemAsStringAsync("accessToken"); - if (!string.IsNullOrEmpty(accessToken)) + if (!string.IsNullOrEmpty(_tokenStore.AccessToken)) { - request.Headers.Authorization = new("Bearer", accessToken); + request.Headers.Authorization = new("Bearer", _tokenStore.AccessToken); } var response = await base.SendAsync(request, cancellationToken); @@ -35,17 +34,15 @@ public class TokenRefreshHandler : DelegatingHandler // 401 응답이면 토큰 갱신 시도 if (response.StatusCode == HttpStatusCode.Unauthorized) { - var refreshToken = await _localStorage.GetItemAsStringAsync("refreshToken"); - if (!string.IsNullOrEmpty(refreshToken)) + if (!string.IsNullOrEmpty(_tokenStore.RefreshToken)) { - var newTokenPair = await RefreshTokenAsync(refreshToken, request, cancellationToken); + var newTokenPair = await RefreshTokenAsync(_tokenStore.RefreshToken, request, cancellationToken); if (newTokenPair != null) { - // 토큰 저장 - await _localStorage.SetItemAsStringAsync("accessToken", newTokenPair.AccessToken); - await _localStorage.SetItemAsStringAsync("refreshToken", newTokenPair.RefreshToken); - await _localStorage.SetItemAsStringAsync("tokenExpiry", - DateTime.UtcNow.AddSeconds(newTokenPair.ExpiresIn).Ticks.ToString()); + // TokenStore에 토큰 저장 + _tokenStore.AccessToken = newTokenPair.AccessToken; + _tokenStore.RefreshToken = newTokenPair.RefreshToken; + _tokenStore.TokenExpiryTicks = DateTime.UtcNow.AddSeconds(newTokenPair.ExpiresIn).Ticks; // 새 토큰으로 재요청 request.Headers.Authorization = new("Bearer", newTokenPair.AccessToken); @@ -54,9 +51,7 @@ public class TokenRefreshHandler : DelegatingHandler else { _logger.LogWarning("토큰 갱신 실패 - 로그아웃"); - await _localStorage.RemoveItemAsync("accessToken"); - await _localStorage.RemoveItemAsync("refreshToken"); - await _localStorage.RemoveItemAsync("tokenExpiry"); + _tokenStore.Clear(); } } }