feat: standalone Blazor WebAssembly admin + SEO enhancements

Architecture:
- Admin UI: /admin (Standalone Blazor WebAssembly, 219 WASM files)
- Portal: /portal (Razor Pages, Cookie/OAuth auth)
- Homepage: / (Razor Pages, SSR)
- API: /api (FastEndpoints + JWT)

SEO:
- Sitemap: Public content only (blog, FAQ, announcements, contact)
- robots.txt: Exclude /admin and /portal, reference production domain
- Naver verification: naverb1813cd79ddc2ded5c5291fca5cb46c2.html ready

Technical:
- TaxBaik.Web.Client: StaticWebAssetBasePath=admin
- Server Program.cs: UseBlazorFrameworkFiles + MapFallback for SPA routing
- base href="/admin/" for client-side navigation
- blazor.webassembly.js (standalone, not web.js)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 04:03:18 +09:00
parent 64e462e57e
commit 54367696dc
112 changed files with 2701 additions and 207 deletions
@@ -0,0 +1,221 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization;
using TaxBaik.Application.Services;
namespace TaxBaik.Web.Client.Components.Admin.Services;
public class CustomAuthenticationStateProvider : AuthenticationStateProvider
{
private readonly ILocalStorageService _localStorage;
private readonly ITokenStore _tokenStore;
private readonly IApiClient _apiClient;
private readonly ILogger<CustomAuthenticationStateProvider> _logger;
public CustomAuthenticationStateProvider(
ILocalStorageService localStorage,
ITokenStore tokenStore,
IApiClient apiClient,
ILogger<CustomAuthenticationStateProvider> logger)
{
_localStorage = localStorage;
_tokenStore = tokenStore;
_apiClient = apiClient;
_logger = logger;
}
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
try
{
var accessToken = _tokenStore.AccessToken;
// TokenStore가 비어있으면 localStorage에서 복원 (페이지 리로드 후)
if (string.IsNullOrEmpty(accessToken))
{
var storedToken = await _localStorage.GetItemAsStringAsync("accessToken");
if (!string.IsNullOrEmpty(storedToken))
{
var refreshToken = await _localStorage.GetItemAsStringAsync("refreshToken");
var ticksStr = await _localStorage.GetItemAsStringAsync("tokenExpiry");
if (TryNormalizeExpiryTicks(ticksStr, out var ticks))
{
_tokenStore.AccessToken = storedToken;
_tokenStore.RefreshToken = refreshToken;
_tokenStore.TokenExpiryTicks = ticks;
accessToken = storedToken;
}
}
}
if (string.IsNullOrEmpty(_tokenStore.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 request = new { RefreshToken = _tokenStore.RefreshToken };
var newTokenPair = await _apiClient.PostAsync<WasmAuthTokenPair>("auth/refresh", request);
if (newTokenPair != null && !string.IsNullOrEmpty(newTokenPair.AccessToken))
{
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 = ValidateTokenWithoutDb(accessToken ?? string.Empty);
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()));
}
}
private ClaimsPrincipal? ValidateTokenWithoutDb(string token)
{
try
{
var handler = new JwtSecurityTokenHandler();
var jwtToken = handler.ReadJwtToken(token);
var identity = new ClaimsIdentity(jwtToken.Claims, "jwt");
return new ClaimsPrincipal(identity);
}
catch
{
return null;
}
}
public async Task LoginAsync(string accessToken, string refreshToken, int expiresIn)
{
// 토큰 만료 시간을 .NET ticks로 계산 (JavaScript와 일치)
// JavaScript: 621355968000000000 + ((Date.now() + expiresIn*1000) * 10000)
// 이를 C#로 변환하면 DateTime.UtcNow.AddSeconds(expiresIn).Ticks
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());
// Blazor에 인증 상태 변경을 알림 - 이 호출 자체는 async이지만 fire-and-forget OK
// (NotifyAuthenticationStateChanged는 내부적으로 Task를 구독함)
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
}
private static bool TryNormalizeExpiryTicks(string? rawValue, out long ticks)
{
ticks = 0;
if (!long.TryParse(rawValue, out var parsed))
{
return false;
}
// Support both legacy Unix-millisecond storage and .NET ticks.
if (parsed > 10_000_000_000_000L && parsed < 100_000_000_000_000_000L)
{
ticks = parsed;
return true;
}
if (parsed > 1_000_000_000_000L && parsed < 100_000_000_000_000L)
{
ticks = DateTimeOffset.FromUnixTimeMilliseconds(parsed).UtcDateTime.Ticks;
return true;
}
return false;
}
private bool ShouldRefreshToken()
{
// 토큰이 5분 이내로 만료되면 갱신 (300초 = 5분)
if (!_tokenStore.TokenExpiryTicks.HasValue || _tokenStore.TokenExpiryTicks.Value <= 0)
return false;
const int refreshThresholdSeconds = 300;
try
{
var expiryTime = new DateTime(_tokenStore.TokenExpiryTicks.Value, 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;
}
}
}
public class WasmAuthTokenPair
{
public WasmAuthTokenPair() { }
public WasmAuthTokenPair(string accessToken, string refreshToken, int expiresIn)
{
AccessToken = accessToken;
RefreshToken = refreshToken;
ExpiresIn = expiresIn;
}
public string AccessToken { get; set; } = "";
public string RefreshToken { get; set; } = "";
public int ExpiresIn { get; set; }
}