diff --git a/build.gradle b/build.gradle index e8ab524..23124cc 100644 --- a/build.gradle +++ b/build.gradle @@ -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' diff --git a/src/main/java/naughty/tuzamate/auth/controller/AuthController.java b/src/main/java/naughty/tuzamate/auth/controller/AuthController.java index 4fb31a4..8cd4d4f 100644 --- a/src/main/java/naughty/tuzamate/auth/controller/AuthController.java +++ b/src/main/java/naughty/tuzamate/auth/controller/AuthController.java @@ -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); } } diff --git a/src/main/java/naughty/tuzamate/auth/dto/TokenResponse.java b/src/main/java/naughty/tuzamate/auth/dto/TokenResponse.java index 4615b3d..7f17e5d 100644 --- a/src/main/java/naughty/tuzamate/auth/dto/TokenResponse.java +++ b/src/main/java/naughty/tuzamate/auth/dto/TokenResponse.java @@ -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; + } + } + } diff --git a/src/main/java/naughty/tuzamate/auth/jwt/JwtProvider.java b/src/main/java/naughty/tuzamate/auth/jwt/JwtProvider.java index ad66029..16d65e3 100644 --- a/src/main/java/naughty/tuzamate/auth/jwt/JwtProvider.java +++ b/src/main/java/naughty/tuzamate/auth/jwt/JwtProvider.java @@ -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; @@ -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; } @@ -73,10 +74,11 @@ public boolean isValid(String token) { public Jws 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); } } diff --git a/src/main/java/naughty/tuzamate/auth/service/AuthService.java b/src/main/java/naughty/tuzamate/auth/service/AuthService.java index f7eddc7..aaf5716 100644 --- a/src/main/java/naughty/tuzamate/auth/service/AuthService.java +++ b/src/main/java/naughty/tuzamate/auth/service/AuthService.java @@ -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; @@ -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) { @@ -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); diff --git a/src/main/java/naughty/tuzamate/auth/service/RefreshTokenService.java b/src/main/java/naughty/tuzamate/auth/service/RefreshTokenService.java index 10d014f..ecf9f1a 100644 --- a/src/main/java/naughty/tuzamate/auth/service/RefreshTokenService.java +++ b/src/main/java/naughty/tuzamate/auth/service/RefreshTokenService.java @@ -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; @@ -16,23 +20,40 @@ @Transactional public class RefreshTokenService { - private final RefreshTokenRepository refreshTokenRepository; + private final RedisTemplate redisTemplate; + private ValueOperations 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 = 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 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); } } diff --git a/src/main/java/naughty/tuzamate/global/config/RedisConfig.java b/src/main/java/naughty/tuzamate/global/config/RedisConfig.java new file mode 100644 index 0000000..91da1bd --- /dev/null +++ b/src/main/java/naughty/tuzamate/global/config/RedisConfig.java @@ -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 redisTemplate(RedisConnectionFactory redisConnectionFactory) { + + RedisTemplate 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; + } +}