refactor: Phase 5 - JWT token lifecycle (Access + Refresh + Auto-refresh)
TaxBaik CI/CD / build-and-deploy (push) Successful in 48s

**Implementation:**
- AuthService: Split token generation
  * AccessToken: 15 minutes
  * RefreshToken: 7 days (10080 minutes)
  * New: GenerateTokenPair() method
  * New: RefreshAccessTokenAsync() method

- AuthTokenPair: New record (accessToken, refreshToken, expiresIn)

- AuthController: New /api/auth/refresh endpoint
  * POST /api/auth/refresh?refreshToken=...
  * Response: { accessToken, refreshToken, expiresIn }
  * RefreshTokenRequest DTO

- TokenRefreshHandler: New DelegatingHandler
  * Automatic Bearer token injection
  * 401 response handling
  * Auto-refresh with retry
  * localStorage sync (accessToken, refreshToken, tokenExpiry)

- CustomAuthenticationStateProvider: Token storage split
  * Before: auth_token (single)
  * After: accessToken, refreshToken, tokenExpiry
  * LoginAsync signature updated

- Login.razor: Handle token pair
  * LoginResponse: { accessToken, refreshToken, expiresIn }
  * Call new LoginAsync(accessToken, refreshToken, expiresIn)

- Program.cs: TokenRefreshHandler registration
  * AddScoped<TokenRefreshHandler>()
  * AdminDashboardClient pipeline: .AddHttpMessageHandler<TokenRefreshHandler>()

**SOLID Principles:**
✓ S (Single Responsibility): TokenRefreshHandler handles only token refresh
✓ D (Dependency Inversion): DelegatingHandler abstracts HTTP concerns
✓ O (Open/Closed): Token lifetime extension without code changes

**Security Pattern:**
- Short-lived access tokens (15min) reduce theft window
- Refresh tokens (7d) enable persistence without storing secrets
- Automatic refresh is transparent to components

**Flow:**
Blazor → AdminDashboardClient → TokenRefreshHandler (auto-add Bearer)
  → 401 → RefreshTokenAsync() → POST /api/auth/refresh
  → Store new pair → Retry original request

Status: Token lifecycle complete, ready for SignalR integration (Phase 6)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-28 10:51:24 +09:00
parent 0334a5f607
commit 58edbd9c8f
7 changed files with 276 additions and 40 deletions
@@ -21,24 +21,24 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider
{
try
{
var token = await _localStorage.GetItemAsStringAsync("auth_token");
var accessToken = await _localStorage.GetItemAsStringAsync("accessToken");
if (string.IsNullOrEmpty(token))
if (string.IsNullOrEmpty(accessToken))
{
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
}
if (IsTokenExpired(token))
if (IsTokenExpired(accessToken))
{
_logger.LogWarning("토큰 만료됨");
await _localStorage.RemoveItemAsync("auth_token");
_logger.LogWarning("Access token 만료됨");
await LogoutAsync();
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
}
var principal = _authService.ValidateToken(token);
var principal = _authService.ValidateToken(accessToken);
if (principal == null)
{
await _localStorage.RemoveItemAsync("auth_token");
await LogoutAsync();
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
}
@@ -51,15 +51,20 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider
}
}
public async Task LoginAsync(string token)
public async Task LoginAsync(string accessToken, string refreshToken, int expiresIn)
{
await _localStorage.SetItemAsStringAsync("auth_token", token);
await _localStorage.SetItemAsStringAsync("accessToken", accessToken);
await _localStorage.SetItemAsStringAsync("refreshToken", refreshToken);
await _localStorage.SetItemAsStringAsync("tokenExpiry",
DateTime.UtcNow.AddSeconds(expiresIn).Ticks.ToString());
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
}
public async Task LogoutAsync()
{
await _localStorage.RemoveItemAsync("auth_token");
await _localStorage.RemoveItemAsync("accessToken");
await _localStorage.RemoveItemAsync("refreshToken");
await _localStorage.RemoveItemAsync("tokenExpiry");
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
}