using Application.Abstractions.Authentication; using Application.Abstractions.Data; using Application.Abstractions.Messaging; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using SharedKernel; using SharedKernel.Results; namespace Application.Features.Api.Auth.RefreshToken; internal sealed class Handler( IAppDbContext db, IJwtTokenProvider jwtTokenProvider, IOptions options, ILogger logger ) : ICommandHandler> { private readonly AppSettings.JwtSection _jwt = options.Value.JWT; /// 회전 직후 race condition 흡수 grace period (초). 이 시간 내에 revoked 토큰이 재사용되면 탈취가 아닌 race 로 간주. private const int GracePeriodSeconds = 30; public async Task> Handle(Command request, CancellationToken ct) { if (string.IsNullOrWhiteSpace(request.RefreshToken)) { return Result.Failure(Error.Problem("Auth.TokenRequired", "refreshToken은 필수입니다.")); } var existingToken = await db.RefreshToken.Include(x => x.Member).FirstOrDefaultAsync(x => x.Token == request.RefreshToken, ct); if (existingToken is null) { return Result.Failure(Error.NotFound("Auth.TokenNotFound", "유효하지 않은 refreshToken 입니다.")); } if (!existingToken.IsActive) { // 이미 폐기된 토큰이 재사용된 경우 if (existingToken.IsRevoked) { // Grace period — 회전 직후 짧은 시간 내 재사용은 다중 탭/race 로 간주. // ReplacedByToken 이 존재하고 RevokedAt 이 grace 윈도우 내라면, 후속 토큰을 그대로 응답. var withinGrace = existingToken.RevokedAt is DateTime revokedAt && DateTime.UtcNow - revokedAt <= TimeSpan.FromSeconds(GracePeriodSeconds) && !string.IsNullOrEmpty(existingToken.ReplacedByToken); if (withinGrace) { var replacement = await db.RefreshToken.Include(x => x.Member).FirstOrDefaultAsync(x => x.Token == existingToken.ReplacedByToken, ct); if (replacement is not null && replacement.IsActive && replacement.Member is not null) { logger.LogInformation("RefreshToken grace period hit for MemberID {MemberID} — returning replacement token", existingToken.MemberID); var graceAccessToken = jwtTokenProvider.CreateAccessToken(replacement.Member.ID, replacement.Member.Email, replacement.Member.Name); var graceExpires = DateTime.UtcNow.AddMinutes(_jwt.AccessTokenExpiration); return Result.Success(new Response(graceAccessToken, replacement.Token, graceExpires)); } } // grace 가 아니면 진짜 재사용 → 전체 활성 세션 폐기 (원래 정책) var activeTokens = await db.RefreshToken.Where(t => t.MemberID == existingToken.MemberID && !t.IsRevoked).ToListAsync(ct); foreach (var t in activeTokens) { t.Revoke(); } await db.SaveChangesAsync(ct); logger.LogWarning("RefreshToken reuse detected for MemberID {MemberID} — all {Count} active sessions revoked", existingToken.MemberID, activeTokens.Count); return Result.Failure(Error.Unauthorized("Auth.TokenReused", "보안상 모든 세션이 종료되었습니다. 다시 로그인해주세요.")); } return Result.Failure(Error.Unauthorized("Auth.TokenExpired", "만료된 refreshToken 입니다. 다시 로그인해주세요.")); } if (existingToken.Member is null) { return Result.Failure(Error.NotFound("Auth.MemberNotFound", "회원 정보를 찾을 수 없습니다.")); } var member = existingToken.Member; var accessToken = jwtTokenProvider.CreateAccessToken(member.ID, member.Email, member.Name); var newRefreshToken = jwtTokenProvider.CreateRefreshToken(); var expiresAt = DateTime.UtcNow.AddMinutes(_jwt.AccessTokenExpiration); var refreshTokenEntity = Domain.Entities.Members.RefreshToken.Create( member.ID, newRefreshToken, DateTime.UtcNow.AddDays(_jwt.RefreshTokenExpiration) ); // 구 토큰 revoke + 후속 토큰 기록 (grace period 흡수용) existingToken.Revoke(); existingToken.MarkReplaced(newRefreshToken); await db.RefreshToken.AddAsync(refreshTokenEntity, ct); await db.SaveChangesAsync(ct); return Result.Success(new Response(accessToken, newRefreshToken, expiresAt)); } }