From 4355b1f257a308bed34d2e4e73abc0d0f1fe5572 Mon Sep 17 00:00:00 2001 From: Sean-mn Date: Mon, 6 Apr 2026 14:20:58 +0900 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20=EB=A6=AC=ED=94=84=EB=A0=88?= =?UTF-8?q?=EC=8B=9C=20=ED=86=A0=ED=81=B0=20=EB=A1=9C=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #7 --- .../Account/Repository/AccountRepository.cs | 5 + .../Interface/IAccountRepository.cs | 1 + .../Domain/Auth/Controller/AuthController.cs | 1 - .../Interface/IRefreshTokenRedisRepository.cs | 2 + .../Repository/RefreshTokenRedisRepository.cs | 92 +++++++++++++++++-- .../Auth/Service/RefreshTokenService.cs | 26 +++--- 6 files changed, 104 insertions(+), 23 deletions(-) diff --git a/Fantasy-server/Fantasy.Server/Domain/Account/Repository/AccountRepository.cs b/Fantasy-server/Fantasy.Server/Domain/Account/Repository/AccountRepository.cs index 1ce986c..2c0a6b7 100644 --- a/Fantasy-server/Fantasy.Server/Domain/Account/Repository/AccountRepository.cs +++ b/Fantasy-server/Fantasy.Server/Domain/Account/Repository/AccountRepository.cs @@ -19,6 +19,11 @@ public AccountRepository(AppDbContext db) .AsNoTracking() .FirstOrDefaultAsync(a => a.Email == email); + public async Task FindByIdAsync(long id) + => await _db.Accounts + .AsNoTracking() + .FirstOrDefaultAsync(a => a.Id == id); + public async Task SaveAsync(AccountEntity account) { var entry = _db.Accounts.Entry(account); diff --git a/Fantasy-server/Fantasy.Server/Domain/Account/Repository/Interface/IAccountRepository.cs b/Fantasy-server/Fantasy.Server/Domain/Account/Repository/Interface/IAccountRepository.cs index bfff2aa..b739101 100644 --- a/Fantasy-server/Fantasy.Server/Domain/Account/Repository/Interface/IAccountRepository.cs +++ b/Fantasy-server/Fantasy.Server/Domain/Account/Repository/Interface/IAccountRepository.cs @@ -5,6 +5,7 @@ namespace Fantasy.Server.Domain.Account.Repository.Interface; public interface IAccountRepository { Task FindByEmailAsync(string email); + Task FindByIdAsync(long id); Task SaveAsync(AccountEntity account); Task ExistsByEmailAsync(string email); Task DeleteAsync(AccountEntity account); diff --git a/Fantasy-server/Fantasy.Server/Domain/Auth/Controller/AuthController.cs b/Fantasy-server/Fantasy.Server/Domain/Auth/Controller/AuthController.cs index 3b50276..ae3def8 100644 --- a/Fantasy-server/Fantasy.Server/Domain/Auth/Controller/AuthController.cs +++ b/Fantasy-server/Fantasy.Server/Domain/Auth/Controller/AuthController.cs @@ -42,7 +42,6 @@ public async Task Logout() return CommonApiResponse.Success("로그아웃 성공."); } - [Authorize] [HttpPost("refresh")] public async Task> Refresh([FromBody] RefreshTokenRequest request) { diff --git a/Fantasy-server/Fantasy.Server/Domain/Auth/Repository/Interface/IRefreshTokenRedisRepository.cs b/Fantasy-server/Fantasy.Server/Domain/Auth/Repository/Interface/IRefreshTokenRedisRepository.cs index 967247e..8d5d2d9 100644 --- a/Fantasy-server/Fantasy.Server/Domain/Auth/Repository/Interface/IRefreshTokenRedisRepository.cs +++ b/Fantasy-server/Fantasy.Server/Domain/Auth/Repository/Interface/IRefreshTokenRedisRepository.cs @@ -3,6 +3,8 @@ namespace Fantasy.Server.Domain.Auth.Repository.Interface; public interface IRefreshTokenRedisRepository { Task SaveAsync(long id, string token, TimeSpan ttl); + Task RotateAsync(long id, string expectedOldToken, string newToken, TimeSpan ttl); Task FindByIdAsync(long id); + Task FindIdByTokenAsync(string token); Task DeleteAsync(long id); } diff --git a/Fantasy-server/Fantasy.Server/Domain/Auth/Repository/RefreshTokenRedisRepository.cs b/Fantasy-server/Fantasy.Server/Domain/Auth/Repository/RefreshTokenRedisRepository.cs index 1c7fd8b..335b3e1 100644 --- a/Fantasy-server/Fantasy.Server/Domain/Auth/Repository/RefreshTokenRedisRepository.cs +++ b/Fantasy-server/Fantasy.Server/Domain/Auth/Repository/RefreshTokenRedisRepository.cs @@ -1,29 +1,101 @@ using Fantasy.Server.Domain.Auth.Repository.Interface; -using Microsoft.Extensions.Caching.Distributed; +using StackExchange.Redis; namespace Fantasy.Server.Domain.Auth.Repository; public class RefreshTokenRedisRepository : IRefreshTokenRedisRepository { - private readonly IDistributedCache _cache; + private const string Prefix = "fantasy:"; + private readonly IDatabase _db; - public RefreshTokenRedisRepository(IDistributedCache cache) + public RefreshTokenRedisRepository(IConnectionMultiplexer multiplexer) { - _cache = cache; + _db = multiplexer.GetDatabase(); } + private static string ForwardKey(long id) => $"{Prefix}refresh:{id}"; + private static string ReverseKey(string token) => $"{Prefix}refresh:token:{token}"; + public async Task SaveAsync(long id, string token, TimeSpan ttl) { - var options = new DistributedCacheEntryOptions + var script = LuaScript.Prepare(@" + local old = redis.call('GET', @forwardKey) + if old then + redis.call('DEL', '" + Prefix + @"refresh:token:' .. old) + end + redis.call('SET', @forwardKey, @token, 'EX', @ttl) + redis.call('SET', @reverseKey, @id, 'EX', @ttl) + return 1 + "); + + await _db.ScriptEvaluateAsync(script, new { - AbsoluteExpirationRelativeToNow = ttl - }; - await _cache.SetStringAsync($"refresh:{id}", token, options); + forwardKey = (RedisKey)ForwardKey(id), + reverseKey = (RedisKey)ReverseKey(token), + token = (RedisValue)token, + id = (RedisValue)id.ToString(), + ttl = (RedisValue)(long)ttl.TotalSeconds + }); + } + + public async Task RotateAsync(long id, string expectedOldToken, string newToken, TimeSpan ttl) + { + var script = LuaScript.Prepare(@" + local current = redis.call('GET', @forwardKey) + if not current or current ~= @expectedOldToken then + return 0 + end + local reverseId = redis.call('GET', @oldReverseKey) + if not reverseId or reverseId ~= @id then + return 0 + end + redis.call('DEL', @oldReverseKey) + redis.call('SET', @forwardKey, @newToken, 'EX', @ttl) + redis.call('SET', @newReverseKey, @id, 'EX', @ttl) + return 1 + "); + + var result = await _db.ScriptEvaluateAsync(script, new + { + forwardKey = (RedisKey)ForwardKey(id), + oldReverseKey = (RedisKey)ReverseKey(expectedOldToken), + newReverseKey = (RedisKey)ReverseKey(newToken), + expectedOldToken = (RedisValue)expectedOldToken, + newToken = (RedisValue)newToken, + id = (RedisValue)id.ToString(), + ttl = (RedisValue)(long)ttl.TotalSeconds + }); + + return (long)result == 1; } public async Task FindByIdAsync(long id) - => await _cache.GetStringAsync($"refresh:{id}"); + { + var value = await _db.StringGetAsync(ForwardKey(id)); + return value.HasValue ? value.ToString() : null; + } + + public async Task FindIdByTokenAsync(string token) + { + var value = await _db.StringGetAsync(ReverseKey(token)); + if (!value.HasValue) return null; + return long.TryParse(value.ToString(), out var id) ? id : null; + } public async Task DeleteAsync(long id) - => await _cache.RemoveAsync($"refresh:{id}"); + { + var script = LuaScript.Prepare(@" + local token = redis.call('GET', @forwardKey) + if token then + redis.call('DEL', '" + Prefix + @"refresh:token:' .. token) + end + redis.call('DEL', @forwardKey) + return 1 + "); + + await _db.ScriptEvaluateAsync(script, new + { + forwardKey = (RedisKey)ForwardKey(id) + }); + } } diff --git a/Fantasy-server/Fantasy.Server/Domain/Auth/Service/RefreshTokenService.cs b/Fantasy-server/Fantasy.Server/Domain/Auth/Service/RefreshTokenService.cs index c5ee0fd..763c12d 100644 --- a/Fantasy-server/Fantasy.Server/Domain/Auth/Service/RefreshTokenService.cs +++ b/Fantasy-server/Fantasy.Server/Domain/Auth/Service/RefreshTokenService.cs @@ -1,9 +1,9 @@ +using Fantasy.Server.Domain.Account.Repository.Interface; using Fantasy.Server.Domain.Auth.Dto.Request; using Fantasy.Server.Domain.Auth.Dto.Response; using Fantasy.Server.Domain.Auth.Repository.Interface; using Fantasy.Server.Domain.Auth.Service.Interface; using Fantasy.Server.Global.Security.Jwt; -using Fantasy.Server.Global.Security.Provider; using Gamism.SDK.Extensions.AspNetCore.Exceptions; namespace Fantasy.Server.Domain.Auth.Service; @@ -12,18 +12,18 @@ public class RefreshTokenService : IRefreshTokenService { private static readonly TimeSpan RefreshTokenTtl = TimeSpan.FromDays(30); - private readonly ICurrentUserProvider _currentUserProvider; + private readonly IAccountRepository _accountRepository; private readonly IRefreshTokenRedisRepository _refreshTokenRepository; private readonly IJwtProvider _jwtProvider; private readonly int _accessTokenExpirationMinutes; public RefreshTokenService( - ICurrentUserProvider currentUserProvider, + IAccountRepository accountRepository, IRefreshTokenRedisRepository refreshTokenRepository, IJwtProvider jwtProvider, IConfiguration configuration) { - _currentUserProvider = currentUserProvider; + _accountRepository = accountRepository; _refreshTokenRepository = refreshTokenRepository; _jwtProvider = jwtProvider; _accessTokenExpirationMinutes = int.Parse( @@ -32,23 +32,25 @@ public RefreshTokenService( public async Task ExecuteAsync(RefreshTokenRequest request) { - var account = await _currentUserProvider.GetAccountAsync(); - - var stored = await _refreshTokenRepository.FindByIdAsync(account.Id) + var accountId = await _refreshTokenRepository.FindIdByTokenAsync(request.RefreshToken) ?? throw new UnauthorizedException("리프레시 토큰을 찾을 수 없습니다."); - if (stored != request.RefreshToken) - throw new UnauthorizedException("리프레시 토큰이 올바르지 않습니다."); + var account = await _accountRepository.FindByIdAsync(accountId) + ?? throw new UnauthorizedException("인증 정보를 찾을 수 없습니다."); var accessToken = _jwtProvider.GenerateAccessToken(account); - var refreshToken = _jwtProvider.GenerateRefreshToken(); + var newRefreshToken = _jwtProvider.GenerateRefreshToken(); + + var rotated = await _refreshTokenRepository.RotateAsync( + account.Id, request.RefreshToken, newRefreshToken, RefreshTokenTtl); - await _refreshTokenRepository.SaveAsync(account.Id, refreshToken, RefreshTokenTtl); + if (!rotated) + throw new UnauthorizedException("리프레시 토큰이 이미 사용되었거나 올바르지 않습니다."); var accessTokenExpiresAt = DateTimeOffset.UtcNow .AddMinutes(_accessTokenExpirationMinutes) .ToUnixTimeSeconds(); - return new TokenResponse(accessToken, refreshToken, accessTokenExpiresAt); + return new TokenResponse(accessToken, newRefreshToken, accessTokenExpiresAt); } } From 85efcc07fafbb3df29dba1f950cf521371505adc Mon Sep 17 00:00:00 2001 From: Sean-mn Date: Mon, 6 Apr 2026 14:21:11 +0900 Subject: [PATCH 02/10] =?UTF-8?q?test:=20RefreshTokenService=20=EB=A1=9C?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EC=85=98=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #7 --- .../Auth/Service/RefreshTokenServiceTests.cs | 102 ++++++++++-------- 1 file changed, 60 insertions(+), 42 deletions(-) diff --git a/Fantasy-server/Fantasy.Test/Auth/Service/RefreshTokenServiceTests.cs b/Fantasy-server/Fantasy.Test/Auth/Service/RefreshTokenServiceTests.cs index 95e8909..b557fe9 100644 --- a/Fantasy-server/Fantasy.Test/Auth/Service/RefreshTokenServiceTests.cs +++ b/Fantasy-server/Fantasy.Test/Auth/Service/RefreshTokenServiceTests.cs @@ -1,8 +1,8 @@ +using Fantasy.Server.Domain.Account.Repository.Interface; using Fantasy.Server.Domain.Auth.Dto.Request; using Fantasy.Server.Domain.Auth.Repository.Interface; using Fantasy.Server.Domain.Auth.Service; using Fantasy.Server.Global.Security.Jwt; -using Fantasy.Server.Global.Security.Provider; using FluentAssertions; using Gamism.SDK.Extensions.AspNetCore.Exceptions; using Microsoft.Extensions.Configuration; @@ -14,116 +14,134 @@ namespace Fantasy.Test.Auth.Service; public class RefreshTokenServiceTests { + private const string RefreshToken = "valid-refresh-token"; + private const long AccountId = 1L; + + private static AccountEntity CreateAccount() + => AccountEntity.Create("user@example.com", "hashed_password"); + public class 유효한_리프레시_토큰으로_요청할_때 { - private readonly ICurrentUserProvider _currentUserProvider = Substitute.For(); + private readonly IAccountRepository _accountRepository = Substitute.For(); private readonly IRefreshTokenRedisRepository _refreshTokenRepository = Substitute.For(); private readonly IJwtProvider _jwtProvider = Substitute.For(); private readonly IConfiguration _configuration = Substitute.For(); private readonly RefreshTokenService _sut; - private readonly AccountEntity _account = AccountEntity.Create("user@example.com", "hashed_password"); - private readonly RefreshTokenRequest _request = new("valid-refresh-token"); + private readonly AccountEntity _account = CreateAccount(); + private readonly RefreshTokenRequest _request = new(RefreshToken); public 유효한_리프레시_토큰으로_요청할_때() { - _currentUserProvider.GetAccountAsync().Returns(_account); - _refreshTokenRepository.FindByIdAsync(_account.Id).Returns("valid-refresh-token"); + _refreshTokenRepository.FindIdByTokenAsync(RefreshToken).Returns(AccountId); + _accountRepository.FindByIdAsync(AccountId).Returns(_account); + _refreshTokenRepository + .RotateAsync(_account.Id, RefreshToken, Arg.Any(), TimeSpan.FromDays(30)) + .Returns(true); _jwtProvider.GenerateAccessToken(_account).Returns("new-access-token"); _jwtProvider.GenerateRefreshToken().Returns("new-refresh-token"); _configuration["Jwt:AccessTokenExpirationMinutes"].Returns("15"); - _sut = new RefreshTokenService(_currentUserProvider, _refreshTokenRepository, _jwtProvider, _configuration); + _sut = new RefreshTokenService(_accountRepository, _refreshTokenRepository, _jwtProvider, _configuration); } [Fact] public async Task 토큰_갱신_요청_시_새_AccessToken과_RefreshToken을_반환한다() { - // Arrange - // (생성자에서 설정 완료) - - // Act var result = await _sut.ExecuteAsync(_request); - // Assert result.AccessToken.Should().Be("new-access-token"); result.RefreshToken.Should().Be("new-refresh-token"); result.AccessTokenExpiresAt.Should().BeGreaterThan(0); } [Fact] - public async Task 토큰_갱신_요청_시_새_리프레시_토큰이_Redis에_저장된다() + public async Task 토큰_갱신_요청_시_RotateAsync가_호출된다() { - // Arrange - // (생성자에서 설정 완료) - - // Act await _sut.ExecuteAsync(_request); - // Assert await _refreshTokenRepository.Received(1) - .SaveAsync(_account.Id, "new-refresh-token", TimeSpan.FromDays(30)); + .RotateAsync(_account.Id, RefreshToken, "new-refresh-token", TimeSpan.FromDays(30)); } } public class Redis에_리프레시_토큰이_없을_때 { - private readonly ICurrentUserProvider _currentUserProvider = Substitute.For(); + private readonly IAccountRepository _accountRepository = Substitute.For(); private readonly IRefreshTokenRedisRepository _refreshTokenRepository = Substitute.For(); private readonly IJwtProvider _jwtProvider = Substitute.For(); private readonly IConfiguration _configuration = Substitute.For(); private readonly RefreshTokenService _sut; - private readonly AccountEntity _account = AccountEntity.Create("user@example.com", "hashed_password"); - private readonly RefreshTokenRequest _request = new("some-refresh-token"); + private readonly RefreshTokenRequest _request = new("unknown-token"); public Redis에_리프레시_토큰이_없을_때() { - _currentUserProvider.GetAccountAsync().Returns(_account); - _refreshTokenRepository.FindByIdAsync(_account.Id).Returns((string?)null); + _refreshTokenRepository.FindIdByTokenAsync("unknown-token").Returns((long?)null); _configuration["Jwt:AccessTokenExpirationMinutes"].Returns("15"); - _sut = new RefreshTokenService(_currentUserProvider, _refreshTokenRepository, _jwtProvider, _configuration); + _sut = new RefreshTokenService(_accountRepository, _refreshTokenRepository, _jwtProvider, _configuration); } [Fact] public async Task 토큰_갱신_요청_시_UnauthorizedException이_발생한다() { - // Arrange - // (생성자에서 설정 완료) - - // Act var act = async () => await _sut.ExecuteAsync(_request); - // Assert await act.Should().ThrowAsync(); } } - public class 리프레시_토큰이_불일치할_때 + public class 계정이_존재하지_않을_때 { - private readonly ICurrentUserProvider _currentUserProvider = Substitute.For(); + private readonly IAccountRepository _accountRepository = Substitute.For(); private readonly IRefreshTokenRedisRepository _refreshTokenRepository = Substitute.For(); private readonly IJwtProvider _jwtProvider = Substitute.For(); private readonly IConfiguration _configuration = Substitute.For(); private readonly RefreshTokenService _sut; - private readonly AccountEntity _account = AccountEntity.Create("user@example.com", "hashed_password"); - private readonly RefreshTokenRequest _request = new("wrong-refresh-token"); + private readonly RefreshTokenRequest _request = new(RefreshToken); - public 리프레시_토큰이_불일치할_때() + public 계정이_존재하지_않을_때() { - _currentUserProvider.GetAccountAsync().Returns(_account); - _refreshTokenRepository.FindByIdAsync(_account.Id).Returns("stored-refresh-token"); + _refreshTokenRepository.FindIdByTokenAsync(RefreshToken).Returns(AccountId); + _accountRepository.FindByIdAsync(AccountId).Returns((AccountEntity?)null); _configuration["Jwt:AccessTokenExpirationMinutes"].Returns("15"); - _sut = new RefreshTokenService(_currentUserProvider, _refreshTokenRepository, _jwtProvider, _configuration); + _sut = new RefreshTokenService(_accountRepository, _refreshTokenRepository, _jwtProvider, _configuration); } [Fact] public async Task 토큰_갱신_요청_시_UnauthorizedException이_발생한다() { - // Arrange - // (생성자에서 설정 완료) + var act = async () => await _sut.ExecuteAsync(_request); - // Act + await act.Should().ThrowAsync(); + } + } + + public class RotateAsync가_실패할_때 + { + private readonly IAccountRepository _accountRepository = Substitute.For(); + private readonly IRefreshTokenRedisRepository _refreshTokenRepository = Substitute.For(); + private readonly IJwtProvider _jwtProvider = Substitute.For(); + private readonly IConfiguration _configuration = Substitute.For(); + private readonly RefreshTokenService _sut; + private readonly AccountEntity _account = CreateAccount(); + private readonly RefreshTokenRequest _request = new(RefreshToken); + + public RotateAsync가_실패할_때() + { + _refreshTokenRepository.FindIdByTokenAsync(RefreshToken).Returns(AccountId); + _accountRepository.FindByIdAsync(AccountId).Returns(_account); + _refreshTokenRepository + .RotateAsync(_account.Id, RefreshToken, Arg.Any(), TimeSpan.FromDays(30)) + .Returns(false); + _jwtProvider.GenerateAccessToken(_account).Returns("new-access-token"); + _jwtProvider.GenerateRefreshToken().Returns("new-refresh-token"); + _configuration["Jwt:AccessTokenExpirationMinutes"].Returns("15"); + _sut = new RefreshTokenService(_accountRepository, _refreshTokenRepository, _jwtProvider, _configuration); + } + + [Fact] + public async Task 토큰_갱신_요청_시_UnauthorizedException이_발생한다() + { var act = async () => await _sut.ExecuteAsync(_request); - // Assert await act.Should().ThrowAsync(); } } From f99a7266c52814365a427dc8ab1715f81dc4b90e Mon Sep 17 00:00:00 2001 From: Sean-mn Date: Mon, 6 Apr 2026 14:48:55 +0900 Subject: [PATCH 03/10] =?UTF-8?q?update:=20RefreshTokenRedisRepository=20L?= =?UTF-8?q?ua=20=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Repository/RefreshTokenRedisRepository.cs | 108 +++++++++--------- 1 file changed, 57 insertions(+), 51 deletions(-) diff --git a/Fantasy-server/Fantasy.Server/Domain/Auth/Repository/RefreshTokenRedisRepository.cs b/Fantasy-server/Fantasy.Server/Domain/Auth/Repository/RefreshTokenRedisRepository.cs index 335b3e1..22de72d 100644 --- a/Fantasy-server/Fantasy.Server/Domain/Auth/Repository/RefreshTokenRedisRepository.cs +++ b/Fantasy-server/Fantasy.Server/Domain/Auth/Repository/RefreshTokenRedisRepository.cs @@ -6,6 +6,42 @@ namespace Fantasy.Server.Domain.Auth.Repository; public class RefreshTokenRedisRepository : IRefreshTokenRedisRepository { private const string Prefix = "fantasy:"; + private static readonly string ReverseKeyPrefix = $"{Prefix}refresh:token:"; + + private static readonly LuaScript SaveScript = LuaScript.Prepare(@" + local old = redis.call('GET', @forwardKey) + if old then + redis.call('DEL', @reverseKeyPrefix .. old) + end + redis.call('SET', @forwardKey, @token, 'EX', @ttl) + redis.call('SET', @reverseKey, @id, 'EX', @ttl) + return 1 + "); + + private static readonly LuaScript RotateScript = LuaScript.Prepare(@" + local current = redis.call('GET', @forwardKey) + if not current or current ~= @expectedOldToken then + return 0 + end + local reverseId = redis.call('GET', @oldReverseKey) + if not reverseId or reverseId ~= @id then + return 0 + end + redis.call('DEL', @oldReverseKey) + redis.call('SET', @forwardKey, @newToken, 'EX', @ttl) + redis.call('SET', @newReverseKey, @id, 'EX', @ttl) + return 1 + "); + + private static readonly LuaScript DeleteScript = LuaScript.Prepare(@" + local token = redis.call('GET', @forwardKey) + if token then + redis.call('DEL', @reverseKeyPrefix .. token) + end + redis.call('DEL', @forwardKey) + return 1 + "); + private readonly IDatabase _db; public RefreshTokenRedisRepository(IConnectionMultiplexer multiplexer) @@ -14,56 +50,32 @@ public RefreshTokenRedisRepository(IConnectionMultiplexer multiplexer) } private static string ForwardKey(long id) => $"{Prefix}refresh:{id}"; - private static string ReverseKey(string token) => $"{Prefix}refresh:token:{token}"; + private static string ReverseKey(string token) => $"{ReverseKeyPrefix}{token}"; public async Task SaveAsync(long id, string token, TimeSpan ttl) { - var script = LuaScript.Prepare(@" - local old = redis.call('GET', @forwardKey) - if old then - redis.call('DEL', '" + Prefix + @"refresh:token:' .. old) - end - redis.call('SET', @forwardKey, @token, 'EX', @ttl) - redis.call('SET', @reverseKey, @id, 'EX', @ttl) - return 1 - "); - - await _db.ScriptEvaluateAsync(script, new + await _db.ScriptEvaluateAsync(SaveScript, new { - forwardKey = (RedisKey)ForwardKey(id), - reverseKey = (RedisKey)ReverseKey(token), - token = (RedisValue)token, - id = (RedisValue)id.ToString(), - ttl = (RedisValue)(long)ttl.TotalSeconds + forwardKey = (RedisKey)ForwardKey(id), + reverseKey = (RedisKey)ReverseKey(token), + reverseKeyPrefix = (RedisValue)ReverseKeyPrefix, + token = (RedisValue)token, + id = (RedisValue)id.ToString(), + ttl = (RedisValue)(long)ttl.TotalSeconds }); } public async Task RotateAsync(long id, string expectedOldToken, string newToken, TimeSpan ttl) { - var script = LuaScript.Prepare(@" - local current = redis.call('GET', @forwardKey) - if not current or current ~= @expectedOldToken then - return 0 - end - local reverseId = redis.call('GET', @oldReverseKey) - if not reverseId or reverseId ~= @id then - return 0 - end - redis.call('DEL', @oldReverseKey) - redis.call('SET', @forwardKey, @newToken, 'EX', @ttl) - redis.call('SET', @newReverseKey, @id, 'EX', @ttl) - return 1 - "); - - var result = await _db.ScriptEvaluateAsync(script, new + var result = await _db.ScriptEvaluateAsync(RotateScript, new { - forwardKey = (RedisKey)ForwardKey(id), - oldReverseKey = (RedisKey)ReverseKey(expectedOldToken), - newReverseKey = (RedisKey)ReverseKey(newToken), + forwardKey = (RedisKey)ForwardKey(id), + oldReverseKey = (RedisKey)ReverseKey(expectedOldToken), + newReverseKey = (RedisKey)ReverseKey(newToken), expectedOldToken = (RedisValue)expectedOldToken, - newToken = (RedisValue)newToken, - id = (RedisValue)id.ToString(), - ttl = (RedisValue)(long)ttl.TotalSeconds + newToken = (RedisValue)newToken, + id = (RedisValue)id.ToString(), + ttl = (RedisValue)(long)ttl.TotalSeconds }); return (long)result == 1; @@ -79,23 +91,17 @@ public async Task RotateAsync(long id, string expectedOldToken, string new { var value = await _db.StringGetAsync(ReverseKey(token)); if (!value.HasValue) return null; - return long.TryParse(value.ToString(), out var id) ? id : null; + if (!long.TryParse(value.ToString(), out var id)) + throw new InvalidOperationException($"Redis 역방향 키에 저장된 값이 올바른 계정 ID 형식이 아닙니다: token={token}"); + return id; } public async Task DeleteAsync(long id) { - var script = LuaScript.Prepare(@" - local token = redis.call('GET', @forwardKey) - if token then - redis.call('DEL', '" + Prefix + @"refresh:token:' .. token) - end - redis.call('DEL', @forwardKey) - return 1 - "); - - await _db.ScriptEvaluateAsync(script, new + await _db.ScriptEvaluateAsync(DeleteScript, new { - forwardKey = (RedisKey)ForwardKey(id) + forwardKey = (RedisKey)ForwardKey(id), + reverseKeyPrefix = (RedisValue)ReverseKeyPrefix }); } } From 7656c1a1c5a8af7810e1e018edd5355f997fbdc6 Mon Sep 17 00:00:00 2001 From: Sean-mn Date: Mon, 6 Apr 2026 14:49:07 +0900 Subject: [PATCH 04/10] =?UTF-8?q?update:=20=EC=98=88=EC=99=B8=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=ED=95=9C=EA=B5=AD=EC=96=B4=EB=A1=9C=20?= =?UTF-8?q?=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Fantasy.Server/Global/Config/DatabaseConfig.cs | 2 +- Fantasy-server/Fantasy.Server/Global/Config/JwtConfig.cs | 6 +++--- Fantasy-server/Fantasy.Server/Global/Config/RedisConfig.cs | 2 +- .../Fantasy.Server/Global/Security/Jwt/JwtProvider.cs | 6 +++--- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Fantasy-server/Fantasy.Server/Global/Config/DatabaseConfig.cs b/Fantasy-server/Fantasy.Server/Global/Config/DatabaseConfig.cs index ce316e5..654e235 100644 --- a/Fantasy-server/Fantasy.Server/Global/Config/DatabaseConfig.cs +++ b/Fantasy-server/Fantasy.Server/Global/Config/DatabaseConfig.cs @@ -10,7 +10,7 @@ public static IServiceCollection AddDatabase( IConfiguration configuration) { var connectionString = configuration.GetConnectionString("Database") - ?? throw new InvalidOperationException("Database connection string is missing."); + ?? throw new InvalidOperationException("데이터베이스 연결 문자열이 설정되지 않았습니다."); services.AddDbContext(options => options.UseNpgsql(connectionString)); diff --git a/Fantasy-server/Fantasy.Server/Global/Config/JwtConfig.cs b/Fantasy-server/Fantasy.Server/Global/Config/JwtConfig.cs index 6d1aa61..f45c2c1 100644 --- a/Fantasy-server/Fantasy.Server/Global/Config/JwtConfig.cs +++ b/Fantasy-server/Fantasy.Server/Global/Config/JwtConfig.cs @@ -11,11 +11,11 @@ public static IServiceCollection AddJwtAuthentication( IConfiguration configuration) { var secretKey = configuration["Jwt:SecretKey"] - ?? throw new InvalidOperationException("JWT secret key is missing."); + ?? throw new InvalidOperationException("JWT 시크릿 키가 설정되지 않았습니다."); var issuer = configuration["Jwt:Issuer"] - ?? throw new InvalidOperationException("JWT issuer is missing."); + ?? throw new InvalidOperationException("JWT 발급자가 설정되지 않았습니다."); var audience = configuration["Jwt:Audience"] - ?? throw new InvalidOperationException("JWT audience is missing."); + ?? throw new InvalidOperationException("JWT 대상이 설정되지 않았습니다."); services.AddAuthentication(options => { diff --git a/Fantasy-server/Fantasy.Server/Global/Config/RedisConfig.cs b/Fantasy-server/Fantasy.Server/Global/Config/RedisConfig.cs index e63be40..04845c1 100644 --- a/Fantasy-server/Fantasy.Server/Global/Config/RedisConfig.cs +++ b/Fantasy-server/Fantasy.Server/Global/Config/RedisConfig.cs @@ -10,7 +10,7 @@ public static IServiceCollection AddRedis( string instanceName) { var connectionString = configuration.GetConnectionString("Redis") - ?? throw new InvalidOperationException("Redis connection string is missing."); + ?? throw new InvalidOperationException("Redis 연결 문자열이 설정되지 않았습니다."); services.AddSingleton( ConnectionMultiplexer.Connect(connectionString)); diff --git a/Fantasy-server/Fantasy.Server/Global/Security/Jwt/JwtProvider.cs b/Fantasy-server/Fantasy.Server/Global/Security/Jwt/JwtProvider.cs index dde2a89..4ee5606 100644 --- a/Fantasy-server/Fantasy.Server/Global/Security/Jwt/JwtProvider.cs +++ b/Fantasy-server/Fantasy.Server/Global/Security/Jwt/JwtProvider.cs @@ -17,11 +17,11 @@ public class JwtProvider : IJwtProvider public JwtProvider(IConfiguration configuration) { var secretKey = configuration["Jwt:SecretKey"] - ?? throw new InvalidOperationException("JWT secret key is missing."); + ?? throw new InvalidOperationException("JWT 시크릿 키가 설정되지 않았습니다."); _issuer = configuration["Jwt:Issuer"] - ?? throw new InvalidOperationException("JWT issuer is missing."); + ?? throw new InvalidOperationException("JWT 발급자가 설정되지 않았습니다."); _audience = configuration["Jwt:Audience"] - ?? throw new InvalidOperationException("JWT audience is missing."); + ?? throw new InvalidOperationException("JWT 대상이 설정되지 않았습니다."); _accessTokenExpirationMinutes = int.Parse( configuration["Jwt:AccessTokenExpirationMinutes"] ?? "15"); From 1274c55aa0b5fc95581996487a8fe537157b72e4 Mon Sep 17 00:00:00 2001 From: Sean-mn Date: Mon, 6 Apr 2026 15:10:46 +0900 Subject: [PATCH 05/10] =?UTF-8?q?feat:=20RotateResult=20=EC=97=B4=EA=B1=B0?= =?UTF-8?q?=ED=98=95=20=EB=8F=84=EC=9E=85=20=EB=B0=8F=20=ED=86=A0=ED=81=B0?= =?UTF-8?q?=20=EC=9E=AC=EC=82=AC=EC=9A=A9=20=EA=B0=90=EC=A7=80=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #7 --- .../Fantasy.Server/Domain/Auth/Enum/RotateResult.cs | 8 ++++++++ .../Interface/IRefreshTokenRedisRepository.cs | 4 +++- .../Auth/Repository/RefreshTokenRedisRepository.cs | 13 ++++++++++--- .../Domain/Auth/Service/RefreshTokenService.cs | 12 +++++++++--- 4 files changed, 30 insertions(+), 7 deletions(-) create mode 100644 Fantasy-server/Fantasy.Server/Domain/Auth/Enum/RotateResult.cs diff --git a/Fantasy-server/Fantasy.Server/Domain/Auth/Enum/RotateResult.cs b/Fantasy-server/Fantasy.Server/Domain/Auth/Enum/RotateResult.cs new file mode 100644 index 0000000..a5bd600 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Auth/Enum/RotateResult.cs @@ -0,0 +1,8 @@ +namespace Fantasy.Server.Domain.Auth.Enum; + +public enum RotateResult +{ + NotFound = 0, + Reused = -1, + Success = 1 +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Auth/Repository/Interface/IRefreshTokenRedisRepository.cs b/Fantasy-server/Fantasy.Server/Domain/Auth/Repository/Interface/IRefreshTokenRedisRepository.cs index 8d5d2d9..1528898 100644 --- a/Fantasy-server/Fantasy.Server/Domain/Auth/Repository/Interface/IRefreshTokenRedisRepository.cs +++ b/Fantasy-server/Fantasy.Server/Domain/Auth/Repository/Interface/IRefreshTokenRedisRepository.cs @@ -1,9 +1,11 @@ +using Fantasy.Server.Domain.Auth.Enum; + namespace Fantasy.Server.Domain.Auth.Repository.Interface; public interface IRefreshTokenRedisRepository { Task SaveAsync(long id, string token, TimeSpan ttl); - Task RotateAsync(long id, string expectedOldToken, string newToken, TimeSpan ttl); + Task RotateAsync(long id, string expectedOldToken, string newToken, TimeSpan ttl); Task FindByIdAsync(long id); Task FindIdByTokenAsync(string token); Task DeleteAsync(long id); diff --git a/Fantasy-server/Fantasy.Server/Domain/Auth/Repository/RefreshTokenRedisRepository.cs b/Fantasy-server/Fantasy.Server/Domain/Auth/Repository/RefreshTokenRedisRepository.cs index 22de72d..aebae7d 100644 --- a/Fantasy-server/Fantasy.Server/Domain/Auth/Repository/RefreshTokenRedisRepository.cs +++ b/Fantasy-server/Fantasy.Server/Domain/Auth/Repository/RefreshTokenRedisRepository.cs @@ -1,3 +1,4 @@ +using Fantasy.Server.Domain.Auth.Enum; using Fantasy.Server.Domain.Auth.Repository.Interface; using StackExchange.Redis; @@ -20,9 +21,14 @@ public class RefreshTokenRedisRepository : IRefreshTokenRedisRepository private static readonly LuaScript RotateScript = LuaScript.Prepare(@" local current = redis.call('GET', @forwardKey) - if not current or current ~= @expectedOldToken then + if not current then return 0 end + if current ~= @expectedOldToken then + redis.call('DEL', @forwardKey) + redis.call('DEL', @reverseKeyPrefix .. current) + return -1 + end local reverseId = redis.call('GET', @oldReverseKey) if not reverseId or reverseId ~= @id then return 0 @@ -65,20 +71,21 @@ public async Task SaveAsync(long id, string token, TimeSpan ttl) }); } - public async Task RotateAsync(long id, string expectedOldToken, string newToken, TimeSpan ttl) + public async Task RotateAsync(long id, string expectedOldToken, string newToken, TimeSpan ttl) { var result = await _db.ScriptEvaluateAsync(RotateScript, new { forwardKey = (RedisKey)ForwardKey(id), oldReverseKey = (RedisKey)ReverseKey(expectedOldToken), newReverseKey = (RedisKey)ReverseKey(newToken), + reverseKeyPrefix = (RedisValue)ReverseKeyPrefix, expectedOldToken = (RedisValue)expectedOldToken, newToken = (RedisValue)newToken, id = (RedisValue)id.ToString(), ttl = (RedisValue)(long)ttl.TotalSeconds }); - return (long)result == 1; + return (RotateResult)(int)(long)result; } public async Task FindByIdAsync(long id) diff --git a/Fantasy-server/Fantasy.Server/Domain/Auth/Service/RefreshTokenService.cs b/Fantasy-server/Fantasy.Server/Domain/Auth/Service/RefreshTokenService.cs index 763c12d..8f9c1bf 100644 --- a/Fantasy-server/Fantasy.Server/Domain/Auth/Service/RefreshTokenService.cs +++ b/Fantasy-server/Fantasy.Server/Domain/Auth/Service/RefreshTokenService.cs @@ -1,6 +1,7 @@ using Fantasy.Server.Domain.Account.Repository.Interface; using Fantasy.Server.Domain.Auth.Dto.Request; using Fantasy.Server.Domain.Auth.Dto.Response; +using Fantasy.Server.Domain.Auth.Enum; using Fantasy.Server.Domain.Auth.Repository.Interface; using Fantasy.Server.Domain.Auth.Service.Interface; using Fantasy.Server.Global.Security.Jwt; @@ -41,11 +42,16 @@ public async Task ExecuteAsync(RefreshTokenRequest request) var accessToken = _jwtProvider.GenerateAccessToken(account); var newRefreshToken = _jwtProvider.GenerateRefreshToken(); - var rotated = await _refreshTokenRepository.RotateAsync( + var rotateResult = await _refreshTokenRepository.RotateAsync( account.Id, request.RefreshToken, newRefreshToken, RefreshTokenTtl); - if (!rotated) - throw new UnauthorizedException("리프레시 토큰이 이미 사용되었거나 올바르지 않습니다."); + switch (rotateResult) + { + case RotateResult.Reused: + throw new UnauthorizedException("토큰 재사용이 감지되었습니다."); + case RotateResult.NotFound: + throw new UnauthorizedException("리프레시 토큰을 찾을 수 없습니다."); + } var accessTokenExpiresAt = DateTimeOffset.UtcNow .AddMinutes(_accessTokenExpirationMinutes) From 10f6829f4a30e3a5046328bd470c53bb3583ede0 Mon Sep 17 00:00:00 2001 From: Sean-mn Date: Mon, 6 Apr 2026 15:11:35 +0900 Subject: [PATCH 06/10] =?UTF-8?q?update:=20RefreshTokenService=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EB=A5=BC=20RotateResult=20=EA=B8=B0=EB=B0=98?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #7 --- .../Auth/Service/RefreshTokenServiceTests.cs | 45 ++++++++++++++++--- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/Fantasy-server/Fantasy.Test/Auth/Service/RefreshTokenServiceTests.cs b/Fantasy-server/Fantasy.Test/Auth/Service/RefreshTokenServiceTests.cs index b557fe9..e8ccd69 100644 --- a/Fantasy-server/Fantasy.Test/Auth/Service/RefreshTokenServiceTests.cs +++ b/Fantasy-server/Fantasy.Test/Auth/Service/RefreshTokenServiceTests.cs @@ -1,5 +1,6 @@ using Fantasy.Server.Domain.Account.Repository.Interface; using Fantasy.Server.Domain.Auth.Dto.Request; +using Fantasy.Server.Domain.Auth.Enum; using Fantasy.Server.Domain.Auth.Repository.Interface; using Fantasy.Server.Domain.Auth.Service; using Fantasy.Server.Global.Security.Jwt; @@ -36,7 +37,7 @@ public 유효한_리프레시_토큰으로_요청할_때() _accountRepository.FindByIdAsync(AccountId).Returns(_account); _refreshTokenRepository .RotateAsync(_account.Id, RefreshToken, Arg.Any(), TimeSpan.FromDays(30)) - .Returns(true); + .Returns(RotateResult.Success); _jwtProvider.GenerateAccessToken(_account).Returns("new-access-token"); _jwtProvider.GenerateRefreshToken().Returns("new-refresh-token"); _configuration["Jwt:AccessTokenExpirationMinutes"].Returns("15"); @@ -114,7 +115,7 @@ public async Task 토큰_갱신_요청_시_UnauthorizedException이_발생한다 } } - public class RotateAsync가_실패할_때 + public class RotateAsync가_NotFound를_반환할_때 { private readonly IAccountRepository _accountRepository = Substitute.For(); private readonly IRefreshTokenRedisRepository _refreshTokenRepository = Substitute.For(); @@ -124,13 +125,13 @@ public class RotateAsync가_실패할_때 private readonly AccountEntity _account = CreateAccount(); private readonly RefreshTokenRequest _request = new(RefreshToken); - public RotateAsync가_실패할_때() + public RotateAsync가_NotFound를_반환할_때() { _refreshTokenRepository.FindIdByTokenAsync(RefreshToken).Returns(AccountId); _accountRepository.FindByIdAsync(AccountId).Returns(_account); _refreshTokenRepository .RotateAsync(_account.Id, RefreshToken, Arg.Any(), TimeSpan.FromDays(30)) - .Returns(false); + .Returns(RotateResult.NotFound); _jwtProvider.GenerateAccessToken(_account).Returns("new-access-token"); _jwtProvider.GenerateRefreshToken().Returns("new-refresh-token"); _configuration["Jwt:AccessTokenExpirationMinutes"].Returns("15"); @@ -142,7 +143,41 @@ public async Task 토큰_갱신_요청_시_UnauthorizedException이_발생한다 { var act = async () => await _sut.ExecuteAsync(_request); - await act.Should().ThrowAsync(); + await act.Should().ThrowAsync() + .WithMessage("리프레시 토큰을 찾을 수 없습니다."); + } + } + + public class RotateAsync가_Reused를_반환할_때 + { + private readonly IAccountRepository _accountRepository = Substitute.For(); + private readonly IRefreshTokenRedisRepository _refreshTokenRepository = Substitute.For(); + private readonly IJwtProvider _jwtProvider = Substitute.For(); + private readonly IConfiguration _configuration = Substitute.For(); + private readonly RefreshTokenService _sut; + private readonly AccountEntity _account = CreateAccount(); + private readonly RefreshTokenRequest _request = new(RefreshToken); + + public RotateAsync가_Reused를_반환할_때() + { + _refreshTokenRepository.FindIdByTokenAsync(RefreshToken).Returns(AccountId); + _accountRepository.FindByIdAsync(AccountId).Returns(_account); + _refreshTokenRepository + .RotateAsync(_account.Id, RefreshToken, Arg.Any(), TimeSpan.FromDays(30)) + .Returns(RotateResult.Reused); + _jwtProvider.GenerateAccessToken(_account).Returns("new-access-token"); + _jwtProvider.GenerateRefreshToken().Returns("new-refresh-token"); + _configuration["Jwt:AccessTokenExpirationMinutes"].Returns("15"); + _sut = new RefreshTokenService(_accountRepository, _refreshTokenRepository, _jwtProvider, _configuration); + } + + [Fact] + public async Task 토큰_갱신_요청_시_UnauthorizedException이_발생한다() + { + var act = async () => await _sut.ExecuteAsync(_request); + + await act.Should().ThrowAsync() + .WithMessage("토큰 재사용이 감지되었습니다."); } } } From 7fa6f9997f76c2c1364be1a6e95928cfe42f5dc0 Mon Sep 17 00:00:00 2001 From: Sean-mn Date: Mon, 6 Apr 2026 15:16:50 +0900 Subject: [PATCH 07/10] =?UTF-8?q?chore:=20pr=20=EC=8A=A4=ED=82=AC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Fantasy-server/.claude/skills/pr/SKILL.md | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/Fantasy-server/.claude/skills/pr/SKILL.md b/Fantasy-server/.claude/skills/pr/SKILL.md index 61f8040..ac40341 100644 --- a/Fantasy-server/.claude/skills/pr/SKILL.md +++ b/Fantasy-server/.claude/skills/pr/SKILL.md @@ -112,20 +112,7 @@ rm PR_BODY.md **Step 3. Write PR body** following the PR Body Template below - Save to `PR_BODY.md` -**Step 4. Output** in this format: -``` -## 추천 PR 제목 - -1. [title1] -2. [title2] -3. [title3] - -## PR 본문 (PR_BODY.md에 저장됨) - -[full body preview] -``` - -**Step 5. Ask the user** using AskUserQuestion with a `choices` array: +**Step 4. Ask the user** using AskUserQuestion with a `choices` array: - Options: the 3 generated titles + "직접 입력" as the last option - If the user selects "직접 입력", ask a follow-up AskUserQuestion for the custom title From eca5bd47dc2a5c53816270478e355f0e25e4a9c7 Mon Sep 17 00:00:00 2001 From: Sean-mn Date: Mon, 6 Apr 2026 15:49:39 +0900 Subject: [PATCH 08/10] =?UTF-8?q?update:=20=EB=A6=AC=ED=94=84=EB=A0=88?= =?UTF-8?q?=EC=8B=9C=20=ED=86=A0=ED=81=B0=EC=97=90=20accountId=20=EC=9D=B8?= =?UTF-8?q?=EC=BD=94=EB=94=A9=20=EB=B0=8F=20=EC=97=AD=EB=B0=A9=ED=96=A5=20?= =?UTF-8?q?Redis=20=ED=82=A4=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #7 --- .../Interface/IRefreshTokenRedisRepository.cs | 2 - .../Repository/RefreshTokenRedisRepository.cs | 61 ++----------------- .../Domain/Auth/Service/LoginService.cs | 2 +- .../Auth/Service/RefreshTokenService.cs | 17 +++++- .../Global/Security/Jwt/IJwtProvider.cs | 2 +- .../Global/Security/Jwt/JwtProvider.cs | 4 +- 6 files changed, 23 insertions(+), 65 deletions(-) diff --git a/Fantasy-server/Fantasy.Server/Domain/Auth/Repository/Interface/IRefreshTokenRedisRepository.cs b/Fantasy-server/Fantasy.Server/Domain/Auth/Repository/Interface/IRefreshTokenRedisRepository.cs index 1528898..db59cee 100644 --- a/Fantasy-server/Fantasy.Server/Domain/Auth/Repository/Interface/IRefreshTokenRedisRepository.cs +++ b/Fantasy-server/Fantasy.Server/Domain/Auth/Repository/Interface/IRefreshTokenRedisRepository.cs @@ -6,7 +6,5 @@ public interface IRefreshTokenRedisRepository { Task SaveAsync(long id, string token, TimeSpan ttl); Task RotateAsync(long id, string expectedOldToken, string newToken, TimeSpan ttl); - Task FindByIdAsync(long id); - Task FindIdByTokenAsync(string token); Task DeleteAsync(long id); } diff --git a/Fantasy-server/Fantasy.Server/Domain/Auth/Repository/RefreshTokenRedisRepository.cs b/Fantasy-server/Fantasy.Server/Domain/Auth/Repository/RefreshTokenRedisRepository.cs index aebae7d..e087ad3 100644 --- a/Fantasy-server/Fantasy.Server/Domain/Auth/Repository/RefreshTokenRedisRepository.cs +++ b/Fantasy-server/Fantasy.Server/Domain/Auth/Repository/RefreshTokenRedisRepository.cs @@ -7,15 +7,9 @@ namespace Fantasy.Server.Domain.Auth.Repository; public class RefreshTokenRedisRepository : IRefreshTokenRedisRepository { private const string Prefix = "fantasy:"; - private static readonly string ReverseKeyPrefix = $"{Prefix}refresh:token:"; private static readonly LuaScript SaveScript = LuaScript.Prepare(@" - local old = redis.call('GET', @forwardKey) - if old then - redis.call('DEL', @reverseKeyPrefix .. old) - end redis.call('SET', @forwardKey, @token, 'EX', @ttl) - redis.call('SET', @reverseKey, @id, 'EX', @ttl) return 1 "); @@ -26,25 +20,9 @@ public class RefreshTokenRedisRepository : IRefreshTokenRedisRepository end if current ~= @expectedOldToken then redis.call('DEL', @forwardKey) - redis.call('DEL', @reverseKeyPrefix .. current) return -1 end - local reverseId = redis.call('GET', @oldReverseKey) - if not reverseId or reverseId ~= @id then - return 0 - end - redis.call('DEL', @oldReverseKey) - redis.call('SET', @forwardKey, @newToken, 'EX', @ttl) - redis.call('SET', @newReverseKey, @id, 'EX', @ttl) - return 1 - "); - - private static readonly LuaScript DeleteScript = LuaScript.Prepare(@" - local token = redis.call('GET', @forwardKey) - if token then - redis.call('DEL', @reverseKeyPrefix .. token) - end - redis.call('DEL', @forwardKey) + redis.call('SET', @forwardKey, @newToken, 'EX', @ttl) return 1 "); @@ -56,18 +34,14 @@ public RefreshTokenRedisRepository(IConnectionMultiplexer multiplexer) } private static string ForwardKey(long id) => $"{Prefix}refresh:{id}"; - private static string ReverseKey(string token) => $"{ReverseKeyPrefix}{token}"; public async Task SaveAsync(long id, string token, TimeSpan ttl) { await _db.ScriptEvaluateAsync(SaveScript, new { - forwardKey = (RedisKey)ForwardKey(id), - reverseKey = (RedisKey)ReverseKey(token), - reverseKeyPrefix = (RedisValue)ReverseKeyPrefix, - token = (RedisValue)token, - id = (RedisValue)id.ToString(), - ttl = (RedisValue)(long)ttl.TotalSeconds + forwardKey = (RedisKey)ForwardKey(id), + token = (RedisValue)token, + ttl = (RedisValue)(long)ttl.TotalSeconds }); } @@ -76,39 +50,14 @@ public async Task RotateAsync(long id, string expectedOldToken, st var result = await _db.ScriptEvaluateAsync(RotateScript, new { forwardKey = (RedisKey)ForwardKey(id), - oldReverseKey = (RedisKey)ReverseKey(expectedOldToken), - newReverseKey = (RedisKey)ReverseKey(newToken), - reverseKeyPrefix = (RedisValue)ReverseKeyPrefix, expectedOldToken = (RedisValue)expectedOldToken, newToken = (RedisValue)newToken, - id = (RedisValue)id.ToString(), ttl = (RedisValue)(long)ttl.TotalSeconds }); return (RotateResult)(int)(long)result; } - public async Task FindByIdAsync(long id) - { - var value = await _db.StringGetAsync(ForwardKey(id)); - return value.HasValue ? value.ToString() : null; - } - - public async Task FindIdByTokenAsync(string token) - { - var value = await _db.StringGetAsync(ReverseKey(token)); - if (!value.HasValue) return null; - if (!long.TryParse(value.ToString(), out var id)) - throw new InvalidOperationException($"Redis 역방향 키에 저장된 값이 올바른 계정 ID 형식이 아닙니다: token={token}"); - return id; - } - public async Task DeleteAsync(long id) - { - await _db.ScriptEvaluateAsync(DeleteScript, new - { - forwardKey = (RedisKey)ForwardKey(id), - reverseKeyPrefix = (RedisValue)ReverseKeyPrefix - }); - } + => await _db.KeyDeleteAsync(ForwardKey(id)); } diff --git a/Fantasy-server/Fantasy.Server/Domain/Auth/Service/LoginService.cs b/Fantasy-server/Fantasy.Server/Domain/Auth/Service/LoginService.cs index 887af23..f3e09c4 100644 --- a/Fantasy-server/Fantasy.Server/Domain/Auth/Service/LoginService.cs +++ b/Fantasy-server/Fantasy.Server/Domain/Auth/Service/LoginService.cs @@ -39,7 +39,7 @@ public async Task ExecuteAsync(LoginRequest request) throw new UnauthorizedException("이메일 또는 비밀번호가 올바르지 않습니다."); var accessToken = _jwtProvider.GenerateAccessToken(account); - var refreshToken = _jwtProvider.GenerateRefreshToken(); + var refreshToken = _jwtProvider.GenerateRefreshToken(account.Id); await _refreshTokenRepository.SaveAsync(account.Id, refreshToken, RefreshTokenTtl); diff --git a/Fantasy-server/Fantasy.Server/Domain/Auth/Service/RefreshTokenService.cs b/Fantasy-server/Fantasy.Server/Domain/Auth/Service/RefreshTokenService.cs index 8f9c1bf..ac7ed1a 100644 --- a/Fantasy-server/Fantasy.Server/Domain/Auth/Service/RefreshTokenService.cs +++ b/Fantasy-server/Fantasy.Server/Domain/Auth/Service/RefreshTokenService.cs @@ -33,14 +33,13 @@ public RefreshTokenService( public async Task ExecuteAsync(RefreshTokenRequest request) { - var accountId = await _refreshTokenRepository.FindIdByTokenAsync(request.RefreshToken) - ?? throw new UnauthorizedException("리프레시 토큰을 찾을 수 없습니다."); + var accountId = ParseAccountId(request.RefreshToken); var account = await _accountRepository.FindByIdAsync(accountId) ?? throw new UnauthorizedException("인증 정보를 찾을 수 없습니다."); var accessToken = _jwtProvider.GenerateAccessToken(account); - var newRefreshToken = _jwtProvider.GenerateRefreshToken(); + var newRefreshToken = _jwtProvider.GenerateRefreshToken(account.Id); var rotateResult = await _refreshTokenRepository.RotateAsync( account.Id, request.RefreshToken, newRefreshToken, RefreshTokenTtl); @@ -59,4 +58,16 @@ public async Task ExecuteAsync(RefreshTokenRequest request) return new TokenResponse(accessToken, newRefreshToken, accessTokenExpiresAt); } + + private static long ParseAccountId(string token) + { + var separatorIndex = token.IndexOf(':'); + if (separatorIndex <= 0) + throw new UnauthorizedException("유효하지 않은 리프레시 토큰입니다."); + + if (!long.TryParse(token[..separatorIndex], out var accountId)) + throw new UnauthorizedException("유효하지 않은 리프레시 토큰입니다."); + + return accountId; + } } diff --git a/Fantasy-server/Fantasy.Server/Global/Security/Jwt/IJwtProvider.cs b/Fantasy-server/Fantasy.Server/Global/Security/Jwt/IJwtProvider.cs index 6f17467..25ee2d0 100644 --- a/Fantasy-server/Fantasy.Server/Global/Security/Jwt/IJwtProvider.cs +++ b/Fantasy-server/Fantasy.Server/Global/Security/Jwt/IJwtProvider.cs @@ -5,5 +5,5 @@ namespace Fantasy.Server.Global.Security.Jwt; public interface IJwtProvider { string GenerateAccessToken(Account account); - string GenerateRefreshToken(); + string GenerateRefreshToken(long accountId); } diff --git a/Fantasy-server/Fantasy.Server/Global/Security/Jwt/JwtProvider.cs b/Fantasy-server/Fantasy.Server/Global/Security/Jwt/JwtProvider.cs index 4ee5606..0c361d7 100644 --- a/Fantasy-server/Fantasy.Server/Global/Security/Jwt/JwtProvider.cs +++ b/Fantasy-server/Fantasy.Server/Global/Security/Jwt/JwtProvider.cs @@ -54,9 +54,9 @@ public string GenerateAccessToken(Account account) return new JwtSecurityTokenHandler().WriteToken(token); } - public string GenerateRefreshToken() + public string GenerateRefreshToken(long accountId) { var bytes = RandomNumberGenerator.GetBytes(64); - return Convert.ToBase64String(bytes); + return $"{accountId}:{Convert.ToBase64String(bytes)}"; } } From 9c71dc3835b23be4d517131268a226f58edbd9e7 Mon Sep 17 00:00:00 2001 From: Sean-mn Date: Mon, 6 Apr 2026 15:49:58 +0900 Subject: [PATCH 09/10] =?UTF-8?q?update:=20=EB=A6=AC=ED=94=84=EB=A0=88?= =?UTF-8?q?=EC=8B=9C=20=ED=86=A0=ED=81=B0=20accountId=20=EC=9D=B8=EC=BD=94?= =?UTF-8?q?=EB=94=A9=20=EB=B0=A9=EC=8B=9D=EC=97=90=20=EB=A7=9E=EC=B6=B0=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #7 --- .../Auth/Service/LoginServiceTests.cs | 4 +- .../Auth/Service/RefreshTokenServiceTests.cs | 66 ++++++++++--------- 2 files changed, 36 insertions(+), 34 deletions(-) diff --git a/Fantasy-server/Fantasy.Test/Auth/Service/LoginServiceTests.cs b/Fantasy-server/Fantasy.Test/Auth/Service/LoginServiceTests.cs index 8fa3b7f..411560e 100644 --- a/Fantasy-server/Fantasy.Test/Auth/Service/LoginServiceTests.cs +++ b/Fantasy-server/Fantasy.Test/Auth/Service/LoginServiceTests.cs @@ -28,7 +28,7 @@ public 유효한_자격증명일_때() var account = AccountEntity.Create(_request.Email, BCrypt.Net.BCrypt.HashPassword(_request.Password)); _accountRepository.FindByEmailAsync(_request.Email).Returns(account); _jwtProvider.GenerateAccessToken(account).Returns("access-token"); - _jwtProvider.GenerateRefreshToken().Returns("refresh-token"); + _jwtProvider.GenerateRefreshToken(Arg.Any()).Returns("refresh-token"); _configuration["Jwt:AccessTokenExpirationMinutes"].Returns("15"); _sut = new LoginService(_accountRepository, _refreshTokenRepository, _jwtProvider, _configuration); } @@ -49,7 +49,7 @@ public async Task 로그인_요청_시_리프레시_토큰이_Redis에_저장된 await _sut.ExecuteAsync(_request); await _refreshTokenRepository.Received(1) - .SaveAsync(Arg.Any(), "refresh-token", TimeSpan.FromDays(30)); + .SaveAsync(Arg.Any(), Arg.Any(), TimeSpan.FromDays(30)); } } diff --git a/Fantasy-server/Fantasy.Test/Auth/Service/RefreshTokenServiceTests.cs b/Fantasy-server/Fantasy.Test/Auth/Service/RefreshTokenServiceTests.cs index e8ccd69..9e74b49 100644 --- a/Fantasy-server/Fantasy.Test/Auth/Service/RefreshTokenServiceTests.cs +++ b/Fantasy-server/Fantasy.Test/Auth/Service/RefreshTokenServiceTests.cs @@ -15,8 +15,8 @@ namespace Fantasy.Test.Auth.Service; public class RefreshTokenServiceTests { - private const string RefreshToken = "valid-refresh-token"; - private const long AccountId = 1L; + // AccountEntity.Create(...)로 생성된 엔티티의 Id는 DB 할당 전이므로 기본값 0 + private const string ValidRefreshToken = "0:valid-refresh-token"; private static AccountEntity CreateAccount() => AccountEntity.Create("user@example.com", "hashed_password"); @@ -29,17 +29,16 @@ public class 유효한_리프레시_토큰으로_요청할_때 private readonly IConfiguration _configuration = Substitute.For(); private readonly RefreshTokenService _sut; private readonly AccountEntity _account = CreateAccount(); - private readonly RefreshTokenRequest _request = new(RefreshToken); + private readonly RefreshTokenRequest _request = new(ValidRefreshToken); public 유효한_리프레시_토큰으로_요청할_때() { - _refreshTokenRepository.FindIdByTokenAsync(RefreshToken).Returns(AccountId); - _accountRepository.FindByIdAsync(AccountId).Returns(_account); + _accountRepository.FindByIdAsync(_account.Id).Returns(_account); _refreshTokenRepository - .RotateAsync(_account.Id, RefreshToken, Arg.Any(), TimeSpan.FromDays(30)) + .RotateAsync(_account.Id, ValidRefreshToken, Arg.Any(), TimeSpan.FromDays(30)) .Returns(RotateResult.Success); _jwtProvider.GenerateAccessToken(_account).Returns("new-access-token"); - _jwtProvider.GenerateRefreshToken().Returns("new-refresh-token"); + _jwtProvider.GenerateRefreshToken(Arg.Any()).Returns("new-refresh-token"); _configuration["Jwt:AccessTokenExpirationMinutes"].Returns("15"); _sut = new RefreshTokenService(_accountRepository, _refreshTokenRepository, _jwtProvider, _configuration); } @@ -60,32 +59,40 @@ public async Task 토큰_갱신_요청_시_RotateAsync가_호출된다() await _sut.ExecuteAsync(_request); await _refreshTokenRepository.Received(1) - .RotateAsync(_account.Id, RefreshToken, "new-refresh-token", TimeSpan.FromDays(30)); + .RotateAsync(_account.Id, ValidRefreshToken, "new-refresh-token", TimeSpan.FromDays(30)); } } - public class Redis에_리프레시_토큰이_없을_때 + public class 유효하지_않은_토큰_형식일_때 { private readonly IAccountRepository _accountRepository = Substitute.For(); private readonly IRefreshTokenRedisRepository _refreshTokenRepository = Substitute.For(); private readonly IJwtProvider _jwtProvider = Substitute.For(); private readonly IConfiguration _configuration = Substitute.For(); private readonly RefreshTokenService _sut; - private readonly RefreshTokenRequest _request = new("unknown-token"); - public Redis에_리프레시_토큰이_없을_때() + public 유효하지_않은_토큰_형식일_때() { - _refreshTokenRepository.FindIdByTokenAsync("unknown-token").Returns((long?)null); _configuration["Jwt:AccessTokenExpirationMinutes"].Returns("15"); _sut = new RefreshTokenService(_accountRepository, _refreshTokenRepository, _jwtProvider, _configuration); } [Fact] - public async Task 토큰_갱신_요청_시_UnauthorizedException이_발생한다() + public async Task 콜론이_없는_토큰으로_요청_시_UnauthorizedException이_발생한다() { - var act = async () => await _sut.ExecuteAsync(_request); + var act = async () => await _sut.ExecuteAsync(new RefreshTokenRequest("invalidtoken")); - await act.Should().ThrowAsync(); + await act.Should().ThrowAsync() + .WithMessage("유효하지 않은 리프레시 토큰입니다."); + } + + [Fact] + public async Task accountId가_숫자가_아닌_토큰으로_요청_시_UnauthorizedException이_발생한다() + { + var act = async () => await _sut.ExecuteAsync(new RefreshTokenRequest("abc:sometoken")); + + await act.Should().ThrowAsync() + .WithMessage("유효하지 않은 리프레시 토큰입니다."); } } @@ -96,12 +103,10 @@ public class 계정이_존재하지_않을_때 private readonly IJwtProvider _jwtProvider = Substitute.For(); private readonly IConfiguration _configuration = Substitute.For(); private readonly RefreshTokenService _sut; - private readonly RefreshTokenRequest _request = new(RefreshToken); public 계정이_존재하지_않을_때() { - _refreshTokenRepository.FindIdByTokenAsync(RefreshToken).Returns(AccountId); - _accountRepository.FindByIdAsync(AccountId).Returns((AccountEntity?)null); + _accountRepository.FindByIdAsync(Arg.Any()).Returns((AccountEntity?)null); _configuration["Jwt:AccessTokenExpirationMinutes"].Returns("15"); _sut = new RefreshTokenService(_accountRepository, _refreshTokenRepository, _jwtProvider, _configuration); } @@ -109,9 +114,10 @@ public 계정이_존재하지_않을_때() [Fact] public async Task 토큰_갱신_요청_시_UnauthorizedException이_발생한다() { - var act = async () => await _sut.ExecuteAsync(_request); + var act = async () => await _sut.ExecuteAsync(new RefreshTokenRequest(ValidRefreshToken)); - await act.Should().ThrowAsync(); + await act.Should().ThrowAsync() + .WithMessage("인증 정보를 찾을 수 없습니다."); } } @@ -123,17 +129,15 @@ public class RotateAsync가_NotFound를_반환할_때 private readonly IConfiguration _configuration = Substitute.For(); private readonly RefreshTokenService _sut; private readonly AccountEntity _account = CreateAccount(); - private readonly RefreshTokenRequest _request = new(RefreshToken); public RotateAsync가_NotFound를_반환할_때() { - _refreshTokenRepository.FindIdByTokenAsync(RefreshToken).Returns(AccountId); - _accountRepository.FindByIdAsync(AccountId).Returns(_account); + _accountRepository.FindByIdAsync(_account.Id).Returns(_account); _refreshTokenRepository - .RotateAsync(_account.Id, RefreshToken, Arg.Any(), TimeSpan.FromDays(30)) + .RotateAsync(_account.Id, ValidRefreshToken, Arg.Any(), TimeSpan.FromDays(30)) .Returns(RotateResult.NotFound); _jwtProvider.GenerateAccessToken(_account).Returns("new-access-token"); - _jwtProvider.GenerateRefreshToken().Returns("new-refresh-token"); + _jwtProvider.GenerateRefreshToken(Arg.Any()).Returns("new-refresh-token"); _configuration["Jwt:AccessTokenExpirationMinutes"].Returns("15"); _sut = new RefreshTokenService(_accountRepository, _refreshTokenRepository, _jwtProvider, _configuration); } @@ -141,7 +145,7 @@ public RotateAsync가_NotFound를_반환할_때() [Fact] public async Task 토큰_갱신_요청_시_UnauthorizedException이_발생한다() { - var act = async () => await _sut.ExecuteAsync(_request); + var act = async () => await _sut.ExecuteAsync(new RefreshTokenRequest(ValidRefreshToken)); await act.Should().ThrowAsync() .WithMessage("리프레시 토큰을 찾을 수 없습니다."); @@ -156,17 +160,15 @@ public class RotateAsync가_Reused를_반환할_때 private readonly IConfiguration _configuration = Substitute.For(); private readonly RefreshTokenService _sut; private readonly AccountEntity _account = CreateAccount(); - private readonly RefreshTokenRequest _request = new(RefreshToken); public RotateAsync가_Reused를_반환할_때() { - _refreshTokenRepository.FindIdByTokenAsync(RefreshToken).Returns(AccountId); - _accountRepository.FindByIdAsync(AccountId).Returns(_account); + _accountRepository.FindByIdAsync(_account.Id).Returns(_account); _refreshTokenRepository - .RotateAsync(_account.Id, RefreshToken, Arg.Any(), TimeSpan.FromDays(30)) + .RotateAsync(_account.Id, ValidRefreshToken, Arg.Any(), TimeSpan.FromDays(30)) .Returns(RotateResult.Reused); _jwtProvider.GenerateAccessToken(_account).Returns("new-access-token"); - _jwtProvider.GenerateRefreshToken().Returns("new-refresh-token"); + _jwtProvider.GenerateRefreshToken(Arg.Any()).Returns("new-refresh-token"); _configuration["Jwt:AccessTokenExpirationMinutes"].Returns("15"); _sut = new RefreshTokenService(_accountRepository, _refreshTokenRepository, _jwtProvider, _configuration); } @@ -174,7 +176,7 @@ public RotateAsync가_Reused를_반환할_때() [Fact] public async Task 토큰_갱신_요청_시_UnauthorizedException이_발생한다() { - var act = async () => await _sut.ExecuteAsync(_request); + var act = async () => await _sut.ExecuteAsync(new RefreshTokenRequest(ValidRefreshToken)); await act.Should().ThrowAsync() .WithMessage("토큰 재사용이 감지되었습니다."); From 89eb282ff82d15f2c49371df488342fb7bc6c4a4 Mon Sep 17 00:00:00 2001 From: Sean-mn Date: Mon, 6 Apr 2026 15:57:30 +0900 Subject: [PATCH 10/10] =?UTF-8?q?update:=20Redis=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=ED=9B=84=20DB=20=EC=A1=B0=ED=9A=8C=ED=95=98=EB=8F=84=EB=A1=9D?= =?UTF-8?q?=20RefreshTokenService=20=EC=8B=A4=ED=96=89=20=EC=88=9C?= =?UTF-8?q?=EC=84=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #7 --- .../Domain/Auth/Service/RefreshTokenService.cs | 14 +++++++------- .../Auth/Service/RefreshTokenServiceTests.cs | 15 +++++---------- 2 files changed, 12 insertions(+), 17 deletions(-) diff --git a/Fantasy-server/Fantasy.Server/Domain/Auth/Service/RefreshTokenService.cs b/Fantasy-server/Fantasy.Server/Domain/Auth/Service/RefreshTokenService.cs index ac7ed1a..db9cae4 100644 --- a/Fantasy-server/Fantasy.Server/Domain/Auth/Service/RefreshTokenService.cs +++ b/Fantasy-server/Fantasy.Server/Domain/Auth/Service/RefreshTokenService.cs @@ -34,15 +34,10 @@ public RefreshTokenService( public async Task ExecuteAsync(RefreshTokenRequest request) { var accountId = ParseAccountId(request.RefreshToken); - - var account = await _accountRepository.FindByIdAsync(accountId) - ?? throw new UnauthorizedException("인증 정보를 찾을 수 없습니다."); - - var accessToken = _jwtProvider.GenerateAccessToken(account); - var newRefreshToken = _jwtProvider.GenerateRefreshToken(account.Id); + var newRefreshToken = _jwtProvider.GenerateRefreshToken(accountId); var rotateResult = await _refreshTokenRepository.RotateAsync( - account.Id, request.RefreshToken, newRefreshToken, RefreshTokenTtl); + accountId, request.RefreshToken, newRefreshToken, RefreshTokenTtl); switch (rotateResult) { @@ -52,6 +47,11 @@ public async Task ExecuteAsync(RefreshTokenRequest request) throw new UnauthorizedException("리프레시 토큰을 찾을 수 없습니다."); } + var account = await _accountRepository.FindByIdAsync(accountId) + ?? throw new UnauthorizedException("인증 정보를 찾을 수 없습니다."); + + var accessToken = _jwtProvider.GenerateAccessToken(account); + var accessTokenExpiresAt = DateTimeOffset.UtcNow .AddMinutes(_accessTokenExpirationMinutes) .ToUnixTimeSeconds(); diff --git a/Fantasy-server/Fantasy.Test/Auth/Service/RefreshTokenServiceTests.cs b/Fantasy-server/Fantasy.Test/Auth/Service/RefreshTokenServiceTests.cs index 9e74b49..f2887b3 100644 --- a/Fantasy-server/Fantasy.Test/Auth/Service/RefreshTokenServiceTests.cs +++ b/Fantasy-server/Fantasy.Test/Auth/Service/RefreshTokenServiceTests.cs @@ -106,6 +106,9 @@ public class 계정이_존재하지_않을_때 public 계정이_존재하지_않을_때() { + _refreshTokenRepository + .RotateAsync(Arg.Any(), Arg.Any(), Arg.Any(), TimeSpan.FromDays(30)) + .Returns(RotateResult.Success); _accountRepository.FindByIdAsync(Arg.Any()).Returns((AccountEntity?)null); _configuration["Jwt:AccessTokenExpirationMinutes"].Returns("15"); _sut = new RefreshTokenService(_accountRepository, _refreshTokenRepository, _jwtProvider, _configuration); @@ -128,16 +131,12 @@ public class RotateAsync가_NotFound를_반환할_때 private readonly IJwtProvider _jwtProvider = Substitute.For(); private readonly IConfiguration _configuration = Substitute.For(); private readonly RefreshTokenService _sut; - private readonly AccountEntity _account = CreateAccount(); public RotateAsync가_NotFound를_반환할_때() { - _accountRepository.FindByIdAsync(_account.Id).Returns(_account); _refreshTokenRepository - .RotateAsync(_account.Id, ValidRefreshToken, Arg.Any(), TimeSpan.FromDays(30)) + .RotateAsync(Arg.Any(), ValidRefreshToken, Arg.Any(), TimeSpan.FromDays(30)) .Returns(RotateResult.NotFound); - _jwtProvider.GenerateAccessToken(_account).Returns("new-access-token"); - _jwtProvider.GenerateRefreshToken(Arg.Any()).Returns("new-refresh-token"); _configuration["Jwt:AccessTokenExpirationMinutes"].Returns("15"); _sut = new RefreshTokenService(_accountRepository, _refreshTokenRepository, _jwtProvider, _configuration); } @@ -159,16 +158,12 @@ public class RotateAsync가_Reused를_반환할_때 private readonly IJwtProvider _jwtProvider = Substitute.For(); private readonly IConfiguration _configuration = Substitute.For(); private readonly RefreshTokenService _sut; - private readonly AccountEntity _account = CreateAccount(); public RotateAsync가_Reused를_반환할_때() { - _accountRepository.FindByIdAsync(_account.Id).Returns(_account); _refreshTokenRepository - .RotateAsync(_account.Id, ValidRefreshToken, Arg.Any(), TimeSpan.FromDays(30)) + .RotateAsync(Arg.Any(), ValidRefreshToken, Arg.Any(), TimeSpan.FromDays(30)) .Returns(RotateResult.Reused); - _jwtProvider.GenerateAccessToken(_account).Returns("new-access-token"); - _jwtProvider.GenerateRefreshToken(Arg.Any()).Returns("new-refresh-token"); _configuration["Jwt:AccessTokenExpirationMinutes"].Returns("15"); _sut = new RefreshTokenService(_accountRepository, _refreshTokenRepository, _jwtProvider, _configuration); }