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 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/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 967247e..db59cee 100644 --- a/Fantasy-server/Fantasy.Server/Domain/Auth/Repository/Interface/IRefreshTokenRedisRepository.cs +++ b/Fantasy-server/Fantasy.Server/Domain/Auth/Repository/Interface/IRefreshTokenRedisRepository.cs @@ -1,8 +1,10 @@ +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 FindByIdAsync(long id); + Task RotateAsync(long id, string expectedOldToken, string newToken, TimeSpan ttl); 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..e087ad3 100644 --- a/Fantasy-server/Fantasy.Server/Domain/Auth/Repository/RefreshTokenRedisRepository.cs +++ b/Fantasy-server/Fantasy.Server/Domain/Auth/Repository/RefreshTokenRedisRepository.cs @@ -1,29 +1,63 @@ +using Fantasy.Server.Domain.Auth.Enum; 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:"; - public RefreshTokenRedisRepository(IDistributedCache cache) + private static readonly LuaScript SaveScript = LuaScript.Prepare(@" + redis.call('SET', @forwardKey, @token, 'EX', @ttl) + return 1 + "); + + private static readonly LuaScript RotateScript = LuaScript.Prepare(@" + local current = redis.call('GET', @forwardKey) + if not current then + return 0 + end + if current ~= @expectedOldToken then + redis.call('DEL', @forwardKey) + return -1 + end + redis.call('SET', @forwardKey, @newToken, 'EX', @ttl) + return 1 + "); + + private readonly IDatabase _db; + + public RefreshTokenRedisRepository(IConnectionMultiplexer multiplexer) { - _cache = cache; + _db = multiplexer.GetDatabase(); } + private static string ForwardKey(long id) => $"{Prefix}refresh:{id}"; + public async Task SaveAsync(long id, string token, TimeSpan ttl) { - var options = new DistributedCacheEntryOptions + await _db.ScriptEvaluateAsync(SaveScript, new { - AbsoluteExpirationRelativeToNow = ttl - }; - await _cache.SetStringAsync($"refresh:{id}", token, options); + forwardKey = (RedisKey)ForwardKey(id), + token = (RedisValue)token, + ttl = (RedisValue)(long)ttl.TotalSeconds + }); } - public async Task FindByIdAsync(long id) - => await _cache.GetStringAsync($"refresh:{id}"); + public async Task RotateAsync(long id, string expectedOldToken, string newToken, TimeSpan ttl) + { + var result = await _db.ScriptEvaluateAsync(RotateScript, new + { + forwardKey = (RedisKey)ForwardKey(id), + expectedOldToken = (RedisValue)expectedOldToken, + newToken = (RedisValue)newToken, + ttl = (RedisValue)(long)ttl.TotalSeconds + }); + + return (RotateResult)(int)(long)result; + } public async Task DeleteAsync(long id) - => await _cache.RemoveAsync($"refresh:{id}"); + => 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 c5ee0fd..db9cae4 100644 --- a/Fantasy-server/Fantasy.Server/Domain/Auth/Service/RefreshTokenService.cs +++ b/Fantasy-server/Fantasy.Server/Domain/Auth/Service/RefreshTokenService.cs @@ -1,9 +1,10 @@ +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; -using Fantasy.Server.Global.Security.Provider; using Gamism.SDK.Extensions.AspNetCore.Exceptions; namespace Fantasy.Server.Domain.Auth.Service; @@ -12,18 +13,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 +33,41 @@ public RefreshTokenService( public async Task ExecuteAsync(RefreshTokenRequest request) { - var account = await _currentUserProvider.GetAccountAsync(); + var accountId = ParseAccountId(request.RefreshToken); + var newRefreshToken = _jwtProvider.GenerateRefreshToken(accountId); - var stored = await _refreshTokenRepository.FindByIdAsync(account.Id) - ?? throw new UnauthorizedException("리프레시 토큰을 찾을 수 없습니다."); + var rotateResult = await _refreshTokenRepository.RotateAsync( + accountId, request.RefreshToken, newRefreshToken, RefreshTokenTtl); - if (stored != request.RefreshToken) - throw new UnauthorizedException("리프레시 토큰이 올바르지 않습니다."); + switch (rotateResult) + { + case RotateResult.Reused: + throw new UnauthorizedException("토큰 재사용이 감지되었습니다."); + case RotateResult.NotFound: + throw new UnauthorizedException("리프레시 토큰을 찾을 수 없습니다."); + } - var accessToken = _jwtProvider.GenerateAccessToken(account); - var refreshToken = _jwtProvider.GenerateRefreshToken(); + var account = await _accountRepository.FindByIdAsync(accountId) + ?? throw new UnauthorizedException("인증 정보를 찾을 수 없습니다."); - await _refreshTokenRepository.SaveAsync(account.Id, refreshToken, RefreshTokenTtl); + var accessToken = _jwtProvider.GenerateAccessToken(account); var accessTokenExpiresAt = DateTimeOffset.UtcNow .AddMinutes(_accessTokenExpirationMinutes) .ToUnixTimeSeconds(); - return new TokenResponse(accessToken, refreshToken, accessTokenExpiresAt); + 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/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/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 dde2a89..0c361d7 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"); @@ -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)}"; } } 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 class 유효한_자격증명일_때 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 class 유효한_자격증명일_때 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 95e8909..f2887b3 100644 --- a/Fantasy-server/Fantasy.Test/Auth/Service/RefreshTokenServiceTests.cs +++ b/Fantasy-server/Fantasy.Test/Auth/Service/RefreshTokenServiceTests.cs @@ -1,8 +1,9 @@ +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; -using Fantasy.Server.Global.Security.Provider; using FluentAssertions; using Gamism.SDK.Extensions.AspNetCore.Exceptions; using Microsoft.Extensions.Configuration; @@ -14,117 +15,166 @@ namespace Fantasy.Test.Auth.Service; public class RefreshTokenServiceTests { + // AccountEntity.Create(...)로 생성된 엔티티의 Id는 DB 할당 전이므로 기본값 0 + private const string ValidRefreshToken = "0:valid-refresh-token"; + + 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(ValidRefreshToken); public 유효한_리프레시_토큰으로_요청할_때() { - _currentUserProvider.GetAccountAsync().Returns(_account); - _refreshTokenRepository.FindByIdAsync(_account.Id).Returns("valid-refresh-token"); + _accountRepository.FindByIdAsync(_account.Id).Returns(_account); + _refreshTokenRepository + .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(_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, ValidRefreshToken, "new-refresh-token", TimeSpan.FromDays(30)); } } - public class Redis에_리프레시_토큰이_없을_때 + 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("some-refresh-token"); - public Redis에_리프레시_토큰이_없을_때() + public 유효하지_않은_토큰_형식일_때() { - _currentUserProvider.GetAccountAsync().Returns(_account); - _refreshTokenRepository.FindByIdAsync(_account.Id).Returns((string?)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이_발생한다() + public async Task 콜론이_없는_토큰으로_요청_시_UnauthorizedException이_발생한다() + { + var act = async () => await _sut.ExecuteAsync(new RefreshTokenRequest("invalidtoken")); + + 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("유효하지 않은 리프레시 토큰입니다."); + } + } + + 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; + + public 계정이_존재하지_않을_때() { - // Arrange - // (생성자에서 설정 완료) + _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); + } - // Act - var act = async () => await _sut.ExecuteAsync(_request); + [Fact] + public async Task 토큰_갱신_요청_시_UnauthorizedException이_발생한다() + { + var act = async () => await _sut.ExecuteAsync(new RefreshTokenRequest(ValidRefreshToken)); - // Assert - await act.Should().ThrowAsync(); + await act.Should().ThrowAsync() + .WithMessage("인증 정보를 찾을 수 없습니다."); } } - public class 리프레시_토큰이_불일치할_때 + public class RotateAsync가_NotFound를_반환할_때 { - 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"); - public 리프레시_토큰이_불일치할_때() + public RotateAsync가_NotFound를_반환할_때() { - _currentUserProvider.GetAccountAsync().Returns(_account); - _refreshTokenRepository.FindByIdAsync(_account.Id).Returns("stored-refresh-token"); + _refreshTokenRepository + .RotateAsync(Arg.Any(), ValidRefreshToken, Arg.Any(), TimeSpan.FromDays(30)) + .Returns(RotateResult.NotFound); _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(new RefreshTokenRequest(ValidRefreshToken)); - // Act - var act = async () => await _sut.ExecuteAsync(_request); + 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; + + public RotateAsync가_Reused를_반환할_때() + { + _refreshTokenRepository + .RotateAsync(Arg.Any(), ValidRefreshToken, Arg.Any(), TimeSpan.FromDays(30)) + .Returns(RotateResult.Reused); + _configuration["Jwt:AccessTokenExpirationMinutes"].Returns("15"); + _sut = new RefreshTokenService(_accountRepository, _refreshTokenRepository, _jwtProvider, _configuration); + } + + [Fact] + public async Task 토큰_갱신_요청_시_UnauthorizedException이_발생한다() + { + var act = async () => await _sut.ExecuteAsync(new RefreshTokenRequest(ValidRefreshToken)); - // Assert - await act.Should().ThrowAsync(); + await act.Should().ThrowAsync() + .WithMessage("토큰 재사용이 감지되었습니다."); } } }