Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ dependencies {
implementation 'com.fasterxml.jackson.core:jackson-databind'
implementation 'org.springframework.boot:spring-boot-starter-validation'

implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'

compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.mysql:mysql-connector-j'
annotationProcessor 'org.projectlombok:lombok'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,6 @@ public CustomResponse<?> reissueToken(
.build();

response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
return CustomResponse.onSuccess(AuthSuccessCode.ACCESS_TOKEN_REISSUE_SUCCESS_CODE);
return CustomResponse.onSuccess(AuthSuccessCode.ACCESS_TOKEN_REISSUE_SUCCESS_CODE, tokenDto);
}
}
8 changes: 5 additions & 3 deletions src/main/java/naughty/tuzamate/auth/jwt/JwtProvider.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package naughty.tuzamate.auth.jwt;

import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;

import lombok.extern.slf4j.Slf4j;
Expand All @@ -26,7 +27,7 @@ public class JwtProvider {

// @Value: yml에서 해당 값을 가져오기 (아래의 YML의 값을 가져올 수 있음)
public JwtProvider(@Value("${JWT_SECRET}") String secret, @Value("${ACCESS_EXPIRATION}") long accessExpiration, @Value("${REFRESH_EXPIRATION}") long refreshExpiration) {
this.secret = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); // 가져온 문자열로 SecretKey 생성
this.secret = Keys.hmacShaKeyFor(Decoders.BASE64URL.decode(secret));
this.accessExpiration = accessExpiration;
this.refreshExpiration = refreshExpiration;
}
Expand Down Expand Up @@ -73,10 +74,11 @@ public boolean isValid(String token) {
public Jws<Claims> getClaims(String token) {
try {
return Jwts.parser() // parsing 하기 위해 builder를 가져옴
.setSigningKey(secret) // sign key 설정
.verifyWith(secret) // sign key 설정
.build()
.parseClaimsJws(token); // claim 가져오기
.parseSignedClaims(token); // claim 가져오기
} catch (Exception e) { // parsing하는 과정에서 sign key가 틀리는 등의 이유로 일어나는 Exception
log.info(e.getMessage());
throw new AuthException(JwtErrorCode.TOKEN_INVALID);
}
}
Expand Down
22 changes: 14 additions & 8 deletions src/main/java/naughty/tuzamate/auth/service/AuthService.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,19 @@
import naughty.tuzamate.domain.user.error.exception.UserCustomException;
import naughty.tuzamate.domain.user.repository.UserRepository;
import naughty.tuzamate.global.error.exception.CustomException;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.concurrent.TimeUnit;

@Service
@RequiredArgsConstructor
@Transactional
public class AuthService {

private final RefreshTokenRepository refreshTokenRepository;
private final StringRedisTemplate redisTemplate;
private final UserRepository userRepository;
private final JwtProvider jwtProvider;

Expand All @@ -42,7 +46,7 @@ public void logout(String bearer) {
User user = userRepository.findById(userId).orElseThrow(() -> new UserCustomException(UserErrorCode.USER_NOT_FOUND));
user.increaseTokenVersion();

refreshTokenRepository.deleteById(userId);
redisTemplate.delete("refreshToken:" + userId);
}

public TokenResponse.TokenDto reissueToken(String token) {
Expand All @@ -56,19 +60,21 @@ public TokenResponse.TokenDto reissueToken(String token) {
throw new CustomException(UserErrorCode.LOGGED_OUT_USER); // 의도적으로 토큰을 더 이상 신뢰하지 않는지 확인
}

RefreshToken storedToken = refreshTokenRepository.findById(userId).orElseThrow(
() -> new CustomException(JwtErrorCode.TOKEN_INVALID));
String storedRefreshToken = redisTemplate.opsForValue().get("refreshToken:" + userId);

// 기존 리플레쉬 토큰을 재사용하지 못하도록 한다. (if문의 다음 코드를 사용하지 못하게)
if (!storedToken.getToken().equals(token)) { // 폐기된 토큰을 재사용 -> 탈취된 것
refreshTokenRepository.deleteById(userId);
throw new CustomException(JwtErrorCode.TOKEN_INVALID); // 저장된 토큰과 일치하지 않으면 예외 발생
if (storedRefreshToken == null || !storedRefreshToken.equals(token)) {
throw new CustomException(UserErrorCode.LOGGED_OUT_USER); // 로그아웃된 사용자이거나 토큰이 일치하지 않음
}

String newAccessToken = jwtProvider.createAccessToken(user);
String newRefreshToken = jwtProvider.createRefreshToken(user);

storedToken.update(newRefreshToken);
redisTemplate.opsForValue().set(
"refreshToken:" + userId,
newRefreshToken,
jwtProvider.getRefreshExpiration(),
TimeUnit.MILLISECONDS
);

return new TokenResponse.TokenDto(newAccessToken, newRefreshToken);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
package naughty.tuzamate.auth.service;

import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import naughty.tuzamate.auth.entity.RefreshToken;
import naughty.tuzamate.auth.repository.RefreshTokenRepository;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Optional;

Expand All @@ -16,23 +20,31 @@
@Transactional
public class RefreshTokenService {

private final RefreshTokenRepository refreshTokenRepository;
private final RedisTemplate<String, String> redisTemplate;
private ValueOperations<String, String> valueOperations;

public void saveRefreshToken(Long userId, String token, LocalDateTime expire) {
@PostConstruct
private void init() {
valueOperations = redisTemplate.opsForValue();
}

deleteToken(userId);
public void saveRefreshToken(Long userId, String token, LocalDateTime expire) {

RefreshToken refreshToken = new RefreshToken(token, userId, expire);
refreshTokenRepository.save(refreshToken);
String key = "refreshToken:" + userId;
Duration ttl = Duration.between(LocalDateTime.now(), expire);
valueOperations.set(key, token, ttl);
}
Comment on lines +34 to 45
Copy link

@coderabbitai coderabbitai bot Sep 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Guard against non-positive TTL when saving tokens.

If expire <= now, Duration.between(...) is zero/negative. RedisTemplate.set with non-positive TTL will either error or instantly expire.

Apply this diff:

-        Duration ttl = Duration.between(LocalDateTime.now(), expire);
-        valueOperations.set(key, token, ttl);
+        Duration ttl = Duration.between(LocalDateTime.now(), expire);
+        if (ttl.isZero() || ttl.isNegative()) {
+            log.warn("Skip saving refresh token: non-positive TTL for userId={}, expire={}", userId, expire);
+            redisTemplate.delete(key);
+            return;
+        }
+        valueOperations.set(key, token, ttl);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Duration ttl = Duration.between(LocalDateTime.now(), expire);
valueOperations.set(key, token, ttl);
}
Duration ttl = Duration.between(LocalDateTime.now(), expire);
if (ttl.isZero() || ttl.isNegative()) {
log.warn("Skip saving refresh token: non-positive TTL for userId={}, expire={}", userId, expire);
redisTemplate.delete(key);
return;
}
valueOperations.set(key, token, ttl);
🤖 Prompt for AI Agents
In src/main/java/naughty/tuzamate/auth/service/RefreshTokenService.java around
lines 34–36, guard against non-positive TTL returned by Duration.between(...)
before calling RedisTemplate.set: compute the Duration, check ttl.isPositive(),
and only call valueOperations.set(key, token, ttl) when positive; if ttl is zero
or negative, do not call set with a TTL (either skip saving the token and
optionally delete any existing key and log/debug the event, or call set without
a TTL if semantics require persistence), ensuring you avoid passing a
non-positive TTL to Redis.

Copy link
Contributor Author

@RCNR RCNR Sep 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@coderabbitai
redisTemplate.delete(key);
새로운 토큰 생성에 실패했다는 이유로 아직 유효할 수 있는 기존 토큰을 삭제하는 건 사용자를 강제 로그아웃시키게 되는 거잖아.

return;
같은 경우 예외를 던지는 게 아니라 메서드를 그냥 종료하는 건 문제가 되는 부분이잖아.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@RCNR 맞습니다! 두 지적 모두 정확합니다.

  1. 기존 토큰 삭제 문제: 새로운 토큰 저장에 실패했다고 해서 아직 유효한 기존 토큰을 삭제하는 것은 사용자를 불필요하게 강제 로그아웃시키는 문제가 있습니다.

  2. Silent failure 문제: return으로 조용히 실패하면 호출하는 쪽에서 저장 실패를 알 수 없어 문제가 됩니다.

더 나은 해결책을 제안드립니다:

public void saveRefreshToken(Long userId, String token, LocalDateTime expire) {
    String key = "refreshToken:" + userId;
    Duration ttl = Duration.between(LocalDateTime.now(), expire);
    
    if (ttl.isZero() || ttl.isNegative()) {
        log.error("Invalid expire time for refresh token: userId={}, expire={}, current={}", 
                 userId, expire, LocalDateTime.now());
        throw new IllegalArgumentException("Refresh token expire time must be in the future");
    }
    
    valueOperations.set(key, token, ttl);
}

이 방식은:

  • 기존 토큰을 삭제하지 않음
  • 명확한 예외를 던져 호출자가 실패를 인지할 수 있음
  • 로그를 통해 디버깅 정보 제공

✏️ Learnings added
Learnt from: RCNR
PR: NaughtyComputer/Naughty-BE-Spring#93
File: src/main/java/naughty/tuzamate/auth/service/RefreshTokenService.java:34-36
Timestamp: 2025-09-09T04:45:15.920Z
Learning: When handling refresh token save failures in authentication systems, avoid deleting existing valid tokens as it would unnecessarily force user logout. Instead of silent failures, throw appropriate exceptions to signal the error to calling code.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


public boolean validateToken(String token) {
public Optional<String> getRefreshToken(Long userId) {
String key = "refreshToken:" + userId;
String token = valueOperations.get(key);

Optional<RefreshToken> refreshToken = refreshTokenRepository.findByToken(token);
return refreshToken.isPresent() && refreshToken.get().getExpireDate().isAfter(LocalDateTime.now());
}
return token != null ? Optional.of(token) : Optional.empty();
}

public void deleteRefreshToken(Long userId) {

public void deleteToken(Long userId) {
refreshTokenRepository.deleteByUserId(userId);
String key = "refreshToken:" + userId;
redisTemplate.delete(key);
}
}
45 changes: 45 additions & 0 deletions src/main/java/naughty/tuzamate/global/config/RedisConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package naughty.tuzamate.global.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
public class RedisConfig {

@Value("${spring.data.redis.host}")
private String host;

@Value("${spring.data.redis.port}")
private int port;

@Bean
public LettuceConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(new RedisStandaloneConfiguration(host, port));
}

@Bean
public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory redisConnectionFactory) {

RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();

// redis 연결
redisTemplate.setConnectionFactory(redisConnectionFactory);

// key, value 직렬화 설정
StringRedisSerializer serializer = new StringRedisSerializer();
redisTemplate.setKeySerializer(serializer);
redisTemplate.setValueSerializer(serializer);

// hash key, hash value 직렬화 설정
redisTemplate.setHashKeySerializer(serializer);
redisTemplate.setHashValueSerializer(serializer);

return redisTemplate;
}
}
Loading