Handler.cs 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107
  1. using Application.Abstractions.Authentication;
  2. using Application.Abstractions.Data;
  3. using Application.Abstractions.Messaging;
  4. using Microsoft.EntityFrameworkCore;
  5. using Microsoft.Extensions.Logging;
  6. using Microsoft.Extensions.Options;
  7. using SharedKernel;
  8. using SharedKernel.Results;
  9. namespace Application.Features.Api.Auth.RefreshToken;
  10. internal sealed class Handler(
  11. IAppDbContext db,
  12. IJwtTokenProvider jwtTokenProvider,
  13. IOptions<AppSettings> options,
  14. ILogger<Handler> logger
  15. ) : ICommandHandler<Command, Result<Response>> {
  16. private readonly AppSettings.JwtSection _jwt = options.Value.JWT;
  17. /// <summary>회전 직후 race condition 흡수 grace period (초). 이 시간 내에 revoked 토큰이 재사용되면 탈취가 아닌 race 로 간주.</summary>
  18. private const int GracePeriodSeconds = 30;
  19. public async Task<Result<Response>> Handle(Command request, CancellationToken ct)
  20. {
  21. if (string.IsNullOrWhiteSpace(request.RefreshToken))
  22. {
  23. return Result.Failure<Response>(Error.Problem("Auth.TokenRequired", "refreshToken은 필수입니다."));
  24. }
  25. var existingToken = await db.RefreshToken.Include(x => x.Member).FirstOrDefaultAsync(x => x.Token == request.RefreshToken, ct);
  26. if (existingToken is null)
  27. {
  28. return Result.Failure<Response>(Error.NotFound("Auth.TokenNotFound", "유효하지 않은 refreshToken 입니다."));
  29. }
  30. if (!existingToken.IsActive)
  31. {
  32. // 이미 폐기된 토큰이 재사용된 경우
  33. if (existingToken.IsRevoked)
  34. {
  35. // Grace period — 회전 직후 짧은 시간 내 재사용은 다중 탭/race 로 간주.
  36. // ReplacedByToken 이 존재하고 RevokedAt 이 grace 윈도우 내라면, 후속 토큰을 그대로 응답.
  37. var withinGrace = existingToken.RevokedAt is DateTime revokedAt
  38. && DateTime.UtcNow - revokedAt <= TimeSpan.FromSeconds(GracePeriodSeconds)
  39. && !string.IsNullOrEmpty(existingToken.ReplacedByToken);
  40. if (withinGrace)
  41. {
  42. var replacement = await db.RefreshToken.Include(x => x.Member).FirstOrDefaultAsync(x => x.Token == existingToken.ReplacedByToken, ct);
  43. if (replacement is not null && replacement.IsActive && replacement.Member is not null)
  44. {
  45. logger.LogInformation("RefreshToken grace period hit for MemberID {MemberID} — returning replacement token", existingToken.MemberID);
  46. var graceAccessToken = jwtTokenProvider.CreateAccessToken(replacement.Member.ID, replacement.Member.Email, replacement.Member.Name);
  47. var graceExpires = DateTime.UtcNow.AddMinutes(_jwt.AccessTokenExpiration);
  48. return Result.Success(new Response(graceAccessToken, replacement.Token, graceExpires));
  49. }
  50. }
  51. // grace 가 아니면 진짜 재사용 → 전체 활성 세션 폐기 (원래 정책)
  52. var activeTokens = await db.RefreshToken.Where(t => t.MemberID == existingToken.MemberID && !t.IsRevoked).ToListAsync(ct);
  53. foreach (var t in activeTokens)
  54. {
  55. t.Revoke();
  56. }
  57. await db.SaveChangesAsync(ct);
  58. logger.LogWarning("RefreshToken reuse detected for MemberID {MemberID} — all {Count} active sessions revoked", existingToken.MemberID, activeTokens.Count);
  59. return Result.Failure<Response>(Error.Unauthorized("Auth.TokenReused", "보안상 모든 세션이 종료되었습니다. 다시 로그인해주세요."));
  60. }
  61. return Result.Failure<Response>(Error.Unauthorized("Auth.TokenExpired", "만료된 refreshToken 입니다. 다시 로그인해주세요."));
  62. }
  63. if (existingToken.Member is null)
  64. {
  65. return Result.Failure<Response>(Error.NotFound("Auth.MemberNotFound", "회원 정보를 찾을 수 없습니다."));
  66. }
  67. var member = existingToken.Member;
  68. var accessToken = jwtTokenProvider.CreateAccessToken(member.ID, member.Email, member.Name);
  69. var newRefreshToken = jwtTokenProvider.CreateRefreshToken();
  70. var expiresAt = DateTime.UtcNow.AddMinutes(_jwt.AccessTokenExpiration);
  71. var refreshTokenEntity = Domain.Entities.Members.RefreshToken.Create(
  72. member.ID,
  73. newRefreshToken,
  74. DateTime.UtcNow.AddDays(_jwt.RefreshTokenExpiration)
  75. );
  76. // 구 토큰 revoke + 후속 토큰 기록 (grace period 흡수용)
  77. existingToken.Revoke();
  78. existingToken.MarkReplaced(newRefreshToken);
  79. await db.RefreshToken.AddAsync(refreshTokenEntity, ct);
  80. await db.SaveChangesAsync(ct);
  81. return Result.Success(new Response(accessToken, newRefreshToken, expiresAt));
  82. }
  83. }