diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignInReq.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignInReq.java index 788aa335e..2c39ff719 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignInReq.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignInReq.java @@ -11,7 +11,10 @@ public record General( String username, @Schema(description = "비밀번호", example = "pennyway1234") @NotBlank(message = "비밀번호를 입력해주세요") - String password + String password, + @Schema(description = "사용자 기기 고유 식별자", example = "AA-BBB-CCC") + @NotBlank(message = "사용자 기기 고유 식별자를 입력해주세요") + String deviceId ) { } @@ -25,7 +28,10 @@ public record Oauth( String idToken, @Schema(description = "OIDC nonce") @NotBlank(message = "OIDC nonce는 필수 입력값입니다.") - String nonce + String nonce, + @Schema(description = "사용자 기기 고유 식별자", example = "AA-BBB-CCC") + @NotBlank(message = "사용자 기기 고유 식별자를 입력해주세요") + String deviceId ) { } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignUpReq.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignUpReq.java index 3ac9a7e7f..cff22e19d 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignUpReq.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignUpReq.java @@ -18,7 +18,7 @@ * 일반 회원가입 시엔 General, 소셜 회원가입 시엔 Oauth를 사용합니다. */ public class SignUpReq { - public record Info(String username, String name, String password, String phone, String code) { + public record Info(String username, String name, String password, String phone, String code, String deviceId) { public String password(PasswordEncoder passwordEncoder) { return passwordEncoder.encode(password); } @@ -43,7 +43,7 @@ public String password() { } public record OauthInfo(String oauthId, String idToken, String nonce, String name, String username, String phone, - String code) { + String code, String deviceId) { public User toUser() { return User.builder() .username(username) @@ -77,10 +77,13 @@ public record General( @Schema(description = "6자리 정수 인증번호", example = "123456") @NotBlank(message = "인증번호는 필수입니다.") @Pattern(regexp = "^\\d{6}$", message = "인증번호는 6자리 숫자여야 합니다.") - String code + String code, + @Schema(description = "사용자 기기 고유 식별자", example = "AA-BBB-CCC") + @NotBlank(message = "사용자 기기 고유 식별자를 입력해주세요") + String deviceId ) { public Info toInfo() { - return new Info(username, name, password, phone, code); + return new Info(username, name, password, phone, code, deviceId); } } @@ -97,10 +100,13 @@ public record SyncWithOauth( @Schema(description = "6자리 정수 인증번호", example = "123456") @NotBlank(message = "인증번호는 필수입니다.") @Pattern(regexp = "^\\d{6}$", message = "인증번호는 6자리 숫자여야 합니다.") - String code + String code, + @Schema(description = "사용자 기기 고유 식별자", example = "AA-BBB-CCC") + @NotBlank(message = "사용자 기기 고유 식별자를 입력해주세요") + String deviceId ) { public Info toInfo() { - return new Info(null, null, password, phone, code); + return new Info(null, null, password, phone, code, deviceId); } } @@ -130,10 +136,13 @@ public record Oauth( @Schema(description = "6자리 정수 인증번호", example = "123456") @NotBlank(message = "인증번호는 필수입니다.") @Pattern(regexp = "^\\d{6}$", message = "인증번호는 6자리 숫자여야 합니다.") - String code + String code, + @Schema(description = "사용자 기기 고유 식별자", example = "AA-BBB-CCC") + @NotBlank(message = "사용자 기기 고유 식별자를 입력해주세요") + String deviceId ) { public OauthInfo toOauthInfo() { - return new OauthInfo(oauthId, idToken, nonce, name, username, phone, code); + return new OauthInfo(oauthId, idToken, nonce, name, username, phone, code, deviceId); } } @@ -155,10 +164,13 @@ public record SyncWithAuth( @Schema(description = "6자리 정수 인증번호", example = "123456") @NotBlank(message = "인증번호는 필수입니다.") @Pattern(regexp = "^\\d{6}$", message = "인증번호는 6자리 숫자여야 합니다.") - String code + String code, + @Schema(description = "사용자 기기 고유 식별자", example = "AA-BBB-CCC") + @NotBlank(message = "사용자 기기 고유 식별자를 입력해주세요") + String deviceId ) { public OauthInfo toOauthInfo() { - return new OauthInfo(oauthId, idToken, nonce, null, null, phone, code); + return new OauthInfo(oauthId, idToken, nonce, null, null, phone, code, deviceId); } } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/JwtAuthHelper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/JwtAuthHelper.java index e17f121c9..ad528942a 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/JwtAuthHelper.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/JwtAuthHelper.java @@ -46,14 +46,15 @@ public JwtAuthHelper( * 사용자 정보 기반으로 access token과 refresh token을 생성하는 메서드
* refresh token은 redis에 저장된다. * - * @param user {@link User} + * @param user {@link User} : 사용자 정보 + * @param deviceId String : 사용자의 디바이스 고유 식별자 * @return {@link Jwts} */ - public Jwts createToken(User user) { + public Jwts createToken(User user, String deviceId) { String accessToken = accessTokenProvider.generateToken(AccessTokenClaim.of(user.getId(), user.getRole().getType())); - String refreshToken = refreshTokenProvider.generateToken(RefreshTokenClaim.of(user.getId(), user.getRole().getType())); + String refreshToken = refreshTokenProvider.generateToken(RefreshTokenClaim.of(user.getId(), deviceId, user.getRole().getType())); - refreshTokenService.save(RefreshToken.of(user.getId(), refreshToken, toSeconds(refreshTokenProvider.getExpiryDate(refreshToken)))); + refreshTokenService.save(RefreshToken.of(user.getId(), deviceId, refreshToken, toSeconds(refreshTokenProvider.getExpiryDate(refreshToken)))); return Jwts.of(accessToken, refreshToken); } @@ -62,11 +63,12 @@ public Pair refresh(String refreshToken) { Long userId = JwtClaimsParserUtil.getClaimsValue(claims, RefreshTokenClaimKeys.USER_ID.getValue(), Long::parseLong); String role = JwtClaimsParserUtil.getClaimsValue(claims, RefreshTokenClaimKeys.ROLE.getValue(), String.class); - log.debug("refresh token userId : {}, role : {}", userId, role); + String deviceId = JwtClaimsParserUtil.getClaimsValue(claims, RefreshTokenClaimKeys.DEVICE_ID.getValue(), String.class); + log.debug("refresh token userId : {}, deviceId: {}, role : {}", userId, deviceId, role); RefreshToken newRefreshToken; try { - newRefreshToken = refreshTokenService.refresh(userId, refreshToken, refreshTokenProvider.generateToken(RefreshTokenClaim.of(userId, role))); + newRefreshToken = refreshTokenService.refresh(userId, deviceId, refreshToken, refreshTokenProvider.generateToken(RefreshTokenClaim.of(userId, deviceId, role))); log.debug("new refresh token : {}", newRefreshToken.getToken()); } catch (IllegalArgumentException e) { throw new JwtErrorException(JwtErrorCode.EXPIRED_TOKEN); @@ -102,22 +104,23 @@ public void removeAccessTokenAndRefreshToken(Long userId, String accessToken, St } if (jwtClaims != null) { - deleteRefreshToken(userId, jwtClaims, refreshToken); + deleteRefreshToken(userId, jwtClaims); } deleteAccessToken(userId, accessToken); } - private void deleteRefreshToken(Long userId, JwtClaims jwtClaims, String refreshToken) { - Long refreshTokenUserId = Long.parseLong((String) jwtClaims.getClaims().get(RefreshTokenClaimKeys.USER_ID.getValue())); - log.info("로그아웃 요청 refresh token id : {}", refreshTokenUserId); + private void deleteRefreshToken(Long userId, JwtClaims jwtClaims) { + Long refreshTokenUserId = JwtClaimsParserUtil.getClaimsValue(jwtClaims, RefreshTokenClaimKeys.USER_ID.getValue(), Long::parseLong); + String refreshTokenDeviceId = JwtClaimsParserUtil.getClaimsValue(jwtClaims, RefreshTokenClaimKeys.DEVICE_ID.getValue(), String.class); + log.info("로그아웃 요청 refresh token userId : {}, deviceId : {}", refreshTokenUserId, refreshTokenDeviceId); if (!userId.equals(refreshTokenUserId)) { throw new JwtErrorException(JwtErrorCode.WITHOUT_OWNERSHIP_REFRESH_TOKEN); } try { - refreshTokenService.delete(refreshTokenUserId, refreshToken); + refreshTokenService.deleteAll(refreshTokenUserId); } catch (IllegalArgumentException e) { log.warn("refresh token not found. id : {}", userId); } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthUseCase.java index 6dc094a9e..81ddc4fd8 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthUseCase.java @@ -46,14 +46,14 @@ public Pair signUp(SignUpReq.Info request) { UserSyncDto userSync = checkOauthUserNotGeneralSignUp(request.phone()); User user = userGeneralSignService.saveUserWithEncryptedPassword(request, userSync); - return Pair.of(user.getId(), jwtAuthHelper.createToken(user)); + return Pair.of(user.getId(), jwtAuthHelper.createToken(user, request.deviceId())); } @Transactional(readOnly = true) public Pair signIn(SignInReq.General request) { User user = userGeneralSignService.readUserIfValid(request.username(), request.password()); - return Pair.of(user.getId(), jwtAuthHelper.createToken(user)); + return Pair.of(user.getId(), jwtAuthHelper.createToken(user, request.deviceId())); } public Pair refresh(String refreshToken) { diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/OauthUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/OauthUseCase.java index d71b9939e..93a092f70 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/OauthUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/OauthUseCase.java @@ -39,7 +39,7 @@ public Pair signIn(Provider provider, SignInReq.Oauth request) { User user = userOauthSignService.readUser(request.oauthId(), provider); - return (user != null) ? Pair.of(user.getId(), jwtAuthHelper.createToken(user)) : Pair.of(-1L, null); + return (user != null) ? Pair.of(user.getId(), jwtAuthHelper.createToken(user, request.deviceId())) : Pair.of(-1L, null); } @Transactional(readOnly = true) @@ -67,7 +67,7 @@ public Pair signUp(Provider provider, SignUpReq.OauthInfo request) { OidcDecodePayload payload = oauthOidcHelper.getPayload(provider, request.oauthId(), request.idToken(), request.nonce()); User user = userOauthSignService.saveUser(request, userSync, provider, payload.sub()); - return Pair.of(user.getId(), jwtAuthHelper.createToken(user)); + return Pair.of(user.getId(), jwtAuthHelper.createToken(user, request.deviceId())); } /** diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/refresh/RefreshTokenClaim.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/refresh/RefreshTokenClaim.java index 1d346e49c..4d7085d33 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/refresh/RefreshTokenClaim.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/refresh/RefreshTokenClaim.java @@ -6,16 +6,16 @@ import java.util.Map; -import static kr.co.pennyway.api.common.security.jwt.refresh.RefreshTokenClaimKeys.ROLE; -import static kr.co.pennyway.api.common.security.jwt.refresh.RefreshTokenClaimKeys.USER_ID; +import static kr.co.pennyway.api.common.security.jwt.refresh.RefreshTokenClaimKeys.*; @RequiredArgsConstructor(access = AccessLevel.PRIVATE) public class RefreshTokenClaim implements JwtClaims { private final Map claims; - public static RefreshTokenClaim of(Long userId, String role) { + public static RefreshTokenClaim of(Long userId, String deviceToken, String role) { Map claims = Map.of( USER_ID.getValue(), userId.toString(), + DEVICE_ID.getValue(), deviceToken, ROLE.getValue(), role ); return new RefreshTokenClaim(claims); diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/refresh/RefreshTokenClaimKeys.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/refresh/RefreshTokenClaimKeys.java index 46a9752ad..304c50301 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/refresh/RefreshTokenClaimKeys.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/refresh/RefreshTokenClaimKeys.java @@ -2,7 +2,8 @@ public enum RefreshTokenClaimKeys { USER_ID("id"), - ROLE("role"); + ROLE("role"), + DEVICE_ID("deviceId"); private final String value; diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/refresh/RefreshTokenProvider.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/refresh/RefreshTokenProvider.java index 4ecdd7eda..ab7d612cf 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/refresh/RefreshTokenProvider.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/refresh/RefreshTokenProvider.java @@ -22,8 +22,7 @@ import java.util.Date; import java.util.Map; -import static kr.co.pennyway.api.common.security.jwt.refresh.RefreshTokenClaimKeys.ROLE; -import static kr.co.pennyway.api.common.security.jwt.refresh.RefreshTokenClaimKeys.USER_ID; +import static kr.co.pennyway.api.common.security.jwt.refresh.RefreshTokenClaimKeys.*; @Slf4j @Component @@ -56,7 +55,11 @@ public String generateToken(JwtClaims claims) { @Override public JwtClaims getJwtClaimsFromToken(String token) { Claims claims = getClaimsFromToken(token); - return RefreshTokenClaim.of(Long.parseLong(claims.get(USER_ID.getValue(), String.class)), claims.get(ROLE.getValue(), String.class)); + return RefreshTokenClaim.of( + Long.parseLong(claims.get(USER_ID.getValue(), String.class)), + claims.get(DEVICE_ID.getValue(), String.class), + claims.get(ROLE.getValue(), String.class) + ); } @Override diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerValidationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerValidationTest.java index c5ef70386..bf6feb79b 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerValidationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerValidationTest.java @@ -52,11 +52,11 @@ void setUp(WebApplicationContext webApplicationContext) { .build(); } - @DisplayName("[1] 아이디, 이름, 비밀번호, 전화번호, 인증번호 필수 입력") + @DisplayName("[1] 아이디, 이름, 비밀번호, 전화번호, 인증번호, 디바이스 아이디 필수 입력") @Test void requiredInputError() throws Exception { // given - SignUpReq.General request = new SignUpReq.General("", "", "", "", ""); + SignUpReq.General request = new SignUpReq.General("", "", "", "", "", ""); // when ResultActions resultActions = mockMvc.perform( @@ -73,6 +73,7 @@ void requiredInputError() throws Exception { .andExpect(jsonPath("$.fieldErrors.password").exists()) .andExpect(jsonPath("$.fieldErrors.phone").exists()) .andExpect(jsonPath("$.fieldErrors.code").exists()) + .andExpect(jsonPath("$.fieldErrors.deviceId").exists()) .andDo(print()); } @@ -81,7 +82,7 @@ void requiredInputError() throws Exception { void idValidError() throws Exception { // given SignUpReq.General request = new SignUpReq.General("#pennyway", "페니웨이", "pennyway1234", - "010-1234-5678", "123456"); + "010-1234-5678", "123456", "AA-BBB-CCC"); // when ResultActions resultActions = mockMvc.perform( @@ -102,7 +103,7 @@ void idValidError() throws Exception { void nameValidError() throws Exception { // given SignUpReq.General request = new SignUpReq.General("pennyway", "페니웨이12345", "pennyway1234", - "010-1234-5678", "123456"); + "010-1234-5678", "123456", "AA-BBB-CCC"); // when ResultActions resultActions = mockMvc.perform( @@ -123,7 +124,7 @@ void nameValidError() throws Exception { void passwordValidError() throws Exception { // given SignUpReq.General request = new SignUpReq.General("pennyway", "페니웨이", "pennyway", - "010-1234-5678", "123456"); + "010-1234-5678", "123456", "AA-BBB-CCC"); // when ResultActions resultActions = mockMvc.perform( @@ -145,7 +146,7 @@ void passwordValidError() throws Exception { void phoneValidError() throws Exception { // given SignUpReq.General request = new SignUpReq.General("pennyway", "페니웨이", "pennyway1234", - "01012345673", "123456"); + "01012345673", "123456", "AA-BBB-CCC"); // when ResultActions resultActions = mockMvc.perform( @@ -166,7 +167,7 @@ void phoneValidError() throws Exception { void codeValidError() throws Exception { // given SignUpReq.General request = new SignUpReq.General("pennyway", "페니웨이", "pennyway1234", - "010-1234-5678", "12345"); + "010-1234-5678", "12345", "AA-BBB-CCC"); // when ResultActions resultActions = mockMvc.perform( @@ -187,7 +188,7 @@ void codeValidError() throws Exception { void someFieldMissingError() throws Exception { // given SignUpReq.General request = new SignUpReq.General("pennyway", "페니웨이", "pennyway1234", - "010-1234-5678", "123456"); + "010-1234-5678", "123456", "AA-BBB-CCC"); // when ResultActions resultActions = mockMvc.perform( @@ -210,7 +211,7 @@ void someFieldMissingError() throws Exception { void signUp() throws Exception { // given SignUpReq.General request = new SignUpReq.General("pennyway123", "페니웨이", "pennyway1234", - "010-1234-5678", "123456"); + "010-1234-5678", "123456", "AA-BBB-CCC"); ResponseCookie expectedCookie = ResponseCookie.from("refreshToken", "refreshToken") .maxAge(Duration.ofDays(7).toSeconds()).httpOnly(true).path("/").build(); diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/helper/JwtAuthHelperTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/helper/JwtAuthHelperTest.java index bc9ac2cdf..301804f84 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/helper/JwtAuthHelperTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/helper/JwtAuthHelperTest.java @@ -61,11 +61,12 @@ public void RefreshTokenRefreshSuccess() { // given RefreshToken refreshToken = RefreshToken.builder() .userId(1L) + .deviceId("AA-BBB-CC-DDD") .token("refreshToken") .ttl(1000L) .build(); refreshTokenRepository.save(refreshToken); - given(refreshTokenProvider.getJwtClaimsFromToken(refreshToken.getToken())).willReturn(RefreshTokenClaim.of(refreshToken.getUserId(), Role.USER.getType())); + given(refreshTokenProvider.getJwtClaimsFromToken(refreshToken.getToken())).willReturn(RefreshTokenClaim.of(refreshToken.getUserId(), refreshToken.getDeviceId(), Role.USER.getType())); given(accessTokenProvider.generateToken(any())).willReturn("newAccessToken"); given(refreshTokenProvider.generateToken(any())).willReturn("newRefreshToken"); @@ -76,7 +77,7 @@ public void RefreshTokenRefreshSuccess() { assertEquals("사용자 아이디가 일치하지 않습니다.", refreshToken.getUserId(), jwts.getLeft()); assertEquals("갱신된 액세스 토큰이 일치하지 않습니다.", "newAccessToken", jwts.getRight().accessToken()); assertEquals("리프레시 토큰이 갱신되지 않았습니다.", "newRefreshToken", jwts.getRight().refreshToken()); - log.info("갱신된 리프레시 토큰 정보 : {}", refreshTokenRepository.findById(refreshToken.getUserId()).orElse(null)); + log.info("갱신된 리프레시 토큰 정보 : {}", refreshTokenRepository.findById(refreshToken.getId()).orElse(null)); } @Test @@ -85,12 +86,13 @@ public void RefreshTokenRefreshFail() { // given RefreshToken refreshToken = RefreshToken.builder() .userId(1L) + .deviceId("AA-BBB-CC-DDD") .token("refreshToken") .ttl(1000L) .build(); refreshTokenRepository.save(refreshToken); - given(refreshTokenProvider.getJwtClaimsFromToken("anotherRefreshToken")).willReturn(RefreshTokenClaim.of(refreshToken.getUserId(), Role.USER.toString())); + given(refreshTokenProvider.getJwtClaimsFromToken("anotherRefreshToken")).willReturn(RefreshTokenClaim.of(refreshToken.getUserId(), refreshToken.getDeviceId(), Role.USER.toString())); given(refreshTokenProvider.generateToken(any())).willReturn("newRefreshToken"); // when @@ -98,6 +100,6 @@ public void RefreshTokenRefreshFail() { // then assertEquals("탈취 시나리오 예외가 발생하지 않았습니다.", JwtErrorCode.TAKEN_AWAY_TOKEN, jwtErrorException.getErrorCode()); - assertFalse("리프레시 토큰이 삭제되지 않았습니다.", refreshTokenRepository.existsById(refreshToken.getUserId())); + assertFalse("리프레시 토큰이 삭제되지 않았습니다.", refreshTokenRepository.existsById(refreshToken.getId())); } } diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/integration/AuthControllerIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/integration/AuthControllerIntegrationTest.java index 30c2f5a32..33c1a9a1f 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/integration/AuthControllerIntegrationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/integration/AuthControllerIntegrationTest.java @@ -43,6 +43,7 @@ public class AuthControllerIntegrationTest extends ExternalApiDBTestConfig { private final String expectedPhone = "010-1234-5678"; private final String expectedCode = "123456"; + private final String expectedDeviceId = "AA-BB-CC-DD"; @Autowired private MockMvc mockMvc; @@ -224,7 +225,7 @@ void generalSignUpSuccess() throws Exception { } private ResultActions performGeneralSignUpRequest(String code) throws Exception { - SignUpReq.General request = new SignUpReq.General(UserFixture.GENERAL_USER.getUsername(), "pennyway", "dkssudgktpdy1", expectedPhone, code); + SignUpReq.General request = new SignUpReq.General(UserFixture.GENERAL_USER.getUsername(), "pennyway", "dkssudgktpdy1", expectedPhone, code, expectedDeviceId); return mockMvc.perform( post("/v1/auth/sign-up") .contentType(MediaType.APPLICATION_JSON) @@ -286,7 +287,7 @@ void syncWithOauthSignUpSuccess() throws Exception { } private ResultActions performSyncWithOauthSignUpRequest(String expectedPhone, String code) throws Exception { - SignUpReq.SyncWithOauth request = new SignUpReq.SyncWithOauth("dkssudgktpdy1", expectedPhone, code); + SignUpReq.SyncWithOauth request = new SignUpReq.SyncWithOauth("dkssudgktpdy1", expectedPhone, code, expectedDeviceId); return mockMvc.perform( post("/v1/auth/link-oauth") .contentType(MediaType.APPLICATION_JSON) diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/integration/OAuthControllerIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/integration/OAuthControllerIntegrationTest.java index af0bdc4db..977efc062 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/integration/OAuthControllerIntegrationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/integration/OAuthControllerIntegrationTest.java @@ -64,6 +64,7 @@ public class OAuthControllerIntegrationTest extends ExternalApiDBTestConfig { private final String expectedNonce = "testNonce"; private final String expectedPhone = "010-1234-5678"; private final String expectedCode = "123456"; + private final String expectedDeviceId = "testDeviceId"; @Autowired private MockMvc mockMvc; @Autowired @@ -226,7 +227,7 @@ void signInWithNoSignedUser() throws Exception { } private ResultActions performOauthSignIn(Provider provider, String oauthId, String idToken, String nonce) throws Exception { - SignInReq.Oauth request = new SignInReq.Oauth(oauthId, idToken, expectedNonce); + SignInReq.Oauth request = new SignInReq.Oauth(oauthId, idToken, expectedNonce, expectedDeviceId); return mockMvc.perform(post("/v1/auth/oauth/sign-in") .param("provider", provider.name()) @@ -557,7 +558,7 @@ void signUpWithDeletedOauth() throws Exception { } private ResultActions performOauthSignUpAccountLinking(Provider provider, String code, String oauthId) throws Exception { - SignUpReq.SyncWithAuth request = new SignUpReq.SyncWithAuth(oauthId, expectedIdToken, expectedNonce, expectedPhone, code); + SignUpReq.SyncWithAuth request = new SignUpReq.SyncWithAuth(oauthId, expectedIdToken, expectedNonce, expectedPhone, code, expectedDeviceId); return mockMvc.perform(post("/v1/auth/oauth/link-auth") .param("provider", provider.name()) .contentType("application/json") @@ -645,7 +646,7 @@ void signUpWithOauthSignedUser() throws Exception { } private ResultActions performOauthSignUp(Provider provider, String code) throws Exception { - SignUpReq.Oauth request = new SignUpReq.Oauth(expectedOauthId, expectedIdToken, expectedNonce, "jayang", expectedUsername, expectedPhone, code); + SignUpReq.Oauth request = new SignUpReq.Oauth(expectedOauthId, expectedIdToken, expectedNonce, "jayang", expectedUsername, expectedPhone, code, expectedDeviceId); return mockMvc.perform(post("/v1/auth/oauth/sign-up") .param("provider", provider.name()) .contentType("application/json") diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/integration/UserAuthControllerIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/integration/UserAuthControllerIntegrationTest.java index 62bf9599b..091b62257 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/integration/UserAuthControllerIntegrationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/integration/UserAuthControllerIntegrationTest.java @@ -83,6 +83,7 @@ public class UserAuthControllerIntegrationTest extends ExternalApiDBTestConfig { class SignOut { private String expectedAccessToken; private String expectedRefreshToken; + private String expectedDeviceId; private Long userId; @BeforeEach @@ -90,15 +91,16 @@ void setUp() { User user = UserFixture.GENERAL_USER.toUser(); userService.createUser(user); userId = user.getId(); + expectedDeviceId = "AA-BBB-CC-DDD"; expectedAccessToken = accessTokenProvider.generateToken(AccessTokenClaim.of(user.getId(), Role.USER.getType())); - expectedRefreshToken = refreshTokenProvider.generateToken(RefreshTokenClaim.of(user.getId(), Role.USER.getType())); + expectedRefreshToken = refreshTokenProvider.generateToken(RefreshTokenClaim.of(user.getId(), expectedDeviceId, Role.USER.getType())); } @Test @DisplayName("Scenario #1 유효한 accessToken과 refreshToken이 있다면, accessToken은 forbiddenToken으로, refreshToken은 삭제한다.") void validAccessTokenAndValidRefreshToken() throws Exception { // given - refreshTokenService.save(RefreshToken.of(userId, expectedRefreshToken, refreshTokenProvider.getExpiryDate(expectedRefreshToken).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli())); + refreshTokenService.save(RefreshToken.of(userId, expectedDeviceId, expectedRefreshToken, refreshTokenProvider.getExpiryDate(expectedRefreshToken).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli())); // when ResultActions result = mockMvc.perform(performSignOut() @@ -108,7 +110,7 @@ void validAccessTokenAndValidRefreshToken() throws Exception { // then result.andExpect(status().isOk()).andDo(print()); assertTrue(forbiddenTokenService.isForbidden(expectedAccessToken)); - assertThrows(IllegalArgumentException.class, () -> refreshTokenService.delete(userId, expectedRefreshToken)); + refreshTokenService.deleteAll(userId); } @Test @@ -126,9 +128,10 @@ void validAccessTokenWithoutRefreshToken() throws Exception { @DisplayName("Scenario #2-1 유효한 accessToken과 다른 사용자의 유효한 refreshToken이 있다면, 401 에러를 반환한다. accessToken이 forbidden 처리되지 않으며, 사용자와 다른 사용자의 refreshToken 정보 모두 삭제되지 않는다.") void validAccessTokenAndWithOutOwnershipRefreshToken() throws Exception { // given - String unexpectedRefreshToken = refreshTokenProvider.generateToken(RefreshTokenClaim.of(1000L, Role.USER.getType())); - refreshTokenService.save(RefreshToken.of(userId, expectedRefreshToken, refreshTokenProvider.getExpiryDate(expectedRefreshToken).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli())); - refreshTokenService.save(RefreshToken.of(1000L, unexpectedRefreshToken, refreshTokenProvider.getExpiryDate(unexpectedRefreshToken).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli())); + String otherDeviceId = "BB-CCC-DDD"; + String unexpectedRefreshToken = refreshTokenProvider.generateToken(RefreshTokenClaim.of(1000L, otherDeviceId, Role.USER.getType())); + refreshTokenService.save(RefreshToken.of(userId, expectedDeviceId, expectedRefreshToken, refreshTokenProvider.getExpiryDate(expectedRefreshToken).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli())); + refreshTokenService.save(RefreshToken.of(1000L, otherDeviceId, unexpectedRefreshToken, refreshTokenProvider.getExpiryDate(unexpectedRefreshToken).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli())); // when ResultActions result = mockMvc @@ -140,9 +143,10 @@ void validAccessTokenAndWithOutOwnershipRefreshToken() throws Exception { .andExpect(jsonPath("$.code").value(JwtErrorCode.WITHOUT_OWNERSHIP_REFRESH_TOKEN.causedBy().getCode())) .andExpect(jsonPath("$.message").value(JwtErrorCode.WITHOUT_OWNERSHIP_REFRESH_TOKEN.getExplainError())) .andDo(print()); - assertDoesNotThrow(() -> refreshTokenService.delete(userId, expectedRefreshToken)); - assertDoesNotThrow(() -> refreshTokenService.delete(1000L, unexpectedRefreshToken)); assertFalse(forbiddenTokenService.isForbidden(expectedAccessToken)); + + refreshTokenService.deleteAll(userId); + refreshTokenService.deleteAll(1000L); } @Test @@ -150,7 +154,7 @@ void validAccessTokenAndWithOutOwnershipRefreshToken() throws Exception { void validAccessTokenAndInvalidRefreshToken() throws Exception { // given long ttl = refreshTokenProvider.getExpiryDate(expectedRefreshToken).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); - refreshTokenService.save(RefreshToken.of(userId, expectedRefreshToken, ttl)); + refreshTokenService.save(RefreshToken.of(userId, expectedDeviceId, expectedRefreshToken, ttl)); // when ResultActions result = mockMvc.perform(performSignOut() @@ -163,16 +167,17 @@ void validAccessTokenAndInvalidRefreshToken() throws Exception { .andExpect(jsonPath("$.code").value(JwtErrorCode.MALFORMED_TOKEN.causedBy().getCode())) .andExpect(jsonPath("$.message").value(JwtErrorCode.MALFORMED_TOKEN.getExplainError())) .andDo(print()); - assertDoesNotThrow(() -> refreshTokenService.delete(userId, expectedRefreshToken)); assertFalse(forbiddenTokenService.isForbidden(expectedAccessToken)); + + refreshTokenService.deleteAll(userId); } @Test @DisplayName("Scenario #2-3 유효한 accessToken, 유효한 refreshToken을 가진 사용자가 refresh 하기 전의 refreshToken을 사용하는 경우, accessToken을 forbidden에 등록하고 refreshToken을 cache에서 제거한다. (refreshToken 탈취 대체 시나리오)") void validAccessTokenAndOldRefreshToken() throws Exception { // given - String oldRefreshToken = refreshTokenProvider.generateToken(RefreshTokenClaim.of(userId, Role.USER.getType())); - refreshTokenService.save(RefreshToken.of(userId, expectedRefreshToken, refreshTokenProvider.getExpiryDate(expectedRefreshToken).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli())); + String oldRefreshToken = refreshTokenProvider.generateToken(RefreshTokenClaim.of(userId, expectedDeviceId, Role.USER.getType())); + refreshTokenService.save(RefreshToken.of(userId, expectedDeviceId, expectedRefreshToken, refreshTokenProvider.getExpiryDate(expectedRefreshToken).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli())); // when ResultActions result = mockMvc.perform(performSignOut() @@ -184,16 +189,16 @@ void validAccessTokenAndOldRefreshToken() throws Exception { .andExpect(status().isOk()) .andExpect(header().exists(HttpHeaders.SET_COOKIE)) .andDo(print()); - assertThrows(IllegalArgumentException.class, () -> refreshTokenService.delete(userId, oldRefreshToken)); - assertThrows(IllegalArgumentException.class, () -> refreshTokenService.delete(userId, expectedRefreshToken)); assertTrue(forbiddenTokenService.isForbidden(expectedAccessToken)); + + refreshTokenService.deleteAll(userId); } @Test @DisplayName("Scenario #3 유효하지 않은 accessToken과 유효한 refreshToken이 있다면 401 에러를 반환한다.") void invalidAccessTokenAndValidRefreshToken() throws Exception { // given - refreshTokenService.save(RefreshToken.of(userId, expectedRefreshToken, refreshTokenProvider.getExpiryDate(expectedRefreshToken).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli())); + refreshTokenService.save(RefreshToken.of(userId, expectedDeviceId, expectedRefreshToken, refreshTokenProvider.getExpiryDate(expectedRefreshToken).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli())); // when ResultActions result = mockMvc.perform(performSignOut() @@ -335,7 +340,7 @@ void linkOauthWithDeletedOauth() throws Exception { private ResultActions performLinkOauth(Provider provider, String oauthId, User requestUser) throws Exception { UserDetails userDetails = SecurityUserDetails.from(requestUser); - SignInReq.Oauth request = new SignInReq.Oauth(oauthId, "idToken", "nonce"); + SignInReq.Oauth request = new SignInReq.Oauth(oauthId, "idToken", "nonce", "deviceId"); return mockMvc.perform(put("/v1/link-oauth") .contentType(MediaType.APPLICATION_JSON) diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshToken.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshToken.java index db38f7a4c..b8689145d 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshToken.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshToken.java @@ -1,9 +1,6 @@ package kr.co.pennyway.domain.common.redis.refresh; -import lombok.Builder; -import lombok.EqualsAndHashCode; -import lombok.Getter; -import lombok.ToString; +import lombok.*; import org.springframework.data.annotation.Id; import org.springframework.data.redis.core.RedisHash; @@ -11,27 +8,37 @@ @Getter @ToString(of = {"userId", "token", "ttl"}) @EqualsAndHashCode(of = {"userId", "token"}) +@NoArgsConstructor(access = AccessLevel.PROTECTED) public class RefreshToken { @Id - private final Long userId; - private final long ttl; + private String id; + private Long userId; + private String deviceId; + private long ttl; private String token; @Builder - private RefreshToken(String token, Long userId, long ttl) { - this.token = token; + private RefreshToken(Long userId, String deviceId, String token, long ttl) { + this.id = createId(userId, deviceId); this.userId = userId; + this.deviceId = deviceId; + this.token = token; this.ttl = ttl; } - public static RefreshToken of(Long userId, String token, long ttl) { + public static RefreshToken of(Long userId, String deviceId, String token, long ttl) { return RefreshToken.builder() .userId(userId) + .deviceId(deviceId) .token(token) .ttl(ttl) .build(); } + public static String createId(Long userId, String deviceId) { + return userId + ":" + deviceId; + } + protected void rotation(String token) { this.token = token; } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenCustomRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenCustomRepository.java new file mode 100644 index 000000000..a23ac6924 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenCustomRepository.java @@ -0,0 +1,5 @@ +package kr.co.pennyway.domain.common.redis.refresh; + +public interface RefreshTokenCustomRepository { + void deleteAllByUserId(Long userId); +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenCustomRepositoryImpl.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenCustomRepositoryImpl.java new file mode 100644 index 000000000..2bed8a805 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenCustomRepositoryImpl.java @@ -0,0 +1,23 @@ +package kr.co.pennyway.domain.common.redis.refresh; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +import java.util.Set; + +@Repository +@RequiredArgsConstructor +public class RefreshTokenCustomRepositoryImpl implements RefreshTokenCustomRepository { + private final RedisTemplate redisTemplate; + + @Override + public void deleteAllByUserId(Long userId) { + String pattern = "refreshToken:" + userId + ":*"; + Set keys = redisTemplate.keys(pattern); + + if (keys != null && !keys.isEmpty()) { + redisTemplate.delete(keys); + } + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenRepository.java index 35467af12..a7e68cd3e 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenRepository.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenRepository.java @@ -2,5 +2,5 @@ import org.springframework.data.repository.CrudRepository; -public interface RefreshTokenRepository extends CrudRepository { +public interface RefreshTokenRepository extends CrudRepository, RefreshTokenCustomRepository { } \ No newline at end of file diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenService.java index 98158f8b7..e53b41f0d 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenService.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenService.java @@ -12,20 +12,19 @@ public interface RefreshTokenService { * 사용자가 보낸 refresh token으로 기존 refresh token과 비교 검증 후, 새로운 refresh token으로 저장한다. * * @param userId : 토큰 주인 pk + * @param deviceId : 토큰 발급한 디바이스 * @param oldRefreshToken : 사용자가 보낸 refresh token * @param newRefreshToken : 교체할 refresh token * @return {@link RefreshToken} * @throws IllegalArgumentException : userId에 해당하는 refresh token이 없을 경우 * @throws IllegalStateException : 요청한 토큰과 저장된 토큰이 다르다면 토큰이 탈취되었다고 판단하여 값 삭제 */ - RefreshToken refresh(Long userId, String oldRefreshToken, String newRefreshToken) throws IllegalArgumentException, IllegalStateException; + RefreshToken refresh(Long userId, String deviceId, String oldRefreshToken, String newRefreshToken) throws IllegalArgumentException, IllegalStateException; /** - * access token 으로 refresh token을 찾아서 제거 (로그아웃) + * 사용자에게 할당된 모든 Device의 refresh token을 삭제한다. * - * @param userId : 토큰 주인 pk - * @param refreshToken : 검증용 refresh token - * @throws IllegalArgumentException : userId에 해당하는 refresh token이 없을 경우 + * @param userId : 토큰 주인 pk */ - void delete(Long userId, String refreshToken) throws IllegalArgumentException; + void deleteAll(Long userId); } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenServiceImpl.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenServiceImpl.java index 194ed90b9..d7eff2755 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenServiceImpl.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenServiceImpl.java @@ -9,7 +9,7 @@ @RequiredArgsConstructor public class RefreshTokenServiceImpl implements RefreshTokenService { private final RefreshTokenRepository refreshTokenRepository; - + @Override public void save(RefreshToken refreshToken) { refreshTokenRepository.save(refreshToken); @@ -17,8 +17,8 @@ public void save(RefreshToken refreshToken) { } @Override - public RefreshToken refresh(Long userId, String oldRefreshToken, String newRefreshToken) throws IllegalArgumentException, IllegalStateException { - RefreshToken refreshToken = findOrElseThrow(userId); + public RefreshToken refresh(Long userId, String deviceId, String oldRefreshToken, String newRefreshToken) throws IllegalArgumentException, IllegalStateException { + RefreshToken refreshToken = findOrElseThrow(userId, deviceId); validateToken(oldRefreshToken, refreshToken); @@ -30,14 +30,13 @@ public RefreshToken refresh(Long userId, String oldRefreshToken, String newRefre } @Override - public void delete(Long userId, String refreshToken) throws IllegalArgumentException { - RefreshToken token = findOrElseThrow(userId); - refreshTokenRepository.delete(token); + public void deleteAll(Long userId) { + refreshTokenRepository.deleteAllByUserId(userId); log.info("사용자 {}의 리프레시 토큰 삭제", userId); } - private RefreshToken findOrElseThrow(Long userId) { - return refreshTokenRepository.findById(userId) + private RefreshToken findOrElseThrow(Long userId, String deviceId) { + return refreshTokenRepository.findById(RefreshToken.createId(userId, deviceId)) .orElseThrow(() -> new IllegalArgumentException("refresh token not found")); } @@ -49,7 +48,7 @@ private RefreshToken findOrElseThrow(Long userId) { private void validateToken(String requestRefreshToken, RefreshToken expectedRefreshToken) throws IllegalStateException { if (isTakenAway(requestRefreshToken, expectedRefreshToken.getToken())) { log.warn("리프레시 토큰 불일치(탈취). expected : {}, actual : {}", requestRefreshToken, expectedRefreshToken.getToken()); - refreshTokenRepository.delete(expectedRefreshToken); + refreshTokenRepository.deleteAllByUserId(expectedRefreshToken.getUserId()); log.info("사용자 {}의 리프레시 토큰 삭제", expectedRefreshToken.getUserId()); throw new IllegalStateException("refresh token mismatched"); diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenServiceIntegrationTest.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenServiceIntegrationTest.java index 204755be6..6c93dcba4 100644 --- a/pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenServiceIntegrationTest.java +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenServiceIntegrationTest.java @@ -11,6 +11,7 @@ import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.ContextConfiguration; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.springframework.test.util.AssertionErrors.assertEquals; import static org.springframework.test.util.AssertionErrors.assertFalse; @@ -35,6 +36,7 @@ void saveTest() { // given RefreshToken refreshToken = RefreshToken.builder() .userId(1L) + .deviceId("AA-BBB-CC-DDD") .token("refreshToken") .ttl(1000L) .build(); @@ -43,7 +45,7 @@ void saveTest() { refreshTokenService.save(refreshToken); // then - RefreshToken savedRefreshToken = refreshTokenRepository.findById(1L).orElse(null); + RefreshToken savedRefreshToken = refreshTokenRepository.findById(refreshToken.getId()).orElse(null); assertEquals("저장된 리프레시 토큰이 일치하지 않습니다.", refreshToken, savedRefreshToken); log.info("저장된 리프레시 토큰 정보 : {}", savedRefreshToken); } @@ -54,16 +56,17 @@ void refreshTest() { // given RefreshToken refreshToken = RefreshToken.builder() .userId(1L) + .deviceId("AA-BBB-CC-DDD") .token("refreshToken") .ttl(1000L) .build(); refreshTokenService.save(refreshToken); // when - refreshTokenService.refresh(1L, "refreshToken", "newRefreshToken"); + refreshTokenService.refresh(refreshToken.getUserId(), refreshToken.getDeviceId(), refreshToken.getToken(), "newRefreshToken"); // then - RefreshToken savedRefreshToken = refreshTokenRepository.findById(1L).orElse(null); + RefreshToken savedRefreshToken = refreshTokenRepository.findById(refreshToken.getId()).orElse(null); assertEquals("갱신된 리프레시 토큰이 일치하지 않습니다.", "newRefreshToken", savedRefreshToken.getToken()); log.info("갱신된 리프레시 토큰 정보 : {}", savedRefreshToken); } @@ -74,16 +77,51 @@ void validateTokenTest() { // given RefreshToken refreshToken = RefreshToken.builder() .userId(1L) + .deviceId("AA-BBB-CC-DDD") .token("refreshToken") .ttl(1000L) .build(); refreshTokenService.save(refreshToken); // when - IllegalStateException exception = assertThrows(IllegalStateException.class, () -> refreshTokenService.refresh(1L, "anotherRefreshToken", "newRefreshToken")); + IllegalStateException exception = assertThrows(IllegalStateException.class, () -> refreshTokenService.refresh(refreshToken.getUserId(), refreshToken.getDeviceId(), "anotherRefreshToken", "newRefreshToken")); // then assertEquals("리프레시 토큰이 탈취되었을 때 예외가 발생해야 합니다.", "refresh token mismatched", exception.getMessage()); - assertFalse("리프레시 토큰이 탈취되었을 때 저장된 리프레시 토큰이 삭제되어야 합니다.", refreshTokenRepository.existsById(1L)); + assertFalse("리프레시 토큰이 탈취되었을 때 저장된 리프레시 토큰이 삭제되어야 합니다.", refreshTokenRepository.existsById(refreshToken.getId())); + } + + @Test + @DisplayName("사용자에게 할당된 모든 Device의 리프레시 토큰 삭제 테스트") + void deleteAllTest() { + // given + RefreshToken refreshToken1 = RefreshToken.builder() + .userId(1L) + .deviceId("AA-BBB-CC-DDD") + .token("refreshToken1") + .ttl(1000L) + .build(); + RefreshToken refreshToken2 = RefreshToken.builder() + .userId(1L) + .deviceId("AA-BBB-CC-EEE") + .token("refreshToken2") + .ttl(1000L) + .build(); + refreshTokenService.save(refreshToken1); + refreshTokenService.save(refreshToken2); + + // when + refreshTokenService.deleteAll(refreshToken1.getUserId()); + + // then + assertFalse("사용자에게 할당된 모든 Device의 리프레시 토큰이 삭제되어야 합니다.", refreshTokenRepository.existsById(refreshToken1.getId())); + assertFalse("사용자에게 할당된 모든 Device의 리프레시 토큰이 삭제되어야 합니다.", refreshTokenRepository.existsById(refreshToken2.getId())); + } + + @Test + @DisplayName("userId에 해당하는 리프레시 토큰이 없어도, 삭제 수행에서 예외가 발생하지 않아야 합니다.") + void deleteAllWithoutRefreshTokenTest() { + // when - then + assertDoesNotThrow(() -> refreshTokenService.deleteAll(1L)); } }