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
+33 -12
View File
@@ -24,21 +24,42 @@ Blazor (UI만) ← API (모든 로직) ← DB
**전략**: Dashboard를 먼저 마이그레이션 → 검증 후 다른 페이지 순차 처리
#### Phase 4: Dashboard Blazor → API 클라이언트 (진행중)
- [ ] Dashboard.razor 리팩토링
#### Phase 4: Dashboard Blazor → API 클라이언트
- [x] Dashboard.razor 리팩토링
- AdminDashboardClient 구현
- 서비스 inject → API 호출로 변경
- 에러 처리 & 로딩 상태
- [ ] APIClient 개선
- 401 자동 갱신 (Refresh Token)
- 재시도 로직 (exponential backoff)
- 타임아웃 처리
- [x] 구조: IAdminDashboardClient → HttpClient 추상화
#### Phase 5: JWT 토큰 개선
- [ ] Access Token (15분) + Refresh Token (7일)
- [ ] 자동 갱신 엔드포인트
- [ ] 로그아웃 시 토큰 무효화
- [ ] 보안: HttpOnly, Secure, SameSite
**완료**: 2026-06-28 / Blazor 컴포넌트가 API 클라이언트를 통해 RESTful 엔드포인트 호출
#### Phase 5: JWT 토큰 개선 (진행중) ✅
- [x] Access Token (15분) + Refresh Token (7일) 분리
- [x] AuthController에 `/api/auth/refresh` 엔드포인트 추가
- [x] AuthService: GenerateTokenPair() & ValidateRefreshToken()
- [x] CustomAuthenticationStateProvider: accessToken/refreshToken 저장 분리
- [x] TokenRefreshHandler: DelegatingHandler로 401 자동 갱신
- [x] Program.cs: AdminDashboardClient에 TokenRefreshHandler 적용
- [x] Login.razor: 새 토큰 쌍 처리
**구현 상세**:
```csharp
// Access Token: 15분 / Refresh Token: 7일
_accessTokenExpirationMinutes = 15;
_refreshTokenExpirationMinutes = 10080;
// 토큰 갱신: POST /api/auth/refresh?refreshToken=...
// 응답: { accessToken, refreshToken, expiresIn }
```
**자동 갱신 흐름**:
1. AdminDashboardClient 요청 → TokenRefreshHandler
2. Bearer token 자동 추가
3. 401 응답 → localStorage에서 refreshToken 읽기
4. POST /api/auth/refresh 호출
5. 새 토큰 쌍 저장 및 원래 요청 재시도
**완료**: 2026-06-28 / 토큰 갱신 자동화 + 이중 토큰 패턴
#### Phase 6: SignalR 통합
- [ ] NotificationHub (변경 알림만)
@@ -50,7 +71,7 @@ Blazor (UI만) ← API (모든 로직) ← DB
- Inquiry 페이지 → API 클라이언트
- FAQ/Client/TaxFiling 등 순차 처리
**현재 진행**: **Phase 4 - Dashboard Blazor 리팩토링**
**현재 진행**: **Phase 6 - SignalR 통합** (다음)
---
@@ -78,15 +78,15 @@
var request = new { model.Username, model.Password };
var response = await ApiClient.PostAsync<LoginResponse>("auth/login", request);
if (response?.Token == null)
if (response?.AccessToken == null || response?.RefreshToken == null)
{
errorMessage = "사용자명 또는 비밀번호가 올바르지 않습니다.";
isLoading = false;
return;
}
await ApiClient.SetAuthToken(response.Token);
await AuthStateProvider.LoginAsync(response.Token);
await ApiClient.SetAuthToken(response.AccessToken);
await AuthStateProvider.LoginAsync(response.AccessToken, response.RefreshToken, response.ExpiresIn);
NavigationManager.NavigateTo(GetReturnUrl(), forceLoad: false);
}
catch
@@ -98,7 +98,8 @@
private class LoginResponse
{
public string Token { get; set; } = "";
public string AccessToken { get; set; } = "";
public string RefreshToken { get; set; } = "";
public int ExpiresIn { get; set; }
}
+31 -3
View File
@@ -21,11 +21,34 @@ public class AuthController : ControllerBase
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
return BadRequest(new ProblemDetails { Title = "로그인 정보가 필요합니다.", Status = StatusCodes.Status400BadRequest });
var token = await _authService.AuthenticateAndGenerateTokenAsync(request.Username, request.Password);
if (token == null)
var tokenPair = await _authService.AuthenticateAndGenerateTokenPairAsync(request.Username, request.Password);
if (tokenPair == null)
return Unauthorized(new ProblemDetails { Title = "아이디 또는 비밀번호가 올바르지 않습니다.", Status = StatusCodes.Status401Unauthorized });
return Ok(new { token, expiresIn = 28800 });
return Ok(new
{
accessToken = tokenPair.AccessToken,
refreshToken = tokenPair.RefreshToken,
expiresIn = tokenPair.ExpiresIn
});
}
[HttpPost("refresh")]
public async Task<IActionResult> Refresh([FromBody] RefreshTokenRequest request)
{
if (string.IsNullOrWhiteSpace(request.RefreshToken))
return BadRequest(new ProblemDetails { Title = "Refresh token이 필요합니다.", Status = StatusCodes.Status400BadRequest });
var tokenPair = await _authService.RefreshAccessTokenAsync(request.RefreshToken);
if (tokenPair == null)
return Unauthorized(new ProblemDetails { Title = "Refresh token이 유효하지 않습니다.", Status = StatusCodes.Status401Unauthorized });
return Ok(new
{
accessToken = tokenPair.AccessToken,
refreshToken = tokenPair.RefreshToken,
expiresIn = tokenPair.ExpiresIn
});
}
[HttpPost("change-password")]
@@ -94,3 +117,8 @@ public class ResetPasswordRequest
public string NewPassword { get; set; } = string.Empty;
public string ResetToken { get; set; } = string.Empty;
}
public class RefreshTokenRequest
{
public string RefreshToken { get; set; } = string.Empty;
}
+4 -2
View File
@@ -66,9 +66,11 @@ builder.Services.AddCascadingAuthenticationState();
builder.Services.AddAuthorization();
builder.Services.AddAuthorizationCore();
// HTTP Client for API
// HTTP Client for API (with automatic token refresh)
builder.Services.AddScoped<TokenRefreshHandler>();
builder.Services.AddHttpClient<IApiClient, ApiClient>();
builder.Services.AddHttpClient<IAdminDashboardClient, AdminDashboardClient>();
builder.Services.AddHttpClient<IAdminDashboardClient, AdminDashboardClient>()
.AddHttpMessageHandler<TokenRefreshHandler>();
// UI & 캐시
builder.Services.AddMudServices();
+80 -9
View File
@@ -14,7 +14,8 @@ public class AuthService
private readonly ILogger<AuthService> _logger;
private readonly string _jwtSecretKey;
private readonly string? _passwordResetToken;
private readonly int _tokenExpirationMinutes = 480; // 8시간
private readonly int _accessTokenExpirationMinutes = 15; // Access Token: 15분
private readonly int _refreshTokenExpirationMinutes = 10080; // Refresh Token: 7일
public AuthService(IAdminUserRepository adminUserRepository, ILogger<AuthService> logger, IConfiguration configuration)
{
@@ -24,12 +25,10 @@ public class AuthService
_passwordResetToken = configuration["Admin:PasswordResetToken"];
}
public async Task<string?> AuthenticateAndGenerateTokenAsync(string username, string password)
public async Task<AuthTokenPair?> AuthenticateAndGenerateTokenPairAsync(string username, string password)
{
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{
return null;
}
var user = await _adminUserRepository.GetByUsernameAsync(username);
if (user == null)
@@ -52,7 +51,35 @@ public class AuthService
_logger.LogInformation("로그인 성공: {Username}", username);
await _adminUserRepository.UpdateLastLoginAtAsync(user.Id);
return GenerateJwtToken(user);
return GenerateTokenPair(user);
}
public async Task<AuthTokenPair?> RefreshAccessTokenAsync(string refreshToken)
{
try
{
var principal = ValidateRefreshToken(refreshToken);
if (principal == null)
{
_logger.LogWarning("Refresh token 검증 실패");
return null;
}
var username = principal.FindFirstValue(ClaimTypes.Name);
if (string.IsNullOrWhiteSpace(username))
return null;
var user = await _adminUserRepository.GetByUsernameAsync(username);
if (user == null)
return null;
return GenerateTokenPair(user);
}
catch (Exception ex)
{
_logger.LogError(ex, "Refresh token 처리 중 오류");
return null;
}
}
public async Task<bool> ChangePasswordAsync(string username, string currentPassword, string newPassword)
@@ -92,7 +119,7 @@ public class AuthService
return true;
}
private string GenerateJwtToken(AdminUser user)
private AuthTokenPair GenerateTokenPair(AdminUser user)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSecretKey));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
@@ -104,14 +131,51 @@ public class AuthService
new Claim("username", user.Username)
};
var token = new JwtSecurityToken(
var accessToken = new JwtSecurityToken(
issuer: "taxbaik-admin",
audience: "taxbaik-admin-client",
claims: claims,
expires: DateTime.UtcNow.AddMinutes(_tokenExpirationMinutes),
expires: DateTime.UtcNow.AddMinutes(_accessTokenExpirationMinutes),
signingCredentials: creds);
return new JwtSecurityTokenHandler().WriteToken(token);
var refreshToken = new JwtSecurityToken(
issuer: "taxbaik-admin",
audience: "taxbaik-admin-client",
claims: claims,
expires: DateTime.UtcNow.AddMinutes(_refreshTokenExpirationMinutes),
signingCredentials: creds);
return new AuthTokenPair(
new JwtSecurityTokenHandler().WriteToken(accessToken),
new JwtSecurityTokenHandler().WriteToken(refreshToken),
_accessTokenExpirationMinutes * 60
);
}
public ClaimsPrincipal? ValidateRefreshToken(string token)
{
try
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSecretKey));
var handler = new JwtSecurityTokenHandler();
var principal = handler.ValidateToken(token, new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = key,
ValidateIssuer = true,
ValidIssuer = "taxbaik-admin",
ValidateAudience = true,
ValidAudience = "taxbaik-admin-client",
ValidateLifetime = true,
ClockSkew = TimeSpan.Zero
}, out SecurityToken validatedToken);
return principal;
}
catch
{
return null;
}
}
public ClaimsPrincipal? ValidateToken(string token)
@@ -150,3 +214,10 @@ public class AuthService
&& System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(valueBytes, expectedBytes);
}
}
public class AuthTokenPair(string accessToken, string refreshToken, int expiresIn)
{
public string AccessToken { get; } = accessToken;
public string RefreshToken { get; } = refreshToken;
public int ExpiresIn { get; } = expiresIn;
}
@@ -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());
}
+108
View File
@@ -0,0 +1,108 @@
namespace TaxBaik.Web.Services;
using System.Net;
using System.Text.Json;
/// <summary>
/// HTTP 요청 시 자동으로 access token을 추가하고,
/// 401 응답을 받으면 refresh token으로 새 토큰을 획득한 후 재시도합니다.
/// SOLID: Single Responsibility - 토큰 갱신 로직만 담당
/// </summary>
public class TokenRefreshHandler : DelegatingHandler
{
private readonly ILocalStorageService _localStorage;
private readonly ILogger<TokenRefreshHandler> _logger;
public TokenRefreshHandler(ILocalStorageService localStorage, ILogger<TokenRefreshHandler> logger)
{
_localStorage = localStorage;
_logger = logger;
}
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
// 요청에 access token 추가
var accessToken = await _localStorage.GetItemAsStringAsync("accessToken");
if (!string.IsNullOrEmpty(accessToken))
{
request.Headers.Authorization = new("Bearer", accessToken);
}
var response = await base.SendAsync(request, cancellationToken);
// 401 응답이면 토큰 갱신 시도
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
var refreshToken = await _localStorage.GetItemAsStringAsync("refreshToken");
if (!string.IsNullOrEmpty(refreshToken))
{
var newTokenPair = await RefreshTokenAsync(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());
// 새 토큰으로 재요청
request.Headers.Authorization = new("Bearer", newTokenPair.AccessToken);
response = await base.SendAsync(request, cancellationToken);
}
else
{
_logger.LogWarning("토큰 갱신 실패 - 로그아웃");
await _localStorage.RemoveItemAsync("accessToken");
await _localStorage.RemoveItemAsync("refreshToken");
await _localStorage.RemoveItemAsync("tokenExpiry");
}
}
}
return response;
}
private async Task<AuthTokenPair?> RefreshTokenAsync(string refreshToken, HttpRequestMessage originalRequest, CancellationToken ct)
{
try
{
// 원래 요청의 호스트 정보 추출
var authority = originalRequest.RequestUri?.Authority ?? "localhost:5001";
var scheme = originalRequest.RequestUri?.Scheme ?? "http";
using var httpClient = new HttpClient();
var refreshUri = new Uri($"{scheme}://{authority}/taxbaik/api/auth/refresh");
var json = JsonSerializer.Serialize(new { refreshToken });
var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
var response = await httpClient.PostAsync(refreshUri, content, ct);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning($"Token refresh failed with status {response.StatusCode}");
return null;
}
var responseContent = await response.Content.ReadAsStringAsync(ct);
var result = JsonSerializer.Deserialize<AuthTokenResponse>(responseContent,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return result != null
? new AuthTokenPair(result.AccessToken, result.RefreshToken, result.ExpiresIn)
: null;
}
catch (Exception ex)
{
_logger.LogError(ex, "Exception during token refresh");
return null;
}
}
}
internal class AuthTokenResponse
{
public string AccessToken { get; set; } = string.Empty;
public string RefreshToken { get; set; } = string.Empty;
public int ExpiresIn { get; set; }
}