Files
taxbaik/TaxBaik.Web/Services/CustomAuthenticationStateProvider.cs
kjh2064 804725a785
TaxBaik CI/CD / build-and-deploy (push) Successful in 48s
fix: prevent admin authentication timeout during session
**Issues Resolved:**
1. Access Token lifetime extended 15m → 1h (better UX)
   - Users can browse admin pages for 1 hour without re-login
   - Reasonable balance between security and usability

2. Automatic pre-expiry token refresh
   - GetAuthenticationStateAsync() now checks if token expires in <5min
   - Automatically refreshes before expiry when user is still active
   - Prevents sudden logout during admin work

**Implementation:**
- Added ShouldRefreshToken() to detect imminent expiry (300s window)
- On auth state check, if token expiring soon: trigger refresh via AuthService
- Refresh happens transparently, no user interaction needed
- Maintains 7-day Refresh Token TTL for security

**Behavior:**
- User logs in with 1-hour session
- Every page load/navigation checks token status
- If <5min remaining: auto-refresh (user doesn't notice)
- If refresh fails: graceful logout with warning
- Refresh Token (7 days) allows re-login without password

This provides better UX while maintaining security through
shorter-lived access tokens and automatic renewal.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 12:56:44 +09:00

160 lines
5.9 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()));
}
// 토큰이 5분 이내로 만료되면 자동 갱신 시도 (사용자 경험 향상)
if (!string.IsNullOrEmpty(_tokenStore.RefreshToken) && ShouldRefreshToken())
{
_logger.LogInformation("토큰 만료 5분 전 - 자동 갱신 시작");
var newTokenPair = await _authService.RefreshAccessTokenAsync(_tokenStore.RefreshToken);
if (newTokenPair != null)
{
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 = _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());
}
private bool ShouldRefreshToken()
{
// 토큰이 5분 이내로 만료되면 갱신 (300초 = 5분)
if (_tokenStore.TokenExpiryTicks <= 0)
return false;
const int refreshThresholdSeconds = 300;
try
{
var expiryTime = new DateTime((long)_tokenStore.TokenExpiryTicks, DateTimeKind.Utc);
var timeUntilExpiry = expiryTime - DateTime.UtcNow;
return timeUntilExpiry.TotalSeconds <= refreshThresholdSeconds && timeUntilExpiry.TotalSeconds > 0;
}
catch
{
return false;
}
}
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;
}
}
}