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
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ public class AuthController {
@Value("${jwt.refresh-token-expiry}")
private long refreshTokenExpiry;

@Value("${app.cookie.secure:true}")
private boolean cookieSecure;

private final AuthService authService;

@Operation(summary = "소셜 로그인 페이지로 리다이렉트")
Expand Down Expand Up @@ -77,8 +80,9 @@ public ResponseEntity<TokenResponse> refresh(
public ResponseEntity<Void> logout(
@RequestHeader("Authorization") String authorizationHeader,
@AuthenticationPrincipal Long userId,
@CookieValue(name = "refreshToken", required = false) String refreshToken,
HttpServletResponse response) {
authService.logout(authorizationHeader.substring(BEARER_PREFIX.length()), userId);
authService.logout(authorizationHeader.substring(BEARER_PREFIX.length()), userId, refreshToken);
deleteRefreshTokenCookie(response);
return ResponseEntity.ok().build();
}
Expand Down Expand Up @@ -137,7 +141,7 @@ public ResponseEntity<NicknameCheckResponse> checkNickname(@RequestParam String
private void addRefreshTokenCookie(HttpServletResponse response, String refreshToken) {
ResponseCookie cookie = ResponseCookie.from("refreshToken", refreshToken)
.httpOnly(true)
.secure(true)
.secure(cookieSecure)
.path("/")
.maxAge(refreshTokenExpiry / 1000)
.sameSite("Strict")
Expand All @@ -148,7 +152,7 @@ private void addRefreshTokenCookie(HttpServletResponse response, String refreshT
private void deleteRefreshTokenCookie(HttpServletResponse response) {
ResponseCookie cookie = ResponseCookie.from("refreshToken", "")
.httpOnly(true)
.secure(true)
.secure(cookieSecure)
.path("/")
.maxAge(0)
.sameSite("Strict")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ public DevTokenResult login(String nickname, UserRole role) {

String accessToken = jwtProvider.generateAccessToken(user.getId(), user.getRole().name());
String refreshToken = jwtProvider.generateRefreshToken(user.getId());
tokenRepository.save(user.getId(), refreshToken, refreshTokenExpiry);
tokenRepository.saveSession(user.getId(), jwtProvider.parseRefreshToken(refreshToken).sessionId(),
refreshToken, refreshTokenExpiry);

log.info("Dev 로그인 — userId: {}, nickname: {}", user.getId(), user.getNickname());
return new DevTokenResult(accessToken, refreshToken);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse
if (token != null) {
try {
Claims claims = jwtProvider.parseClaims(token);
if (!blacklistRepository.isBlacklisted(token)) {
if (jwtProvider.isAccessToken(claims) && !blacklistRepository.isBlacklisted(token)) {
setAuthentication(claims);
}
} catch (Exception e) {
Expand Down
38 changes: 36 additions & 2 deletions src/main/java/com/Coming/Backend/auth/jwt/JwtProvider.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,18 @@
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import java.util.Date;
import java.util.UUID;
import javax.crypto.SecretKey;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

@Component
public class JwtProvider {

private static final String TOKEN_TYPE_CLAIM = "typ";
private static final String ACCESS_TOKEN_TYPE = "access";
private static final String REFRESH_TOKEN_TYPE = "refresh";

private final SecretKey secretKey;
private final long accessTokenExpiry;
private final long refreshTokenExpiry;
Expand All @@ -35,16 +40,29 @@ public String generateAccessToken(Long userId, String role) {
return Jwts.builder()
.subject(userId.toString())
.claim("role", role)
.claim(TOKEN_TYPE_CLAIM, ACCESS_TOKEN_TYPE)
.issuedAt(now)
.expiration(new Date(now.getTime() + accessTokenExpiry))
.signWith(secretKey)
.compact();
}

/**
* 새 세션의 Refresh Token을 발급한다. 세션 식별자(jti)는 새로 생성된다.
*/
public String generateRefreshToken(Long userId) {
return generateRefreshToken(userId, UUID.randomUUID().toString());
}

/**
* 기존 세션의 Refresh Token을 재발급한다. 회전 시 세션 식별자(jti)를 유지하기 위해 사용한다.
*/
public String generateRefreshToken(Long userId, String sessionId) {
Date now = new Date();
return Jwts.builder()
.subject(userId.toString())
.id(sessionId)
.claim(TOKEN_TYPE_CLAIM, REFRESH_TOKEN_TYPE)
.issuedAt(now)
.expiration(new Date(now.getTime() + refreshTokenExpiry))
.signWith(secretKey)
Expand All @@ -55,8 +73,24 @@ public void validateToken(String token) {
parseClaims(token);
}

public Long getUserId(String token) {
return Long.parseLong(parseClaims(token).getSubject());
/**
* Refresh Token을 검증하고 사용자 ID와 세션 식별자를 반환한다.
* Access Token이나 세션 식별자(jti) 도입 이전에 발급된 토큰이면 {@link InvalidTokenException}.
*/
public RefreshTokenClaims parseRefreshToken(String token) {
Claims claims = parseClaims(token);
String tokenType = claims.get(TOKEN_TYPE_CLAIM, String.class);
if (!REFRESH_TOKEN_TYPE.equals(tokenType) || claims.getId() == null) {
throw new InvalidTokenException();
}
return new RefreshTokenClaims(Long.parseLong(claims.getSubject()), claims.getId());
}

/**
* API 인증에 사용할 수 있는 Access Token인지 확인한다. Refresh Token은 서명이 유효해도 false.
*/
public boolean isAccessToken(Claims claims) {
return ACCESS_TOKEN_TYPE.equals(claims.get(TOKEN_TYPE_CLAIM, String.class));
}

public String getRole(String token) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.Coming.Backend.auth.jwt;

/**
* 검증된 Refresh Token에서 추출한 사용자 ID와 세션 식별자(jti).
*/
public record RefreshTokenClaims(Long userId, String sessionId) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo
log.info("OAuth2 로그인 성공 — userId: {}, isNewUser: {}", user.getId(), oAuth2User.isNewUser());

String refreshToken = jwtProvider.generateRefreshToken(user.getId());
tokenRepository.save(user.getId(), refreshToken, refreshTokenExpiry);
tokenRepository.saveSession(user.getId(), jwtProvider.parseRefreshToken(refreshToken).sessionId(),
refreshToken, refreshTokenExpiry);
addRefreshTokenCookie(response, refreshToken);

String redirectUrl = redirectBaseUri + "?isNewUser=" + (user.getRole() == UserRole.PENDING);
Expand Down
112 changes: 104 additions & 8 deletions src/main/java/com/Coming/Backend/auth/repository/TokenRepository.java
Original file line number Diff line number Diff line change
@@ -1,32 +1,128 @@
package com.Coming.Backend.auth.repository;

import java.util.Optional;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.stereotype.Repository;

/**
* Refresh Token을 기기(세션) 단위로 저장한다.
* <ul>
* <li>{@code RT:{userId}:{sessionId}} — 세션별 Refresh Token</li>
* <li>{@code RT_SESSIONS:{userId}} — 사용자의 세션 목록 ZSET (score = 세션 생성 시각)</li>
* </ul>
*/
@Repository
public class TokenRepository {

private static final String KEY_PREFIX = "RT:";
private static final String SESSIONS_KEY_PREFIX = "RT_SESSIONS:";
private static final int MAX_SESSIONS = 5;

// 저장값이 일치하면 교체하고(1), 다르면 이미 회전된 토큰의 재사용으로 보고 세션을 폐기하며(0), 세션이 없으면 -1.
// 세션이 refresh로만 연장되는 경우에도 세션 목록이 먼저 만료되지 않도록 목록 TTL을 함께 연장한다.
private static final RedisScript<Long> ROTATE_SCRIPT = new DefaultRedisScript<>(
"local stored = redis.call('get', KEYS[1]) "
+ "if not stored then return -1 end "
+ "if stored ~= ARGV[1] then "
+ "redis.call('del', KEYS[1]) "
+ "redis.call('zrem', KEYS[2], ARGV[4]) "
+ "return 0 end "
+ "redis.call('set', KEYS[1], ARGV[2], 'PX', ARGV[3]) "
+ "redis.call('pexpire', KEYS[2], ARGV[3]) "
+ "return 1",
Long.class);

public enum RotationResult {
ROTATED,
REUSE_DETECTED,
SESSION_NOT_FOUND
}

private final RedisTemplate<String, String> redisTemplate;

public TokenRepository(RedisTemplate<String, String> redisTemplate) {
this.redisTemplate = redisTemplate;
}

public void save(Long userId, String refreshToken, long ttlMillis) {
/**
* 새 세션을 저장한다. 세션이 최대 개수를 넘으면 가장 오래 전에 생성된 세션부터 제거한다.
*/
public void saveSession(Long userId, String sessionId, String refreshToken, long ttlMillis) {
String sessionsKey = sessionsKey(userId);
redisTemplate.opsForValue()
.set(KEY_PREFIX + userId, refreshToken, ttlMillis, TimeUnit.MILLISECONDS);
.set(sessionKey(userId, sessionId), refreshToken, ttlMillis, TimeUnit.MILLISECONDS);
redisTemplate.opsForZSet().add(sessionsKey, sessionId, System.currentTimeMillis());
redisTemplate.expire(sessionsKey, ttlMillis, TimeUnit.MILLISECONDS);

pruneSessions(userId);
}

/**
* 저장된 Refresh Token이 currentToken과 일치할 때만 newToken으로 교체한다.
* 일치하지 않으면 이미 회전된 토큰의 재사용(탈취 의심)으로 보고 해당 세션을 폐기한다.
*/
public RotationResult rotate(Long userId, String sessionId, String currentToken, String newToken,
long ttlMillis) {
Long result = redisTemplate.execute(
ROTATE_SCRIPT,
List.of(sessionKey(userId, sessionId), sessionsKey(userId)),
currentToken, newToken, String.valueOf(ttlMillis), sessionId);
if (Long.valueOf(1L).equals(result)) {
return RotationResult.ROTATED;
}
if (Long.valueOf(0L).equals(result)) {
return RotationResult.REUSE_DETECTED;
}
return RotationResult.SESSION_NOT_FOUND;
}

public void deleteSession(Long userId, String sessionId) {
redisTemplate.delete(sessionKey(userId, sessionId));
redisTemplate.opsForZSet().remove(sessionsKey(userId), sessionId);
}

public void deleteAllSessions(Long userId) {
String sessionsKey = sessionsKey(userId);
Set<String> sessionIds = redisTemplate.opsForZSet().range(sessionsKey, 0, -1);
List<String> keys = new ArrayList<>();
if (sessionIds != null) {
sessionIds.forEach(sessionId -> keys.add(sessionKey(userId, sessionId)));
}
keys.add(sessionsKey);
redisTemplate.delete(keys);
}

// 만료된 세션을 목록에서 정리한 뒤, 남은 세션이 최대 개수를 넘으면 오래된 순으로 제거한다.
// score(생성 시각)는 refresh로 연장된 세션을 구분하지 못하므로, 만료는 Refresh Token 키 존재 여부로 판단한다.
private void pruneSessions(Long userId) {
String sessionsKey = sessionsKey(userId);
Set<String> sessionIds = redisTemplate.opsForZSet().range(sessionsKey, 0, -1);
if (sessionIds == null) {
return;
}
List<String> activeSessionIds = new ArrayList<>();
for (String sessionId : sessionIds) {
if (Boolean.TRUE.equals(redisTemplate.hasKey(sessionKey(userId, sessionId)))) {
activeSessionIds.add(sessionId);
} else {
redisTemplate.opsForZSet().remove(sessionsKey, sessionId);
}
}
for (int i = 0; i < activeSessionIds.size() - MAX_SESSIONS; i++) {
deleteSession(userId, activeSessionIds.get(i));
}
}

public Optional<String> find(Long userId) {
String value = redisTemplate.opsForValue().get(KEY_PREFIX + userId);
return Optional.ofNullable(value);
private String sessionKey(Long userId, String sessionId) {
return KEY_PREFIX + userId + ":" + sessionId;
}

public void delete(Long userId) {
redisTemplate.delete(KEY_PREFIX + userId);
private String sessionsKey(Long userId) {
return SESSIONS_KEY_PREFIX + userId;
}
}
Loading
Loading