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
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,9 @@ public CustomResponse<?> reissueToken(
.build();

response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
return CustomResponse.onSuccess(AuthSuccessCode.ACCESS_TOKEN_REISSUE_SUCCESS_CODE);

TokenResponse.AccessTokenDto accessTokenDto = new TokenResponse.AccessTokenDto(tokenDto.getAccessToken());

return CustomResponse.onSuccess(AuthSuccessCode.ACCESS_TOKEN_REISSUE_SUCCESS_CODE, accessTokenDto);
}
}
9 changes: 9 additions & 0 deletions src/main/java/naughty/tuzamate/auth/dto/TokenResponse.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,13 @@ public TokenDto(String newAccessToken, String newRefreshToken) {
}
}

@Getter
public static class AccessTokenDto {
private final String accessToken;

public AccessTokenDto(String newAccessToken) {
this.accessToken = newAccessToken;
}
}
Comment on lines +19 to +26
Copy link

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

Verify client/API contract for reissue endpoint

Reissue now returns AccessTokenDto (accessToken only). Ensure Swagger/specs and consumers aren’t expecting TokenDto with refreshToken.

Run to locate call sites and docs to update:

I can update the Swagger annotations and a brief migration note for clients if you’d like.


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find usages of TokenDto vs AccessTokenDto
rg -n --type=java -C2 'TokenResponse\.(TokenDto|AccessTokenDto)'

# Find controller responses using onSuccess(...) to check payload types
rg -nP --type=java -C2 '\bonSuccess\s*\('

# Scan OpenAPI/YAML for schema/field mentions
fd -t f -e yml -e yaml -e yml | xargs -I{} rg -n 'AccessTokenDto|TokenDto|accessToken|refreshToken' {}

Length of output: 26428


Update Swagger/OpenAPI contract for the reissue endpoint

  • In AuthController.java’s reissueToken method, update the @ApiResponse/@Schema (or OpenAPI YAML) to reference AccessTokenDto (accessToken only) instead of TokenDto (which includes refreshToken)
  • Remove refreshToken from the response schema/docs and add a brief migration note for clients about the changed payload
🤖 Prompt for AI Agents
In src/main/java/naughty/tuzamate/auth/dto/TokenResponse.java around lines 19 to
26, the API docs still expose TokenDto (which includes refreshToken) for the
reissue endpoint; update the OpenAPI/Swagger annotations in
AuthController.reissueToken to reference AccessTokenDto (only accessToken)
instead of TokenDto, remove refreshToken from the response schema/docs, and add
a short migration note in the API documentation (or YAML) stating that the
reissue response no longer returns a refreshToken and clients must obtain/retain
refresh tokens via the prior flow.


}
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,40 @@
@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) {

deleteToken(userId);
String key = "refreshToken:" + userId;
Duration ttl = Duration.between(LocalDateTime.now(), expire);

RefreshToken refreshToken = new RefreshToken(token, userId, expire);
refreshTokenRepository.save(refreshToken);
}
if (ttl.isNegative() || ttl.isZero()) {

log.warn("만료 시간이 현재 시간보다 이전이거나 같습니다. 토큰을 저장하지 않습니다.");
log.warn("skip saving refreshToken : non-positive ttl. userId = {}, expire = {}", userId, expire);

public boolean validateToken(String token) {
throw new IllegalArgumentException("만료 시간이 이미 지났습니다.");
}

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

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

return token != null ? Optional.of(token) : Optional.empty();
}

public void deleteRefreshToken(Long userId) {

String key = "refreshToken:" + userId;
redisTemplate.delete(key);
}
}
54 changes: 54 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,54 @@
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;

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

@Bean
public LettuceConnectionFactory redisConnectionFactory() {

RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration(host, port);

if (password != null && !password.isEmpty()) {
redisStandaloneConfiguration.setPassword(password);
}
return new LettuceConnectionFactory(redisStandaloneConfiguration);
}

@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