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
6 changes: 6 additions & 0 deletions src/main/java/com/dnd/moddo/auth/application/AuthService.java
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ public class AuthService {
private final QueryUserService queryUserService;
private final JwtProvider jwtProvider;
private final KakaoClient kakaoClient;
private final RefreshTokenBlacklist refreshTokenBlacklist;

@Transactional
public TokenResponse loginWithGuest() {
Expand Down Expand Up @@ -63,6 +64,11 @@ public TokenResponse loginOrRegisterWithKakao(String code, String state) {
}

public void logout(Long userId) {
logout(userId, null);
}

public void logout(Long userId, String refreshToken) {
refreshTokenBlacklist.revoke(refreshToken);
queryUserService.findKakaoIdById(userId).ifPresent(kakaoId -> {
KakaoLogoutResponse logoutResponse = kakaoClient.logout(kakaoId);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package com.dnd.moddo.auth.application;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.Date;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

import org.springframework.beans.factory.ObjectProvider;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

import com.dnd.moddo.auth.infrastructure.security.JwtProvider;

import io.jsonwebtoken.Claims;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Component
@RequiredArgsConstructor
@Slf4j
public class RefreshTokenBlacklist {
private static final String KEY_PREFIX = "auth:refresh:blacklist:";

private final JwtProvider jwtProvider;
private final ObjectProvider<RedisTemplate<String, Object>> redisTemplateProvider;
private final Map<String, Instant> localBlacklist = new ConcurrentHashMap<>();

public void revoke(String refreshToken) {
if (refreshToken == null || refreshToken.isBlank()) {
return;
}

try {
Claims claims = jwtProvider.parseClaims(refreshToken);
Date expiration = claims.getExpiration();
if (expiration == null) {
return;
}

Instant expiresAt = expiration.toInstant();
Duration ttl = Duration.between(Instant.now(), expiresAt);
if (ttl.isNegative() || ttl.isZero()) {
return;
}

String key = key(refreshToken);
localBlacklist.put(key, expiresAt);
saveToRedis(key, ttl);
} catch (Exception exception) {
log.warn("Failed to revoke refresh token", exception);
}
}

public boolean isRevoked(String refreshToken) {
if (refreshToken == null || refreshToken.isBlank()) {
return false;
}

String key = key(refreshToken);
if (isRevokedInLocal(key)) {
return true;
}

try {
RedisTemplate<String, Object> redisTemplate = redisTemplateProvider.getIfAvailable();
if (redisTemplate == null) {
return false;
}
return Boolean.TRUE.equals(redisTemplate.hasKey(key));
} catch (Exception exception) {
log.warn("Redis blacklist read failed for key={}", key, exception);
return false;
}
}

private void saveToRedis(String key, Duration ttl) {
try {
RedisTemplate<String, Object> redisTemplate = redisTemplateProvider.getIfAvailable();
if (redisTemplate != null) {
redisTemplate.opsForValue().set(key, true, ttl);
}
} catch (Exception exception) {
log.warn("Redis blacklist write failed for key={}", key, exception);
}
}

private boolean isRevokedInLocal(String key) {
Instant expiresAt = localBlacklist.get(key);
if (expiresAt == null) {
return false;
}
if (Instant.now().isAfter(expiresAt)) {
localBlacklist.remove(key);
return false;
}
return true;
}

private String key(String refreshToken) {
return KEY_PREFIX + hash(refreshToken);
}

private String hash(String value) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hashed = digest.digest(value.getBytes(StandardCharsets.UTF_8));
return Base64.getUrlEncoder().withoutPadding().encodeToString(hashed);
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 algorithm is not available", exception);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,14 @@ public class RefreshTokenService {
private final JwtUtil jwtUtil;
private final UserRepository userRepository;
private final JwtProvider jwtProvider;
private final RefreshTokenBlacklist refreshTokenBlacklist;

public RefreshResponse execute(String token) {
try {
if (refreshTokenBlacklist.isRevoked(token)) {
throw new TokenInvalidException();
}

String tokenType = jwtProvider.getTokenType(token);
if (!JwtConstants.REFRESH_KEY.message.equals(tokenType)) {
throw new TokenInvalidException();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,11 @@ public ResponseEntity<Void> kakaoLoginCallback(@RequestParam @NotBlank String co

@PostMapping("/logout")
public ResponseEntity<?> kakaoLogout(@CookieValue(value = "accessToken") String token,
@CookieValue(value = "refreshToken", required = false) String refreshToken,
@LoginUser LoginUserInfo loginUser) {
String accessTokenCookie = authCookieManager.expireCookie("accessToken");
String refreshTokenCookie = authCookieManager.expireCookie("refreshToken");
authService.logout(loginUser.userId());
authService.logout(loginUser.userId(), refreshToken);
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, accessTokenCookie, refreshTokenCookie)
.body(Collections.singletonMap("message", "Logout successful"));
Expand Down
16 changes: 15 additions & 1 deletion src/main/java/com/dnd/moddo/common/config/CacheConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
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.connection.lettuce.LettuceClientConfiguration;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
Expand Down Expand Up @@ -39,13 +40,26 @@ public class CacheConfig {
@Value("${spring.data.redis.password:}")
private String password;

@Value("${spring.data.redis.ssl.enabled:false}")
private boolean sslEnabled;

@Value("${spring.data.redis.timeout:300ms}")
private Duration commandTimeout;

@Bean
public RedisConnectionFactory redisConnectionFactory() {
RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(host, port);
if (password != null && !password.isBlank()) {
config.setPassword(password);
}
LettuceConnectionFactory factory = new LettuceConnectionFactory(config);

LettuceClientConfiguration.LettuceClientConfigurationBuilder builder = LettuceClientConfiguration.builder()
.commandTimeout(commandTimeout);
if (sslEnabled) {
builder.useSsl();
}

LettuceConnectionFactory factory = new LettuceConnectionFactory(config, builder.build());
factory.afterPropertiesSet();
factory.setValidateConnection(false);
return factory;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,18 +171,20 @@ void kakaoLogout() throws Exception {
given(loginUserArgumentResolver.resolveArgument(any(), any(), any(), any()))
.willReturn(new LoginUserInfo(1L, "USER"));

doNothing().when(authService).logout(any());
doNothing().when(authService).logout(any(), any());

//when & then
mockMvc.perform(post("/api/v1/logout")
.cookie(new Cookie("accessToken", "access-token")))
.cookie(new Cookie("accessToken", "access-token"))
.cookie(new Cookie("refreshToken", "refresh-token")))
.andExpect(status().isOk())
.andExpect(header().stringValues(HttpHeaders.SET_COOKIE,
hasItems(org.hamcrest.Matchers.startsWith("accessToken="),
org.hamcrest.Matchers.startsWith("refreshToken="))))
.andDo(document("logout",
requestCookies(
cookieWithName("accessToken").description("액세스 토큰")
cookieWithName("accessToken").description("액세스 토큰"),
cookieWithName("refreshToken").description("리프레시 토큰")
),
responseCookies(
cookieWithName("accessToken").description("만료된 액세스 토큰"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

import com.dnd.moddo.auth.application.AuthService;
import com.dnd.moddo.auth.application.KakaoClient;
import com.dnd.moddo.auth.application.RefreshTokenBlacklist;
import com.dnd.moddo.auth.infrastructure.security.JwtProvider;
import com.dnd.moddo.auth.presentation.response.KakaoLogoutResponse;
import com.dnd.moddo.auth.presentation.response.KakaoProfile;
Expand All @@ -34,6 +35,8 @@ public class AuthServiceTest {
private QueryUserService queryUserService;
@Mock
private KakaoClient kakaoClient;
@Mock
private RefreshTokenBlacklist refreshTokenBlacklist;
@InjectMocks
private AuthService authService;

Expand Down Expand Up @@ -88,11 +91,13 @@ void whenKakaoUserExists_thenTokenIsIssued() {
void whenKakaoIdMatches_thenKakaoLogoutSuccess() {
//given
Long kakaoId = 123456L;
String refreshToken = "refresh-token";
when(queryUserService.findKakaoIdById(any())).thenReturn(Optional.of(kakaoId));
when(kakaoClient.logout(any())).thenReturn(new KakaoLogoutResponse(kakaoId));
//when
authService.logout(1L);
authService.logout(1L, refreshToken);
//then
verify(refreshTokenBlacklist, times(1)).revoke(refreshToken);
verify(queryUserService, times(1)).findKakaoIdById(1L);
verify(kakaoClient, times(1)).logout(kakaoId);
}
Expand Down
Loading
Loading