Skip to content
Closed
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
5 changes: 5 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ repositories {
}

dependencies {


implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'
Expand All @@ -32,6 +34,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);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

Bug: maxAge expects seconds (you’re passing millis) → cookie lives 1000× longer

Pass a Duration based on milliseconds.

-        return CustomResponse.onSuccess(AuthSuccessCode.ACCESS_TOKEN_REISSUE_SUCCESS_CODE, tokenDto);
+        return CustomResponse.onSuccess(AuthSuccessCode.ACCESS_TOKEN_REISSUE_SUCCESS_CODE, tokenDto);

Apply this diff above where the cookie is built:

+import java.time.Duration;
@@
-        ResponseCookie cookie = ResponseCookie.from("refreshToken", tokenDto.getRefreshToken())
+        ResponseCookie cookie = ResponseCookie.from("refreshToken", tokenDto.getRefreshToken())
                 .httpOnly(true)
                 .secure(true) // 테스트 시 false
                 .path("/")
                 .sameSite("Lax")
-                .maxAge(jwtProvider.getRefreshExpiration())
+                .maxAge(Duration.ofMillis(jwtProvider.getRefreshExpiration()))
                 .build();
📝 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
return CustomResponse.onSuccess(AuthSuccessCode.ACCESS_TOKEN_REISSUE_SUCCESS_CODE, tokenDto);
++ b/src/main/java/naughty/tuzamate/auth/controller/AuthController.java
@@
import java.time.Duration;
@@
ResponseCookie cookie = ResponseCookie.from("refreshToken", tokenDto.getRefreshToken())
.httpOnly(true)
.secure(true) // 테스트 시 false
.path("/")
.sameSite("Lax")
.maxAge(Duration.ofMillis(jwtProvider.getRefreshExpiration()))
.build();
@@
return CustomResponse.onSuccess(AuthSuccessCode.ACCESS_TOKEN_REISSUE_SUCCESS_CODE, tokenDto);
🤖 Prompt for AI Agents
In src/main/java/naughty/tuzamate/auth/controller/AuthController.java around
line 61, the cookie maxAge is being set using milliseconds (so it ends up 1000×
too long); change the cookie builder to pass a java.time.Duration created from
the millisecond value (e.g., use Duration.ofMillis(yourMillis) for the maxAge
parameter) so the cookie lifetime is interpreted correctly.

}
}
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,8 @@ 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.BASE64.decode(secret));
this.accessExpiration = accessExpiration;
this.refreshExpiration = refreshExpiration;
}
Expand Down Expand Up @@ -73,9 +75,9 @@ 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
throw new AuthException(JwtErrorCode.TOKEN_INVALID);
}
Expand Down
21 changes: 15 additions & 6 deletions src/main/java/naughty/tuzamate/auth/service/AuthService.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,14 @@
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.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.Instant;
import java.util.concurrent.TimeUnit;

@Service
@RequiredArgsConstructor
@Transactional
Expand All @@ -22,6 +27,7 @@ public class AuthService {
private final RefreshTokenRepository refreshTokenRepository;
private final UserRepository userRepository;
private final JwtProvider jwtProvider;
private final StringRedisTemplate redisTemplate;

/**
* user의 tokenVersion을 1 증가시킨다. -> 기존 토큰을 무효화 시킨다
Expand Down Expand Up @@ -56,19 +62,22 @@ 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);
if (storedRefreshToken == null || !storedRefreshToken.equals(token)) {
throw new CustomException(JwtErrorCode.TOKEN_INVALID); // 저장된 토큰과 일치하지 않으면 예외 발생
}

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,38 +1,59 @@
package naughty.tuzamate.auth.service;

import io.netty.util.internal.StringUtil;
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.beans.factory.annotation.Autowired;
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 org.springframework.util.StringUtils;

import javax.swing.text.html.Option;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Optional;

@Service
@RequiredArgsConstructor
@Slf4j
@Transactional
//@Transactional
public class RefreshTokenService {

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

@PostConstruct
private void init() {
valueOperations = redisTemplate.opsForValue();
}

public void saveRefreshToken(Long userId, String token, LocalDateTime expire) {
String key = "refreshToken:" + userId;
Duration ttl = Duration.between(LocalDateTime.now(), expire);
valueOperations.set(key, token, ttl);
}
Comment on lines 35 to +39
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

TTL calc may be wrong across time zones; add guard for negative/zero TTL

expire is LocalDateTime (no zone). If server default zone differs from where expire was computed (e.g., Asia/Seoul), TTL can skew. Also guard negative TTL.

-        Duration ttl = Duration.between(LocalDateTime.now(), expire);
-        valueOperations.set(key, token, ttl);
+        Duration ttl = Duration.between(LocalDateTime.now(), expire);
+        if (!ttl.isNegative() && !ttl.isZero()) {
+            valueOperations.set(key, token, ttl);
+        } else {
+            // Expired or invalid TTL: ensure token isn’t stored
+            redisTemplate.delete(key);
+        }

Follow-up (recommended): change the method to accept Instant expireAt and compute Duration.between(Instant.now(), expireAt) to avoid zone issues.

📝 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
public void saveRefreshToken(Long userId, String token, LocalDateTime expire) {
String key = "refreshToken:" + userId;
Duration ttl = Duration.between(LocalDateTime.now(), expire);
valueOperations.set(key, token, ttl);
}
public void saveRefreshToken(Long userId, String token, LocalDateTime expire) {
String key = "refreshToken:" + userId;
Duration ttl = Duration.between(LocalDateTime.now(), expire);
if (!ttl.isNegative() && !ttl.isZero()) {
valueOperations.set(key, token, ttl);
} else {
// Expired or invalid TTL: ensure token isn’t stored
redisTemplate.delete(key);
}
}
🤖 Prompt for AI Agents
In src/main/java/naughty/tuzamate/auth/service/RefreshTokenService.java around
lines 35 to 39, the TTL is computed from LocalDateTime which is zone-ambiguous
and may produce negative/incorrect durations; update the method to use a
zoned/instant-aware calculation and guard against non-positive TTLs. Replace
Duration.between(LocalDateTime.now(), expire) with a computation that converts
the provided expire to an Instant (or accept an Instant expireAt parameter) and
use Duration.between(Instant.now(), expireAt), then check if the resulting
duration is positive before calling valueOperations.set; if duration is zero or
negative, skip saving (or delete/expire immediately) and log/handle the case
appropriately. Consider the follow-up: change the method signature to accept
Instant expireAt to avoid zone issues entirely.


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

return StringUtils.hasText(token) ? Optional.of(token) : Optional.empty();

RefreshToken refreshToken = new RefreshToken(token, userId, expire);
refreshTokenRepository.save(refreshToken);
}

public boolean validateToken(String token) {
public void deleteRefreshToken(Long userId) {
String key = "refreshToken:" + userId;
redisTemplate.delete(key);
}

/* public boolean validateToken(String token) {

Optional<RefreshToken> refreshToken = refreshTokenRepository.findByToken(token);
return refreshToken.isPresent() && refreshToken.get().getExpireDate().isAfter(LocalDateTime.now());
}

public void deleteToken(Long userId) {
refreshTokenRepository.deleteByUserId(userId);
}
}*/
}
48 changes: 48 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,48 @@
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.core.ValueOperations;
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