diff --git a/src/main/java/com/run_us/server/domains/user/controller/AuthController.java b/src/main/java/com/run_us/server/domains/user/controller/AuthController.java index 769d2cc8..e708816b 100644 --- a/src/main/java/com/run_us/server/domains/user/controller/AuthController.java +++ b/src/main/java/com/run_us/server/domains/user/controller/AuthController.java @@ -43,4 +43,12 @@ public SuccessResponse signupAndLogin( return SuccessResponse.of( UserHttpResponseCode.SIGNUP_SUCCESS, new AuthResponse(authResult.tokenPair())); } + + @PreAuthorize("permitAll()") + @PostMapping("/refresh") + public SuccessResponse refresh(@Valid @RequestBody AuthRefreshRequest request) { + AuthResult authResult = userAuthService.refresh(request.getRefreshToken()); + return SuccessResponse.of( + UserHttpResponseCode.REFRESH_SUCCESS, new AuthResponse(authResult.tokenPair())); + } } diff --git a/src/main/java/com/run_us/server/domains/user/controller/model/request/AuthRefreshRequest.java b/src/main/java/com/run_us/server/domains/user/controller/model/request/AuthRefreshRequest.java new file mode 100644 index 00000000..811a309c --- /dev/null +++ b/src/main/java/com/run_us/server/domains/user/controller/model/request/AuthRefreshRequest.java @@ -0,0 +1,12 @@ +package com.run_us.server.domains.user.controller.model.request; + +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class AuthRefreshRequest { + @NotBlank + private String refreshToken; +} \ No newline at end of file diff --git a/src/main/java/com/run_us/server/domains/user/controller/model/response/UserHttpResponseCode.java b/src/main/java/com/run_us/server/domains/user/controller/model/response/UserHttpResponseCode.java index 64fcfe6d..78e105b0 100644 --- a/src/main/java/com/run_us/server/domains/user/controller/model/response/UserHttpResponseCode.java +++ b/src/main/java/com/run_us/server/domains/user/controller/model/response/UserHttpResponseCode.java @@ -7,6 +7,7 @@ public enum UserHttpResponseCode implements CustomResponseCode { MY_PAGE_DATA_FETCHED("USH2001", "마이페이지 데이터 조회 성공", "마이페이지 데이터 조회 성공"), SIGNUP_SUCCESS("USH2002", "회원가입 성공", "회원가입 성공"), LOGIN_SUCCESS("USH2003", "로그인 성공", "로그인 성공"), + REFRESH_SUCCESS("USH2004", "토큰 재발급 성공", "토큰 재발급 성공"), ; private final String code; diff --git a/src/main/java/com/run_us/server/domains/user/domain/AuthResultType.java b/src/main/java/com/run_us/server/domains/user/domain/AuthResultType.java index d2f91296..504cb7a5 100644 --- a/src/main/java/com/run_us/server/domains/user/domain/AuthResultType.java +++ b/src/main/java/com/run_us/server/domains/user/domain/AuthResultType.java @@ -2,6 +2,7 @@ public enum AuthResultType { LOGIN_SUCCESS, + REFRESH_SUCCESS, SIGNUP_REQUIRED, AUTH_FAILED, SIGNUP_FAILED diff --git a/src/main/java/com/run_us/server/domains/user/exception/UserErrorCode.java b/src/main/java/com/run_us/server/domains/user/exception/UserErrorCode.java index 1e7b54a4..d6d30bf8 100644 --- a/src/main/java/com/run_us/server/domains/user/exception/UserErrorCode.java +++ b/src/main/java/com/run_us/server/domains/user/exception/UserErrorCode.java @@ -14,6 +14,7 @@ public enum UserErrorCode implements CustomResponseCode { JWT_NOT_FOUND("UEH4012", HttpStatus.UNAUTHORIZED, "JWT 토큰이 존재하지 않습니다.", "JWT 토큰이 존재하지 않습니다."), JWT_EXPIRED("UEH4013", HttpStatus.UNAUTHORIZED, "JWT 토큰이 만료되었습니다.", "JWT 토큰이 만료되었습니다."), JWT_BROKEN("UEH4014", HttpStatus.UNAUTHORIZED, "JWT 토큰이 손상되었습니다", "JWT 토큰이 손상되었습니다"), + REFRESH_FAILED("UEH4015", HttpStatus.UNAUTHORIZED, "리프레시 토큰이 만료되었습니다.", "리프레시 토큰이 만료되었습니다."), // 404 USER_NOT_FOUND("UEH4041", HttpStatus.NOT_FOUND, "사용자를 찾을 수 없음", "사용자를 찾을 수 없음"),; diff --git a/src/main/java/com/run_us/server/domains/user/service/JwtService.java b/src/main/java/com/run_us/server/domains/user/service/JwtService.java index 6ff34e72..f19c50e2 100644 --- a/src/main/java/com/run_us/server/domains/user/service/JwtService.java +++ b/src/main/java/com/run_us/server/domains/user/service/JwtService.java @@ -7,15 +7,20 @@ import com.run_us.server.domains.user.domain.TokenPair; import com.run_us.server.domains.user.domain.User; import com.run_us.server.domains.user.service.verifier.TokenVerifierFactory; +import com.run_us.server.global.common.cache.InMemoryCache; +import java.time.Duration; import java.util.Date; +import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @Service +@RequiredArgsConstructor public class JwtService { private static final String ISSUER = "RunUSAuthService"; private final TokenVerifierFactory tokenVerifierFactory; + private final InMemoryCache refreshTokenCache; @Value("${jwt.secret}") private String jwtSecret; @Value("${jwt.expiration}") @@ -23,10 +28,6 @@ public class JwtService { @Value("${jwt.refresh.expiration}") private long jwtRefreshExpiration; - public JwtService(TokenVerifierFactory tokenVerifierFactory) { - this.tokenVerifierFactory = tokenVerifierFactory; - } - public String generateAccessToken(User user) { Date now = new Date(); Date expiryDate = new Date(now.getTime() + jwtExpiration); @@ -43,13 +44,22 @@ public String generateRefreshToken(User user) { Date now = new Date(); Date expiryDate = new Date(now.getTime() + jwtRefreshExpiration); - return JWT.create() - .withSubject(user.getPublicId()) - .withIssuedAt(now) - .withExpiresAt(expiryDate) - .withIssuer(ISSUER) - .withClaim("tokenType", "refresh") - .sign(Algorithm.HMAC256(jwtSecret)); + String refreshToken = JWT.create() + .withSubject(user.getPublicId()) + .withIssuedAt(now) + .withExpiresAt(expiryDate) + .withIssuer(ISSUER) + .withClaim("tokenType", "refresh") + .sign(Algorithm.HMAC256(jwtSecret)); + + refreshTokenCache.put("auth:refresh:"+user.getPublicId(), + refreshToken, Duration.ofSeconds(jwtExpiration)); + return refreshToken; + } + + public boolean nonceRefreshToken(String refreshToken) { + String userPublicId = getUserIdFromAccessToken(refreshToken); + return refreshTokenCache.remove("auth:refresh:"+userPublicId, refreshToken); } public TokenPair generateTokenPair(User user) { diff --git a/src/main/java/com/run_us/server/domains/user/service/UserAuthService.java b/src/main/java/com/run_us/server/domains/user/service/UserAuthService.java index 988a43e7..6a942395 100644 --- a/src/main/java/com/run_us/server/domains/user/service/UserAuthService.java +++ b/src/main/java/com/run_us/server/domains/user/service/UserAuthService.java @@ -27,6 +27,7 @@ public class UserAuthService { private final OAuthInfoRepository oAuthInfoRepository; private final OAuthTokenRepository oAuthTokenRepository; private final JwtService jwtService; + private final UserService userService; @Transactional(readOnly = true) public AuthResult authenticateOAuth(String rawToken, SocialProvider provider) { @@ -54,6 +55,22 @@ public AuthResult signupAndLogin(String rawToken, SocialProvider provider, Profi } } + @Transactional(readOnly = true) + public AuthResult refresh(String refreshToken) { + if (!jwtService.nonceRefreshToken(refreshToken)) { + throw UserAuthException.of(UserErrorCode.REFRESH_FAILED); + } + + String userPublicId = jwtService.getUserIdFromAccessToken(refreshToken); + + User user = userService.getUserByPublicId(userPublicId); + if (user == null) { + throw UserAuthException.of(UserErrorCode.USER_NOT_FOUND); + } + + return new AuthResult(AuthResultType.REFRESH_SUCCESS, login(user)); + } + private TokenPair login(User user) { return jwtService.generateTokenPair(user); } diff --git a/src/main/java/com/run_us/server/global/common/cache/InMemoryCache.java b/src/main/java/com/run_us/server/global/common/cache/InMemoryCache.java index 4ec4545a..6a5a41b9 100644 --- a/src/main/java/com/run_us/server/global/common/cache/InMemoryCache.java +++ b/src/main/java/com/run_us/server/global/common/cache/InMemoryCache.java @@ -13,6 +13,7 @@ public interface InMemoryCache { Optional get(K key); Optional> getEntry(K key); - void remove(K key); - void cleanup(); + + boolean remove(K key); + boolean remove(K key, V value); } \ No newline at end of file diff --git a/src/main/java/com/run_us/server/global/common/cache/RedisInMemoryCache.java b/src/main/java/com/run_us/server/global/common/cache/RedisInMemoryCache.java new file mode 100644 index 00000000..cf33c22c --- /dev/null +++ b/src/main/java/com/run_us/server/global/common/cache/RedisInMemoryCache.java @@ -0,0 +1,69 @@ +package com.run_us.server.global.common.cache; + +import java.time.Duration; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; + +@RequiredArgsConstructor +public class RedisInMemoryCache implements InMemoryCache { + private final RedisTemplate cache; + + @Override + public void put(K key, V value) { + cache.opsForValue().set(key, value); + } + + @Override + public void put(K key, V value, Duration ttl) { + cache.opsForValue().set(key, value, ttl); + } + + @Override + public boolean putIfAbsent(K key, V value) { + return Boolean.TRUE.equals( + cache.opsForValue().setIfAbsent(key, value)); + } + + @Override + public boolean putIfAbsent(K key, V value, Duration ttl) { + return Boolean.TRUE.equals( + cache.opsForValue().setIfAbsent(key, value, ttl)); + } + + @Override + public Optional get(K key) { + V value = cache.opsForValue().get(key); + if (value == null) { + return Optional.empty(); + } + return Optional.of(value); + } + + @Override + public Optional> getEntry(K key) { + V value = cache.opsForValue().get(key); + if (value == null) { + return Optional.empty(); + } + Long ttl = cache.getExpire(key); + return Optional.of( + CacheEntry.withTtl(value, Duration.ofSeconds(ttl))); + } + + @Override + public boolean remove(K key) { + return Boolean.TRUE.equals( + cache.delete(key)); + } + + @Override + public boolean remove(K key, V value) { + V currentValue = cache.opsForValue().get(key); + if (value == null || !value.equals(currentValue)) { + return false; + } + return Boolean.TRUE.equals( + cache.delete(key)); + } +} diff --git a/src/main/java/com/run_us/server/global/common/cache/SpringInMemoryCache.java b/src/main/java/com/run_us/server/global/common/cache/SpringInMemoryCache.java index b417adcc..175a7348 100644 --- a/src/main/java/com/run_us/server/global/common/cache/SpringInMemoryCache.java +++ b/src/main/java/com/run_us/server/global/common/cache/SpringInMemoryCache.java @@ -65,7 +65,6 @@ public Optional get(K key) { return Optional.of(entry.value()); } - @Override public Optional> getEntry(K key) { CacheEntry entry = cache.get(key); if (entry == null || entry.isExpired()) { @@ -78,11 +77,19 @@ public Optional> getEntry(K key) { } @Override - public void remove(K key) { - cache.remove(key); + public boolean remove(K key) { + return cache.remove(key) != null; } @Override + public boolean remove(K key, V value) { + CacheEntry entry = cache.get(key); + if(entry == null || !entry.value().equals(value)) { + return false; + } + return cache.remove(key) != null; + } + public void cleanup() { cache.entrySet().removeIf(entry -> entry.getValue().expiresAt() != null && diff --git a/src/main/java/com/run_us/server/global/config/CacheConfig.java b/src/main/java/com/run_us/server/global/config/CacheConfig.java index 0bece3d1..65558e60 100644 --- a/src/main/java/com/run_us/server/global/config/CacheConfig.java +++ b/src/main/java/com/run_us/server/global/config/CacheConfig.java @@ -3,11 +3,13 @@ import com.run_us.server.domains.crew.domain.CrewPrincipal; import com.run_us.server.domains.user.domain.UserPrincipal; import com.run_us.server.global.common.cache.InMemoryCache; +import com.run_us.server.global.common.cache.RedisInMemoryCache; import com.run_us.server.global.common.cache.SpringInMemoryCache; import com.run_us.server.domains.user.domain.TokenStatus; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.scheduling.TaskScheduler; import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; @@ -60,4 +62,11 @@ public InMemoryCache crewPrincipalCache( Duration.ofSeconds(cleanupIntervalSeconds) ); } + + @Bean + public InMemoryCache generalStringCache( + RedisTemplate redisTemplate + ) { + return new RedisInMemoryCache<>(redisTemplate); + } } \ No newline at end of file diff --git a/src/main/java/com/run_us/server/global/config/RedisConfig.java b/src/main/java/com/run_us/server/global/config/RedisConfig.java index 43669378..4419fb77 100644 --- a/src/main/java/com/run_us/server/global/config/RedisConfig.java +++ b/src/main/java/com/run_us/server/global/config/RedisConfig.java @@ -31,8 +31,8 @@ public RedisConnectionFactory redisConnectionFactory() { } @Bean - public RedisTemplate redisTemplate() { - RedisTemplate redisTemplate = new RedisTemplate<>(); + public RedisTemplate cacheTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); redisTemplate.setConnectionFactory(redisConnectionFactory()); redisTemplate.setKeySerializer(new StringRedisSerializer()); redisTemplate.setValueSerializer(new StringRedisSerializer());