| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107 |
- 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<AppSettings> options,
- ILogger<Handler> logger
- ) : ICommandHandler<Command, Result<Response>> {
- private readonly AppSettings.JwtSection _jwt = options.Value.JWT;
- /// <summary>회전 직후 race condition 흡수 grace period (초). 이 시간 내에 revoked 토큰이 재사용되면 탈취가 아닌 race 로 간주.</summary>
- private const int GracePeriodSeconds = 30;
- public async Task<Result<Response>> Handle(Command request, CancellationToken ct)
- {
- if (string.IsNullOrWhiteSpace(request.RefreshToken))
- {
- return Result.Failure<Response>(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<Response>(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<Response>(Error.Unauthorized("Auth.TokenReused", "보안상 모든 세션이 종료되었습니다. 다시 로그인해주세요."));
- }
- return Result.Failure<Response>(Error.Unauthorized("Auth.TokenExpired", "만료된 refreshToken 입니다. 다시 로그인해주세요."));
- }
- if (existingToken.Member is null)
- {
- return Result.Failure<Response>(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));
- }
- }
|