Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 1 addition & 14 deletions Fantasy-server/.claude/skills/pr/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ public AccountRepository(AppDbContext db)
.AsNoTracking()
.FirstOrDefaultAsync(a => a.Email == email);

public async Task<AccountEntity?> FindByIdAsync(long id)
=> await _db.Accounts
.AsNoTracking()
.FirstOrDefaultAsync(a => a.Id == id);

public async Task<AccountEntity> SaveAsync(AccountEntity account)
{
var entry = _db.Accounts.Entry(account);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ namespace Fantasy.Server.Domain.Account.Repository.Interface;
public interface IAccountRepository
{
Task<AccountEntity?> FindByEmailAsync(string email);
Task<AccountEntity?> FindByIdAsync(long id);
Task<AccountEntity> SaveAsync(AccountEntity account);
Task<bool> ExistsByEmailAsync(string email);
Task DeleteAsync(AccountEntity account);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ public async Task<CommonApiResponse> Logout()
return CommonApiResponse.Success("로그아웃 성공.");
}

[Authorize]
[HttpPost("refresh")]
public async Task<CommonApiResponse<TokenResponse>> Refresh([FromBody] RefreshTokenRequest request)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Fantasy.Server.Domain.Auth.Enum;

public enum RotateResult
{
NotFound = 0,
Reused = -1,
Success = 1
}
Original file line number Diff line number Diff line change
@@ -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<string?> FindByIdAsync(long id);
Task<RotateResult> RotateAsync(long id, string expectedOldToken, string newToken, TimeSpan ttl);
Task DeleteAsync(long id);
}
Original file line number Diff line number Diff line change
@@ -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<string?> FindByIdAsync(long id)
=> await _cache.GetStringAsync($"refresh:{id}");
public async Task<RotateResult> 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));
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ public async Task<TokenResponse> 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);

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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(
Expand All @@ -32,23 +33,41 @@ public RefreshTokenService(

public async Task<TokenResponse> 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppDbContext>(options =>
options.UseNpgsql(connectionString));
Expand Down
6 changes: 3 additions & 3 deletions Fantasy-server/Fantasy.Server/Global/Config/JwtConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 =>
{
Expand Down
2 changes: 1 addition & 1 deletion Fantasy-server/Fantasy.Server/Global/Config/RedisConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<IConnectionMultiplexer>(
ConnectionMultiplexer.Connect(connectionString));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@ namespace Fantasy.Server.Global.Security.Jwt;
public interface IJwtProvider
{
string GenerateAccessToken(Account account);
string GenerateRefreshToken();
string GenerateRefreshToken(long accountId);
}
10 changes: 5 additions & 5 deletions Fantasy-server/Fantasy.Server/Global/Security/Jwt/JwtProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -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)}";
}
}
4 changes: 2 additions & 2 deletions Fantasy-server/Fantasy.Test/Auth/Service/LoginServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<long>()).Returns("refresh-token");
_configuration["Jwt:AccessTokenExpirationMinutes"].Returns("15");
_sut = new LoginService(_accountRepository, _refreshTokenRepository, _jwtProvider, _configuration);
}
Expand All @@ -49,7 +49,7 @@ public async Task 로그인_요청_시_리프레시_토큰이_Redis에_저장된
await _sut.ExecuteAsync(_request);

await _refreshTokenRepository.Received(1)
.SaveAsync(Arg.Any<long>(), "refresh-token", TimeSpan.FromDays(30));
.SaveAsync(Arg.Any<long>(), Arg.Any<string>(), TimeSpan.FromDays(30));
}
}

Expand Down
Loading
Loading