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
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
import org.websoso.WSSServer.auth.jwt.JWTUtil;
import org.websoso.WSSServer.auth.jwt.JwtProvider;
import org.websoso.WSSServer.auth.jwt.JwtValidationType;
import org.websoso.WSSServer.auth.repository.RefreshTokenRepository;
import org.websoso.WSSServer.auth.client.AppleClient;
import org.websoso.WSSServer.auth.client.AppleIdTokenVerifier;
import org.websoso.WSSServer.auth.client.AppleKeyGenerator;
Expand All @@ -36,7 +35,6 @@ public class AuthApplication {
private final TokenService tokenService;
private final JwtProvider jwtProvider;
private final JWTUtil jwtUtil;
private final RefreshTokenRepository refreshTokenRepository;
private final UserDeviceRepository userDeviceRepository;
private final UserService userService;
private final KakaoService kakaoService;
Expand Down Expand Up @@ -138,8 +136,7 @@ public LoginResponse login(Long userId) {

@Transactional
public void logout(User user, LogoutRequest request) {
refreshTokenRepository.findByRefreshToken(request.refreshToken())
.ifPresent(refreshTokenRepository::delete);
tokenService.deleteRefreshToken(request.refreshToken());

userDeviceRepository.deleteByUserAndDeviceIdentifier(user, request.deviceIdentifier());

Expand Down
6 changes: 6 additions & 0 deletions src/main/java/org/websoso/WSSServer/auth/jwt/JwtProvider.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import java.util.Date;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;
Expand Down Expand Up @@ -50,6 +51,11 @@ private Claims generateClaims(Authentication authentication, Long expirationTime
.setExpiration(new Date(now + expirationTime));
claims.put(CLAIM_USER_ID, authentication.getPrincipal());

// iat/exp는 초 단위로 잘리므로, 회전 대상인 Refresh Token은 jti로 발급마다 문자열 유일성을 보장한다.
if (tokenType == TokenType.REFRESH) {
claims.setId(UUID.randomUUID().toString());
}

return claims;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,11 @@ public RefreshToken findRefreshTokenOrThrow(String refreshToken) {
return refreshTokenRepository.findByRefreshToken(refreshToken)
.orElseThrow(() -> new CustomAuthException(INVALID_TOKEN, "given token is invalid token for reissue"));
}

// 리프레시 토큰을 삭제한다. 존재하지 않아도 예외를 던지지 않는다.
@Transactional
public void deleteRefreshToken(String refreshToken) {
refreshTokenRepository.findByRefreshToken(refreshToken)
.ifPresent(refreshTokenRepository::delete);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package org.websoso.WSSServer.auth.application;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.willAnswer;
import static org.websoso.WSSServer.exception.error.CustomAuthError.INVALID_TOKEN;

import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.websoso.WSSServer.auth.client.AppleClient;
import org.websoso.WSSServer.auth.client.AppleIdTokenVerifier;
import org.websoso.WSSServer.auth.client.AppleKeyGenerator;
import org.websoso.WSSServer.auth.client.KakaoService;
import org.websoso.WSSServer.auth.controller.dto.ReissueResponse;
import org.websoso.WSSServer.auth.domain.RefreshToken;
import org.websoso.WSSServer.auth.jwt.CustomAuthenticationToken;
import org.websoso.WSSServer.auth.jwt.JWTUtil;
import org.websoso.WSSServer.auth.jwt.JwtKeyProvider;
import org.websoso.WSSServer.auth.jwt.JwtProvider;
import org.websoso.WSSServer.auth.jwt.TestTokenFactory;
import org.websoso.WSSServer.auth.repository.RefreshTokenRepository;
import org.websoso.WSSServer.auth.service.AppleService;
import org.websoso.WSSServer.auth.service.TokenService;
import org.websoso.WSSServer.exception.exception.CustomAuthException;
import org.websoso.WSSServer.notification.repository.UserDeviceRepository;
import org.websoso.WSSServer.user.domain.User;
import org.websoso.WSSServer.user.service.UserService;

/**
* 저장소를 토큰 문자열 키 기반 맵으로 흉내 내어, 실제 {@link TokenService} 위에서 회전 결과를 상태로 검증한다.
* {@link RefreshToken}의 @Id가 토큰 문자열이므로 delete/save가 같은 키를 가리키면 회전이 무효화된다.
*/
@ExtendWith(MockitoExtension.class)
class AuthApplicationReissueRotationTest {

private static final Long USER_ID = 42L;

private final JwtKeyProvider jwtKeyProvider = new JwtKeyProvider(TestTokenFactory.TEST_SECRET);
private final JwtProvider jwtProvider = new JwtProvider(jwtKeyProvider,
TestTokenFactory.ACCESS_TOKEN_EXPIRATION, TestTokenFactory.REFRESH_TOKEN_EXPIRATION);
private final JWTUtil jwtUtil = new JWTUtil(jwtKeyProvider);
private final Map<String, RefreshToken> refreshTokenStore = new HashMap<>();

@Mock
private RefreshTokenRepository refreshTokenRepository;

@Mock
private UserDeviceRepository userDeviceRepository;

@Mock
private UserService userService;

@Mock
private KakaoService kakaoService;

@Mock
private AppleService appleService;

@Mock
private AppleClient appleClient;

@Mock
private AppleKeyGenerator appleKeyGenerator;

@Mock
private AppleIdTokenVerifier appleIdTokenVerifier;

@Mock
private User user;

private TokenService tokenService;
private AuthApplication authApplication;

@BeforeEach
void setUp() {
given(user.getUserId()).willReturn(USER_ID);
given(refreshTokenRepository.findByRefreshToken(anyString()))
.willAnswer(invocation -> Optional.ofNullable(refreshTokenStore.get(invocation.getArgument(0))));
given(refreshTokenRepository.save(any(RefreshToken.class)))
.willAnswer(invocation -> {
RefreshToken saved = invocation.getArgument(0);
refreshTokenStore.put(saved.getRefreshToken(), saved);
return saved;
});
willAnswer(invocation -> refreshTokenStore.remove(
invocation.<RefreshToken>getArgument(0).getRefreshToken()))
.given(refreshTokenRepository).delete(any(RefreshToken.class));

tokenService = new TokenService(refreshTokenRepository);
authApplication = new AuthApplication(tokenService, jwtProvider, jwtUtil, userDeviceRepository,
userService, kakaoService, appleService, appleClient, appleKeyGenerator, appleIdTokenVerifier);
}

@DisplayName("재발급에 성공하면 기존 리프레시 토큰은 저장소에서 사라져 다시 재발급에 사용할 수 없다")
@Test
void reissue_afterRotation_rejectsReusedOldRefreshToken() {
String oldRefreshToken = jwtProvider.generateRefreshToken(CustomAuthenticationToken.create(USER_ID));
tokenService.saveRefreshToken(user, oldRefreshToken);

ReissueResponse response = authApplication.reissue(oldRefreshToken);

assertThat(response.refreshToken()).isNotEqualTo(oldRefreshToken);
assertThat(refreshTokenStore).doesNotContainKey(oldRefreshToken);
assertThatThrownBy(() -> authApplication.reissue(oldRefreshToken))
.isInstanceOf(CustomAuthException.class)
.extracting(throwable -> ((CustomAuthException) throwable).getICustomError())
.isEqualTo(INVALID_TOKEN);
}

@DisplayName("재발급된 리프레시 토큰은 저장되어 이어서 다시 재발급할 수 있다")
@Test
void reissue_newRefreshToken_isStoredAndUsableForNextReissue() {
String oldRefreshToken = jwtProvider.generateRefreshToken(CustomAuthenticationToken.create(USER_ID));
tokenService.saveRefreshToken(user, oldRefreshToken);

ReissueResponse first = authApplication.reissue(oldRefreshToken);

assertThat(refreshTokenStore).containsOnlyKeys(first.refreshToken());
assertThat(refreshTokenStore.get(first.refreshToken()).getUserId()).isEqualTo(USER_ID);

ReissueResponse second = authApplication.reissue(first.refreshToken());

assertThat(second.refreshToken()).isNotEqualTo(first.refreshToken());
assertThat(refreshTokenStore).containsOnlyKeys(second.refreshToken());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
package org.websoso.WSSServer.auth.application;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;
import static org.mockito.Mockito.never;
import static org.websoso.WSSServer.exception.error.CustomAuthError.INVALID_TOKEN;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.websoso.WSSServer.auth.client.AppleClient;
import org.websoso.WSSServer.auth.client.AppleIdTokenVerifier;
import org.websoso.WSSServer.auth.client.AppleKeyGenerator;
import org.websoso.WSSServer.auth.client.KakaoService;
import org.websoso.WSSServer.auth.controller.dto.ReissueResponse;
import org.websoso.WSSServer.auth.domain.RefreshToken;
import org.websoso.WSSServer.auth.jwt.JWTUtil;
import org.websoso.WSSServer.auth.jwt.JwtKeyProvider;
import org.websoso.WSSServer.auth.jwt.JwtProvider;
import org.websoso.WSSServer.auth.jwt.TestTokenFactory;
import org.websoso.WSSServer.auth.service.AppleService;
import org.websoso.WSSServer.auth.service.TokenService;
import org.websoso.WSSServer.exception.exception.CustomAuthException;
import org.websoso.WSSServer.notification.repository.UserDeviceRepository;
import org.websoso.WSSServer.user.service.UserService;

@ExtendWith(MockitoExtension.class)
class AuthApplicationReissueTest {

private static final Long USER_ID = 42L;

private final TestTokenFactory testTokenFactory = new TestTokenFactory();
private final JwtKeyProvider jwtKeyProvider = new JwtKeyProvider(TestTokenFactory.TEST_SECRET);
private final JwtProvider jwtProvider = new JwtProvider(jwtKeyProvider,
TestTokenFactory.ACCESS_TOKEN_EXPIRATION, TestTokenFactory.REFRESH_TOKEN_EXPIRATION);
private final JWTUtil jwtUtil = new JWTUtil(jwtKeyProvider);

@Mock
private TokenService tokenService;

@Mock
private UserDeviceRepository userDeviceRepository;

@Mock
private UserService userService;

@Mock
private KakaoService kakaoService;

@Mock
private AppleService appleService;

@Mock
private AppleClient appleClient;

@Mock
private AppleKeyGenerator appleKeyGenerator;

@Mock
private AppleIdTokenVerifier appleIdTokenVerifier;

private AuthApplication authApplication;

@BeforeEach
void setUp() {
authApplication = new AuthApplication(tokenService, jwtProvider, jwtUtil, userDeviceRepository,
userService, kakaoService, appleService, appleClient, appleKeyGenerator, appleIdTokenVerifier);
}

@DisplayName("유효한 리프레시 토큰이면 Access/Refresh Token을 모두 재발급하고 기존 토큰을 회전한다")
@Test
void reissue_validRefreshToken_rotatesAndReturnsNewTokens() {
String oldRefreshToken = testTokenFactory.createRefreshToken(USER_ID);
RefreshToken storedRefreshToken = new RefreshToken(oldRefreshToken, USER_ID);
given(tokenService.findRefreshTokenOrThrow(oldRefreshToken)).willReturn(storedRefreshToken);

ReissueResponse response = authApplication.reissue(oldRefreshToken);

assertThat(response.Authorization()).isNotBlank();
assertThat(response.refreshToken()).isNotBlank();
assertThat(jwtUtil.getUserIdFromJwt(response.refreshToken())).isEqualTo(USER_ID);

ArgumentCaptor<String> newRefreshTokenCaptor = ArgumentCaptor.forClass(String.class);
then(tokenService).should().rotateRefreshToken(eq(storedRefreshToken), newRefreshTokenCaptor.capture(),
eq(USER_ID));
assertThat(newRefreshTokenCaptor.getValue()).isEqualTo(response.refreshToken());
}

@DisplayName("재발급된 리프레시 토큰은 요청에 사용한 기존 리프레시 토큰과 다른 값이다")
@Test
void reissue_returnsRefreshTokenDifferentFromOldOne() {
String oldRefreshToken = testTokenFactory.createRefreshToken(USER_ID);
given(tokenService.findRefreshTokenOrThrow(oldRefreshToken))
.willReturn(new RefreshToken(oldRefreshToken, USER_ID));

ReissueResponse response = authApplication.reissue(oldRefreshToken);

assertThat(response.refreshToken()).isNotEqualTo(oldRefreshToken);
}

@DisplayName("만료된 리프레시 토큰이면 재발급을 거부한다")
@Test
void reissue_expiredRefreshToken_throwsInvalidToken() {
String expiredRefreshToken = testTokenFactory.createExpiredRefreshToken(USER_ID);

assertThatThrownBy(() -> authApplication.reissue(expiredRefreshToken))
.isInstanceOf(CustomAuthException.class)
.extracting(throwable -> ((CustomAuthException) throwable).getICustomError())
.isEqualTo(INVALID_TOKEN);

then(tokenService).shouldHaveNoInteractions();
}

@DisplayName("변조된(서명이 다른) 리프레시 토큰이면 재발급을 거부한다")
@Test
void reissue_tamperedRefreshToken_throwsInvalidToken() {
String tamperedRefreshToken = testTokenFactory.createRefreshTokenWithInvalidSignature(USER_ID);

assertThatThrownBy(() -> authApplication.reissue(tamperedRefreshToken))
.isInstanceOf(CustomAuthException.class)
.extracting(throwable -> ((CustomAuthException) throwable).getICustomError())
.isEqualTo(INVALID_TOKEN);

then(tokenService).shouldHaveNoInteractions();
}

@DisplayName("Access Token으로 재발급을 시도하면 잘못된 토큰 유형으로 거부한다")
@Test
void reissue_accessTokenGiven_throwsInvalidToken() {
String accessToken = testTokenFactory.createAccessToken(USER_ID);

assertThatThrownBy(() -> authApplication.reissue(accessToken))
.isInstanceOf(CustomAuthException.class)
.extracting(throwable -> ((CustomAuthException) throwable).getICustomError())
.isEqualTo(INVALID_TOKEN);

then(tokenService).shouldHaveNoInteractions();
}

@DisplayName("서명은 유효하지만 저장되지 않은 리프레시 토큰이면 재발급을 거부한다")
@Test
void reissue_unstoredRefreshToken_throwsInvalidToken() {
String refreshToken = testTokenFactory.createRefreshToken(USER_ID);
given(tokenService.findRefreshTokenOrThrow(refreshToken))
.willThrow(new CustomAuthException(INVALID_TOKEN, "given token is invalid token for reissue"));

assertThatThrownBy(() -> authApplication.reissue(refreshToken))
.isInstanceOf(CustomAuthException.class)
.extracting(throwable -> ((CustomAuthException) throwable).getICustomError())
.isEqualTo(INVALID_TOKEN);

then(tokenService).should(never())
.rotateRefreshToken(any(), any(), any());
}
}
14 changes: 11 additions & 3 deletions src/test/java/org/websoso/WSSServer/auth/jwt/JWTUtilTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,18 @@ void validateJWT_expiredRefreshToken_returnsExpiredRefresh() {
assertThat(jwtUtil.validateJWT(token)).isEqualTo(JwtValidationType.EXPIRED_REFRESH);
}

@DisplayName("다른 시크릿으로 서명된 토큰은 INVALID_SIGNATURE를 반환한다")
@DisplayName("다른 시크릿으로 서명된 Access Token은 INVALID_SIGNATURE를 반환한다")
@Test
void validateJWT_wrongSignature_returnsInvalidSignature() {
String token = testTokenFactory.createTokenWithInvalidSignature(USER_ID);
void validateJWT_wrongSignatureAccessToken_returnsInvalidSignature() {
String token = testTokenFactory.createAccessTokenWithInvalidSignature(USER_ID);

assertThat(jwtUtil.validateJWT(token)).isEqualTo(JwtValidationType.INVALID_SIGNATURE);
}

@DisplayName("다른 시크릿으로 서명된 Refresh Token은 INVALID_SIGNATURE를 반환한다")
@Test
void validateJWT_wrongSignatureRefreshToken_returnsInvalidSignature() {
String token = testTokenFactory.createRefreshTokenWithInvalidSignature(USER_ID);

assertThat(jwtUtil.validateJWT(token)).isEqualTo(JwtValidationType.INVALID_SIGNATURE);
}
Expand Down
Loading
Loading