diff --git a/src/main/java/com/backend/blooming/authentication/application/AuthenticationService.java b/src/main/java/com/backend/blooming/authentication/application/AuthenticationService.java index a8abc646..85a390be 100644 --- a/src/main/java/com/backend/blooming/authentication/application/AuthenticationService.java +++ b/src/main/java/com/backend/blooming/authentication/application/AuthenticationService.java @@ -3,6 +3,7 @@ import com.backend.blooming.authentication.application.dto.LoginDto; import com.backend.blooming.authentication.application.dto.LoginInformationDto; import com.backend.blooming.authentication.application.dto.LoginUserInformationDto; +import com.backend.blooming.authentication.application.dto.LogoutDto; import com.backend.blooming.authentication.application.dto.TokenDto; import com.backend.blooming.authentication.application.util.OAuthClientComposite; import com.backend.blooming.authentication.infrastructure.exception.InvalidTokenException; @@ -13,6 +14,7 @@ import com.backend.blooming.authentication.infrastructure.oauth.OAuthType; import com.backend.blooming.authentication.infrastructure.oauth.dto.UserInformationDto; import com.backend.blooming.devicetoken.application.service.DeviceTokenService; +import com.backend.blooming.user.application.exception.NotFoundUserException; import com.backend.blooming.user.domain.Email; import com.backend.blooming.user.domain.Name; import com.backend.blooming.user.domain.User; @@ -33,6 +35,7 @@ public class AuthenticationService { private final OAuthClientComposite oAuthClientComposite; private final TokenProvider tokenProvider; private final UserRepository userRepository; + private final BlackListTokenService blackListTokenService; private final DeviceTokenService deviceTokenService; public LoginInformationDto login(final OAuthType oAuthType, final LoginDto loginDto) { @@ -88,7 +91,7 @@ private TokenDto convertToTokenDto(final User user) { private void saveOrActiveToken(final User user, final String deviceToken) { if (deviceToken != null && !deviceToken.isEmpty()) { - deviceTokenService.saveOrActive(user.getId(), deviceToken); + deviceTokenService.saveOrActivate(user.getId(), deviceToken); } } @@ -107,4 +110,30 @@ private void validateUser(final Long userId) { throw new InvalidTokenException(); } } + + public void logout(final Long userId, final LogoutDto logoutDto) { + final AuthClaims authClaims = tokenProvider.parseToken(TokenType.REFRESH, logoutDto.refreshToken()); + final User user = validateAndGetUser(userId, authClaims); + + blackListTokenService.register(logoutDto.refreshToken()); + deviceTokenService.deactivate(user.getId(), logoutDto.deviceToken()); + } + + private User validateAndGetUser(final Long userId, final AuthClaims authClaims) { + if (!userId.equals(authClaims.userId())) { + throw new InvalidTokenException(); + } + + return userRepository.findById(userId) + .orElseThrow(NotFoundUserException::new); + } + + public void withdraw(final Long userId, final String refreshToken) { + final AuthClaims authClaims = tokenProvider.parseToken(TokenType.REFRESH, refreshToken); + final User user = validateAndGetUser(userId, authClaims); + + user.delete(); + blackListTokenService.register(refreshToken); + deviceTokenService.deactivateAllByUserId(user.getId()); + } } diff --git a/src/main/java/com/backend/blooming/authentication/application/BlackListTokenService.java b/src/main/java/com/backend/blooming/authentication/application/BlackListTokenService.java new file mode 100644 index 00000000..94a27877 --- /dev/null +++ b/src/main/java/com/backend/blooming/authentication/application/BlackListTokenService.java @@ -0,0 +1,30 @@ +package com.backend.blooming.authentication.application; + +import com.backend.blooming.authentication.application.exception.AlreadyRegisterBlackListTokenException; +import com.backend.blooming.authentication.domain.BlackListToken; +import com.backend.blooming.authentication.infrastructure.blacklist.BlackListTokenRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class BlackListTokenService { + + private final BlackListTokenRepository blackListTokenRepository; + + public Long register(final String token) { + validateToken(token); + final BlackListToken blackListToken = new BlackListToken(token); + + return blackListTokenRepository.save(blackListToken) + .getId(); + } + + private void validateToken(String token) { + if (blackListTokenRepository.existsByToken(token)) { + throw new AlreadyRegisterBlackListTokenException(); + } + } +} diff --git a/src/main/java/com/backend/blooming/authentication/application/dto/LogoutDto.java b/src/main/java/com/backend/blooming/authentication/application/dto/LogoutDto.java new file mode 100644 index 00000000..0f4a427e --- /dev/null +++ b/src/main/java/com/backend/blooming/authentication/application/dto/LogoutDto.java @@ -0,0 +1,10 @@ +package com.backend.blooming.authentication.application.dto; + +import com.backend.blooming.authentication.presentation.dto.LogoutRequest; + +public record LogoutDto(String refreshToken, String deviceToken) { + + public static LogoutDto from(final LogoutRequest logoutRequest) { + return new LogoutDto(logoutRequest.refreshToken(), logoutRequest.deviceToken()); + } +} diff --git a/src/main/java/com/backend/blooming/authentication/application/exception/AlreadyRegisterBlackListTokenException.java b/src/main/java/com/backend/blooming/authentication/application/exception/AlreadyRegisterBlackListTokenException.java new file mode 100644 index 00000000..30544917 --- /dev/null +++ b/src/main/java/com/backend/blooming/authentication/application/exception/AlreadyRegisterBlackListTokenException.java @@ -0,0 +1,11 @@ +package com.backend.blooming.authentication.application.exception; + +import com.backend.blooming.exception.BloomingException; +import com.backend.blooming.exception.ExceptionMessage; + +public class AlreadyRegisterBlackListTokenException extends BloomingException { + + public AlreadyRegisterBlackListTokenException() { + super(ExceptionMessage.ALREADY_REGISTER_BLACK_LIST_TOKEN); + } +} diff --git a/src/main/java/com/backend/blooming/authentication/configuration/AuthenticationWebMvcConfiguration.java b/src/main/java/com/backend/blooming/authentication/configuration/AuthenticationWebMvcConfiguration.java index ba9d45b3..0d96ad22 100644 --- a/src/main/java/com/backend/blooming/authentication/configuration/AuthenticationWebMvcConfiguration.java +++ b/src/main/java/com/backend/blooming/authentication/configuration/AuthenticationWebMvcConfiguration.java @@ -20,8 +20,7 @@ public class AuthenticationWebMvcConfiguration implements WebMvcConfigurer { @Override public void addInterceptors(final InterceptorRegistry registry) { registry.addInterceptor(authenticationInterceptor) - .addPathPatterns("/**") - .excludePathPatterns("/auth/**"); + .addPathPatterns("/**"); } @Override diff --git a/src/main/java/com/backend/blooming/authentication/domain/BlackListToken.java b/src/main/java/com/backend/blooming/authentication/domain/BlackListToken.java new file mode 100644 index 00000000..661b771d --- /dev/null +++ b/src/main/java/com/backend/blooming/authentication/domain/BlackListToken.java @@ -0,0 +1,31 @@ +package com.backend.blooming.authentication.domain; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +@EqualsAndHashCode(of = "id", callSuper = false) +@ToString +@Table +public class BlackListToken { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String token; + + public BlackListToken(final String token) { + this.token = token; + } +} diff --git a/src/main/java/com/backend/blooming/authentication/infrastructure/blacklist/BlackListTokenRepository.java b/src/main/java/com/backend/blooming/authentication/infrastructure/blacklist/BlackListTokenRepository.java new file mode 100644 index 00000000..06efd02e --- /dev/null +++ b/src/main/java/com/backend/blooming/authentication/infrastructure/blacklist/BlackListTokenRepository.java @@ -0,0 +1,13 @@ +package com.backend.blooming.authentication.infrastructure.blacklist; + +import com.backend.blooming.authentication.domain.BlackListToken; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface BlackListTokenRepository extends JpaRepository { + + Optional findByToken(final String token); + + boolean existsByToken(final String token); +} diff --git a/src/main/java/com/backend/blooming/authentication/presentation/AuthenticationController.java b/src/main/java/com/backend/blooming/authentication/presentation/AuthenticationController.java index ce265928..dd008180 100644 --- a/src/main/java/com/backend/blooming/authentication/presentation/AuthenticationController.java +++ b/src/main/java/com/backend/blooming/authentication/presentation/AuthenticationController.java @@ -3,8 +3,13 @@ import com.backend.blooming.authentication.application.AuthenticationService; import com.backend.blooming.authentication.application.dto.LoginDto; import com.backend.blooming.authentication.application.dto.LoginInformationDto; +import com.backend.blooming.authentication.application.dto.LogoutDto; import com.backend.blooming.authentication.application.dto.TokenDto; import com.backend.blooming.authentication.infrastructure.oauth.OAuthType; +import com.backend.blooming.authentication.presentation.anotaion.Authenticated; +import com.backend.blooming.authentication.presentation.argumentresolver.AuthenticatedUser; +import com.backend.blooming.authentication.presentation.dto.LogoutRequest; +import com.backend.blooming.authentication.presentation.dto.WithdrawRequest; import com.backend.blooming.authentication.presentation.dto.request.ReissueAccessTokenRequest; import com.backend.blooming.authentication.presentation.dto.response.LoginInformationResponse; import com.backend.blooming.authentication.presentation.dto.response.SocialLoginRequest; @@ -12,6 +17,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -45,4 +51,26 @@ public ResponseEntity reissueAccessToken( return ResponseEntity.ok(TokenResponse.from(tokenDto)); } + + @PostMapping(value = "/logout", headers = "X-API-VERSION=1") + public ResponseEntity logout( + @Authenticated final AuthenticatedUser authenticatedUser, + @RequestBody final LogoutRequest logoutRequest + ) { + authenticationService.logout(authenticatedUser.userId(), LogoutDto.from(logoutRequest)); + + return ResponseEntity.noContent() + .build(); + } + + @DeleteMapping(headers = "X-API-VERSION=1") + public ResponseEntity withdraw( + @Authenticated final AuthenticatedUser authenticatedUser, + @RequestBody final WithdrawRequest withdrawRequest + ) { + authenticationService.withdraw(authenticatedUser.userId(), withdrawRequest.refreshToken()); + + return ResponseEntity.noContent() + .build(); + } } diff --git a/src/main/java/com/backend/blooming/authentication/presentation/dto/LogoutRequest.java b/src/main/java/com/backend/blooming/authentication/presentation/dto/LogoutRequest.java new file mode 100644 index 00000000..71c90aa1 --- /dev/null +++ b/src/main/java/com/backend/blooming/authentication/presentation/dto/LogoutRequest.java @@ -0,0 +1,4 @@ +package com.backend.blooming.authentication.presentation.dto; + +public record LogoutRequest(String refreshToken, String deviceToken) { +} diff --git a/src/main/java/com/backend/blooming/authentication/presentation/dto/WithdrawRequest.java b/src/main/java/com/backend/blooming/authentication/presentation/dto/WithdrawRequest.java new file mode 100644 index 00000000..28807263 --- /dev/null +++ b/src/main/java/com/backend/blooming/authentication/presentation/dto/WithdrawRequest.java @@ -0,0 +1,4 @@ +package com.backend.blooming.authentication.presentation.dto; + +public record WithdrawRequest(String refreshToken) { +} diff --git a/src/main/java/com/backend/blooming/devicetoken/application/service/DeviceTokenService.java b/src/main/java/com/backend/blooming/devicetoken/application/service/DeviceTokenService.java index c5880a31..f2afdd4a 100644 --- a/src/main/java/com/backend/blooming/devicetoken/application/service/DeviceTokenService.java +++ b/src/main/java/com/backend/blooming/devicetoken/application/service/DeviceTokenService.java @@ -8,6 +8,7 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.Optional; @Service @Transactional @@ -16,7 +17,7 @@ public class DeviceTokenService { private final DeviceTokenRepository deviceTokenRepository; - public Long saveOrActive(final Long userId, final String token) { + public Long saveOrActivate(final Long userId, final String token) { final DeviceToken deviceToken = findOrPersistDeviceToken(userId, token); activateIfInactive(deviceToken); @@ -46,4 +47,14 @@ public ReadDeviceTokensDto readAllByUserId(final Long userId) { return ReadDeviceTokensDto.from(deviceTokens); } + + public void deactivate(final Long userId, final String token) { + final Optional deviceToken = deviceTokenRepository.findByUserIdAndToken(userId, token); + deviceToken.ifPresent(DeviceToken::deactivate); + } + + public void deactivateAllByUserId(final Long userId) { + final List deviceTokens = deviceTokenRepository.findAllByUserIdAndActiveIsTrue(userId); + deviceTokens.forEach(DeviceToken::deactivate); + } } diff --git a/src/main/java/com/backend/blooming/exception/ExceptionMessage.java b/src/main/java/com/backend/blooming/exception/ExceptionMessage.java index e79bdd38..9c3da30e 100644 --- a/src/main/java/com/backend/blooming/exception/ExceptionMessage.java +++ b/src/main/java/com/backend/blooming/exception/ExceptionMessage.java @@ -16,6 +16,9 @@ public enum ExceptionMessage { EXPIRED_TOKEN("기한이 만료된 토큰입니다."), UNSUPPORTED_OAUTH_TYPE("지원하지 않는 소셜 로그인 방식입니다."), + // 블랙 리스트 토큰 + ALREADY_REGISTER_BLACK_LIST_TOKEN("이미 등록된 블랙 리스트 토큰입니다."), + // 사용자 NOT_FOUND_USER("사용자를 조회할 수 없습니다."), NULL_OR_EMPTY_EMAIL("이메일은 비어있을 수 없습니다."), diff --git a/src/main/java/com/backend/blooming/exception/GlobalExceptionHandler.java b/src/main/java/com/backend/blooming/exception/GlobalExceptionHandler.java index 3ff0b32e..032ae857 100644 --- a/src/main/java/com/backend/blooming/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/backend/blooming/exception/GlobalExceptionHandler.java @@ -1,5 +1,6 @@ package com.backend.blooming.exception; +import com.backend.blooming.authentication.application.exception.AlreadyRegisterBlackListTokenException; import com.backend.blooming.authentication.infrastructure.exception.InvalidTokenException; import com.backend.blooming.authentication.infrastructure.exception.OAuthException; import com.backend.blooming.authentication.infrastructure.exception.UnsupportedOAuthTypeException; @@ -170,4 +171,14 @@ public ResponseEntity handleDeleteFriendForbiddenException( return ResponseEntity.status(HttpStatus.FORBIDDEN) .body(new ExceptionResponse(exception.getMessage())); } + + @ExceptionHandler(AlreadyRegisterBlackListTokenException.class) + public ResponseEntity handleAlreadyRegisterBlackListTokenException( + final AlreadyRegisterBlackListTokenException exception + ) { + logger.warn(String.format(LOG_MESSAGE_FORMAT, exception.getClass().getSimpleName(), exception.getMessage())); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(new ExceptionResponse(exception.getMessage())); + } } diff --git a/src/test/java/com/backend/blooming/authentication/application/AuthenticationServiceTest.java b/src/test/java/com/backend/blooming/authentication/application/AuthenticationServiceTest.java index f742eafc..1be3c203 100644 --- a/src/test/java/com/backend/blooming/authentication/application/AuthenticationServiceTest.java +++ b/src/test/java/com/backend/blooming/authentication/application/AuthenticationServiceTest.java @@ -2,19 +2,26 @@ import com.backend.blooming.authentication.application.dto.LoginInformationDto; import com.backend.blooming.authentication.application.dto.TokenDto; +import com.backend.blooming.authentication.domain.BlackListToken; +import com.backend.blooming.authentication.infrastructure.blacklist.BlackListTokenRepository; import com.backend.blooming.authentication.infrastructure.exception.InvalidTokenException; import com.backend.blooming.authentication.infrastructure.exception.OAuthException; import com.backend.blooming.authentication.infrastructure.exception.UnsupportedOAuthTypeException; import com.backend.blooming.authentication.infrastructure.oauth.OAuthClient; import com.backend.blooming.configuration.IsolateDatabase; +import com.backend.blooming.devicetoken.domain.DeviceToken; +import com.backend.blooming.devicetoken.infrastructure.repository.DeviceTokenRepository; import com.backend.blooming.user.domain.User; import com.backend.blooming.user.infrastructure.repository.UserRepository; +import org.assertj.core.api.SoftAssertions; import org.junit.jupiter.api.DisplayNameGeneration; import org.junit.jupiter.api.DisplayNameGenerator; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.mock.mockito.SpyBean; +import java.util.List; + import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.SoftAssertions.assertSoftly; import static org.mockito.BDDMockito.willReturn; @@ -34,6 +41,12 @@ class AuthenticationServiceTest extends AuthenticationServiceTestFixture { @Autowired private UserRepository userRepository; + @Autowired + private DeviceTokenRepository deviceTokenRepository; + + @Autowired + private BlackListTokenRepository blackListTokenRepository; + @Test void 로그인시_존재하지_않는_사용자인_경우_해당_사용자를_저장후_토큰_정보를_반환한다() { // given @@ -155,4 +168,51 @@ class AuthenticationServiceTest extends AuthenticationServiceTestFixture { assertThatThrownBy(() -> authenticationService.reissueAccessToken(유효하지_않는_타입의_refresh_token)) .isInstanceOf(InvalidTokenException.class); } + + @Test + void 로그아웃시_디바이스_토큰과_액세스_토큰을_비활성화한다() { + // when + authenticationService.logout(기존_사용자.getId(), 로그아웃_dto); + + // then + final BlackListToken blackListToken = blackListTokenRepository.findByToken(로그아웃_dto.refreshToken()).get(); + final DeviceToken deviceToken = deviceTokenRepository.findByUserIdAndToken( + 기존_사용자.getId(), + 로그아웃_dto.deviceToken() + ).get(); + SoftAssertions.assertSoftly(softAssertions -> { + softAssertions.assertThat(blackListToken.getToken()).isEqualTo(로그아웃_dto.refreshToken()); + softAssertions.assertThat(deviceToken.isActive()).isFalse(); + }); + } + + @Test + void 로그아웃시_리프레시_토큰이_유효하지_않다면_예외를_발생시킨다() { + // when & then + assertThatThrownBy(() -> authenticationService.logout(기존_사용자.getId(), 유효하지_않은_리프레시_토큰을_갖는_로그아웃_dto)) + .isInstanceOf(InvalidTokenException.class); + } + + @Test + void 로그아웃시_디바이스_토큰과_액세스_토큰을_비활성화하고_사용자의_삭제_여부를_참으로_변경한다() { + // when + authenticationService.withdraw(기존_사용자.getId(), 유효한_refresh_token); + + // then + final User user = userRepository.findById(기존_사용자.getId()).get(); + final BlackListToken blackListToken = blackListTokenRepository.findByToken(유효한_refresh_token).get(); + final List deviceTokens = deviceTokenRepository.findAllByUserIdAndActiveIsTrue(기존_사용자.getId()); + SoftAssertions.assertSoftly(softAssertions -> { + softAssertions.assertThat(user.isDeleted()).isTrue(); + softAssertions.assertThat(blackListToken.getToken()).isEqualTo(유효한_refresh_token); + softAssertions.assertThat(deviceTokens).isEmpty(); + }); + } + + @Test + void 탈퇴시_리프레시_토큰이_유효하지_않다면_예외를_발생시킨다() { + // when & then + assertThatThrownBy(() -> authenticationService.withdraw(기존_사용자.getId(), 유효하지_않는_refresh_token)) + .isInstanceOf(InvalidTokenException.class); + } } diff --git a/src/test/java/com/backend/blooming/authentication/application/AuthenticationServiceTestFixture.java b/src/test/java/com/backend/blooming/authentication/application/AuthenticationServiceTestFixture.java index 5c157fd3..2f7e599f 100644 --- a/src/test/java/com/backend/blooming/authentication/application/AuthenticationServiceTestFixture.java +++ b/src/test/java/com/backend/blooming/authentication/application/AuthenticationServiceTestFixture.java @@ -1,11 +1,14 @@ package com.backend.blooming.authentication.application; import com.backend.blooming.authentication.application.dto.LoginDto; +import com.backend.blooming.authentication.application.dto.LogoutDto; import com.backend.blooming.authentication.infrastructure.jwt.TokenProvider; import com.backend.blooming.authentication.infrastructure.jwt.TokenType; import com.backend.blooming.authentication.infrastructure.oauth.OAuthType; import com.backend.blooming.authentication.infrastructure.oauth.dto.UserInformationDto; import com.backend.blooming.authentication.infrastructure.oauth.kakao.dto.KakaoUserInformationDto; +import com.backend.blooming.devicetoken.domain.DeviceToken; +import com.backend.blooming.devicetoken.infrastructure.repository.DeviceTokenRepository; import com.backend.blooming.user.domain.Email; import com.backend.blooming.user.domain.Name; import com.backend.blooming.user.domain.User; @@ -22,6 +25,9 @@ public class AuthenticationServiceTestFixture { @Autowired private TokenProvider tokenProvider; + @Autowired + private DeviceTokenRepository deviceTokenRepository; + protected OAuthType oauth_타입 = OAuthType.KAKAO; protected String 소셜_액세스_토큰 = "social_access_token"; protected String 디바이스_토큰 = "device_token"; @@ -40,19 +46,28 @@ public class AuthenticationServiceTestFixture { protected String 존재하지_않는_사용자의_refresh_token; protected String 유효하지_않는_refresh_token = "Bearer invalid_refresh_token"; protected String 유효하지_않는_타입의_refresh_token = "refresh_token"; + protected User 기존_사용자; + protected LogoutDto 로그아웃_dto; + protected LogoutDto 유효하지_않은_리프레시_토큰을_갖는_로그아웃_dto; @BeforeEach void setUpFixture() { - final User 기존_사용자 = User.builder() - .oAuthType(oauth_타입) - .oAuthId(기존_사용자_소셜_정보.oAuthId()) - .name(new Name("기존 사용자")) - .email(new Email(기존_사용자_소셜_정보.email())) - .build(); - + 기존_사용자 = User.builder() + .oAuthType(oauth_타입) + .oAuthId(기존_사용자_소셜_정보.oAuthId()) + .name(new Name("기존 사용자")) + .email(new Email(기존_사용자_소셜_정보.email())) + .build(); userRepository.save(기존_사용자); + final DeviceToken 기존_사용자의_디바이스_토큰 = new DeviceToken(기존_사용자.getId(), "default_user_device_token"); + deviceTokenRepository.save(기존_사용자의_디바이스_토큰); + 유효한_refresh_token = "Bearer " + tokenProvider.createToken(TokenType.REFRESH, 기존_사용자.getId()); + final String 유효하지_않은_refresh_token = "Bearer " + tokenProvider.createToken(TokenType.REFRESH, 999L); + + 로그아웃_dto = new LogoutDto(유효한_refresh_token, 기존_사용자의_디바이스_토큰.getToken()); + 유효하지_않은_리프레시_토큰을_갖는_로그아웃_dto = new LogoutDto(유효하지_않은_refresh_token, 기존_사용자의_디바이스_토큰.getToken()); final long 존재하지_않는_사용자_아이디 = 9999L; 존재하지_않는_사용자의_refresh_token = "Bearer " + tokenProvider.createToken(TokenType.REFRESH, 존재하지_않는_사용자_아이디); diff --git a/src/test/java/com/backend/blooming/authentication/application/BlackListTokenServiceTest.java b/src/test/java/com/backend/blooming/authentication/application/BlackListTokenServiceTest.java new file mode 100644 index 00000000..1fcecea9 --- /dev/null +++ b/src/test/java/com/backend/blooming/authentication/application/BlackListTokenServiceTest.java @@ -0,0 +1,36 @@ +package com.backend.blooming.authentication.application; + +import com.backend.blooming.authentication.application.exception.AlreadyRegisterBlackListTokenException; +import com.backend.blooming.configuration.IsolateDatabase; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@IsolateDatabase +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class BlackListTokenServiceTest extends BlackListTokenServiceTestFixture { + + @Autowired + private BlackListTokenService blackListTokenService; + + @Test + void 블랙_리스트_토큰을_등록한다() { + // when + final Long actual = blackListTokenService.register(토큰); + + // then + assertThat(actual).isPositive(); + } + + @Test + void 블랙_리스트_토큰_등록시_이미_등록된_토큰이라면_예외를_반환한다() { + // when & then + assertThatThrownBy(() -> blackListTokenService.register(이미_등록된_토큰)) + .isInstanceOf(AlreadyRegisterBlackListTokenException.class); + } +} diff --git a/src/test/java/com/backend/blooming/authentication/application/BlackListTokenServiceTestFixture.java b/src/test/java/com/backend/blooming/authentication/application/BlackListTokenServiceTestFixture.java new file mode 100644 index 00000000..a9a34939 --- /dev/null +++ b/src/test/java/com/backend/blooming/authentication/application/BlackListTokenServiceTestFixture.java @@ -0,0 +1,22 @@ +package com.backend.blooming.authentication.application; + +import com.backend.blooming.authentication.domain.BlackListToken; +import com.backend.blooming.authentication.infrastructure.blacklist.BlackListTokenRepository; +import org.junit.jupiter.api.BeforeEach; +import org.springframework.beans.factory.annotation.Autowired; + +@SuppressWarnings("NonAsciiCharacters") +public class BlackListTokenServiceTestFixture { + + @Autowired + private BlackListTokenRepository blackListTokenRepository; + + protected String 토큰 = "refresh token"; + protected String 이미_등록된_토큰 = "already register refresh token"; + + @BeforeEach + void setUpFixture() { + final BlackListToken 블랙_리스트_토큰 = new BlackListToken(이미_등록된_토큰); + blackListTokenRepository.save(블랙_리스트_토큰); + } +} diff --git a/src/test/java/com/backend/blooming/authentication/infrastructure/blacklist/BlackListTokenRepositoryTest.java b/src/test/java/com/backend/blooming/authentication/infrastructure/blacklist/BlackListTokenRepositoryTest.java new file mode 100644 index 00000000..48de368c --- /dev/null +++ b/src/test/java/com/backend/blooming/authentication/infrastructure/blacklist/BlackListTokenRepositoryTest.java @@ -0,0 +1,52 @@ +package com.backend.blooming.authentication.infrastructure.blacklist; + +import com.backend.blooming.authentication.domain.BlackListToken; +import org.assertj.core.api.SoftAssertions; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class BlackListTokenRepositoryTest extends BlackListTokenRepositoryTestFixture { + + @Autowired + private BlackListTokenRepository blackListTokenRepository; + + @Test + void 블랙_리스트에_특정_토큰이_존재한다면_해당_토큰을_반환한다() { + // when + final Optional actual = blackListTokenRepository.findByToken(블랙_리스트_토큰.getToken()); + + // then + SoftAssertions.assertSoftly(softAssertions -> { + softAssertions.assertThat(actual).isPresent(); + softAssertions.assertThat(actual.get().getToken()).isEqualTo(블랙_리스트_토큰.getToken()); + }); + } + + @Test + void 블랙_리스트에_특정_토큰이_존재한다면_참을_반환한다() { + // when + final boolean actual = blackListTokenRepository.existsByToken(블랙_리스트_토큰.getToken()); + + // then + assertThat(actual).isTrue(); + } + + @Test + void 블랙_리스트에_특정_토큰이_존재하지_않다면_거짓을_반환한다() { + // when + final boolean actual = blackListTokenRepository.existsByToken(존재하지_않는_토큰); + + // then + assertThat(actual).isFalse(); + } +} diff --git a/src/test/java/com/backend/blooming/authentication/infrastructure/blacklist/BlackListTokenRepositoryTestFixture.java b/src/test/java/com/backend/blooming/authentication/infrastructure/blacklist/BlackListTokenRepositoryTestFixture.java new file mode 100644 index 00000000..3ac8e7ee --- /dev/null +++ b/src/test/java/com/backend/blooming/authentication/infrastructure/blacklist/BlackListTokenRepositoryTestFixture.java @@ -0,0 +1,21 @@ +package com.backend.blooming.authentication.infrastructure.blacklist; + +import com.backend.blooming.authentication.domain.BlackListToken; +import org.junit.jupiter.api.BeforeEach; +import org.springframework.beans.factory.annotation.Autowired; + +@SuppressWarnings("NonAsciiCharacters") +public class BlackListTokenRepositoryTestFixture { + + @Autowired + private BlackListTokenRepository blackListTokenRepository; + + protected BlackListToken 블랙_리스트_토큰; + protected String 존재하지_않는_토큰 = "not exist token"; + + @BeforeEach + void setUpFixture() { + 블랙_리스트_토큰 = new BlackListToken("black list token"); + blackListTokenRepository.save(블랙_리스트_토큰); + } +} diff --git a/src/test/java/com/backend/blooming/authentication/presentation/AuthenticationControllerTest.java b/src/test/java/com/backend/blooming/authentication/presentation/AuthenticationControllerTest.java index a30a7288..a59f9fd6 100644 --- a/src/test/java/com/backend/blooming/authentication/presentation/AuthenticationControllerTest.java +++ b/src/test/java/com/backend/blooming/authentication/presentation/AuthenticationControllerTest.java @@ -1,9 +1,11 @@ package com.backend.blooming.authentication.presentation; import com.backend.blooming.authentication.application.AuthenticationService; +import com.backend.blooming.authentication.application.exception.AlreadyRegisterBlackListTokenException; import com.backend.blooming.authentication.infrastructure.exception.InvalidTokenException; import com.backend.blooming.authentication.infrastructure.exception.OAuthException; import com.backend.blooming.authentication.infrastructure.jwt.TokenProvider; +import com.backend.blooming.authentication.infrastructure.jwt.TokenType; import com.backend.blooming.authentication.presentation.argumentresolver.AuthenticatedThreadLocal; import com.backend.blooming.common.RestDocsConfiguration; import com.backend.blooming.user.infrastructure.repository.UserRepository; @@ -16,6 +18,7 @@ import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.context.annotation.Import; +import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.restdocs.mockmvc.RestDocumentationResultHandler; import org.springframework.restdocs.payload.JsonFieldType; @@ -23,20 +26,23 @@ import static org.hamcrest.Matchers.is; import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willDoNothing; +import static org.mockito.BDDMockito.willThrow; import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @WebMvcTest(AuthenticationController.class) @Import({RestDocsConfiguration.class, AuthenticatedThreadLocal.class}) -@MockBean({TokenProvider.class, UserRepository.class}) @AutoConfigureRestDocs @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) @SuppressWarnings("NonAsciiCharacters") @@ -48,6 +54,12 @@ class AuthenticationControllerTest extends AuthenticationControllerTestFixture { @MockBean private AuthenticationService authenticationService; + @MockBean + private UserRepository userRepository; + + @MockBean + private TokenProvider tokenProvider; + @Autowired private ObjectMapper objectMapper; @@ -195,4 +207,81 @@ class AuthenticationControllerTest extends AuthenticationControllerTestFixture { jsonPath("$.message").exists() ); } + + @Test + void 로그아웃을_수행한다() throws Exception { + // given + given(tokenProvider.parseToken(TokenType.ACCESS, 소셜_액세스_토큰)).willReturn(사용자_토큰_정보); + given(userRepository.existsByIdAndDeletedIsFalse(사용자_아이디)).willReturn(true); + willDoNothing().given(authenticationService).logout(사용자_아이디, 로그아웃_정보_dto); + + // when & then + mockMvc.perform(post("/auth/logout") + .header("X-API-VERSION", 1) + .header(HttpHeaders.AUTHORIZATION, 소셜_액세스_토큰) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(로그아웃_정보_요청)) + ).andExpectAll( + status().isNoContent() + ).andDo(print()).andDo( + restDocs.document( + requestHeaders( + headerWithName("X-API-VERSION").description("요청 버전"), + headerWithName(HttpHeaders.AUTHORIZATION).description("액세스 토큰") + ), + requestFields( + fieldWithPath("refreshToken").type(JsonFieldType.STRING).description("서비스 refresh token"), + fieldWithPath("deviceToken").type(JsonFieldType.STRING).description("서비스 device token") + ) + ) + ); + } + + @Test + void 로그아웃을_수행시_이미_로그인했다면_400_예외를_반환한다() throws Exception { + // given + given(tokenProvider.parseToken(TokenType.ACCESS, 소셜_액세스_토큰)).willReturn(사용자_토큰_정보); + given(userRepository.existsByIdAndDeletedIsFalse(사용자_아이디)).willReturn(true); + willThrow(new AlreadyRegisterBlackListTokenException()).given(authenticationService) + .logout(사용자_아이디, 로그아웃_정보_dto); + + // when & then + mockMvc.perform(post("/auth/logout") + .header("X-API-VERSION", 1) + .header(HttpHeaders.AUTHORIZATION, 소셜_액세스_토큰) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(로그아웃_정보_요청)) + ).andExpectAll( + status().isBadRequest(), + jsonPath("$.message").exists() + ).andDo(print()); + } + + @Test + void 탈퇴를_수행한다() throws Exception { + // given + given(tokenProvider.parseToken(TokenType.ACCESS, 소셜_액세스_토큰)).willReturn(사용자_토큰_정보); + given(userRepository.existsByIdAndDeletedIsFalse(사용자_아이디)).willReturn(true); + willDoNothing().given(authenticationService).withdraw(사용자_아이디, 서비스_refresh_token); + + // when & then + mockMvc.perform(delete("/auth") + .header("X-API-VERSION", 1) + .header(HttpHeaders.AUTHORIZATION, 소셜_액세스_토큰) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(탈퇴_정보_요청)) + ).andExpectAll( + status().isNoContent() + ).andDo(print()).andDo( + restDocs.document( + requestHeaders( + headerWithName("X-API-VERSION").description("요청 버전"), + headerWithName(HttpHeaders.AUTHORIZATION).description("액세스 토큰") + ), + requestFields( + fieldWithPath("refreshToken").type(JsonFieldType.STRING).description("서비스 refresh token") + ) + ) + ); + } } diff --git a/src/test/java/com/backend/blooming/authentication/presentation/AuthenticationControllerTestFixture.java b/src/test/java/com/backend/blooming/authentication/presentation/AuthenticationControllerTestFixture.java index 2bf7ecb5..683dd877 100644 --- a/src/test/java/com/backend/blooming/authentication/presentation/AuthenticationControllerTestFixture.java +++ b/src/test/java/com/backend/blooming/authentication/presentation/AuthenticationControllerTestFixture.java @@ -2,8 +2,12 @@ import com.backend.blooming.authentication.application.dto.LoginDto; import com.backend.blooming.authentication.application.dto.LoginInformationDto; +import com.backend.blooming.authentication.application.dto.LogoutDto; import com.backend.blooming.authentication.application.dto.TokenDto; +import com.backend.blooming.authentication.infrastructure.jwt.dto.AuthClaims; import com.backend.blooming.authentication.infrastructure.oauth.OAuthType; +import com.backend.blooming.authentication.presentation.dto.LogoutRequest; +import com.backend.blooming.authentication.presentation.dto.WithdrawRequest; import com.backend.blooming.authentication.presentation.dto.request.ReissueAccessTokenRequest; import com.backend.blooming.authentication.presentation.dto.response.SocialLoginRequest; @@ -11,8 +15,8 @@ public class AuthenticationControllerTestFixture { protected OAuthType oauth_타입 = OAuthType.KAKAO; - private String 소셜_액세스_토큰 = "social_access_token"; - private String 디바이스_토큰 = "device_token"; + protected String 소셜_액세스_토큰 = "social_access_token"; + protected String 디바이스_토큰 = "device_token"; protected LoginDto 로그인_정보 = LoginDto.of(소셜_액세스_토큰, 디바이스_토큰); private String 유효하지_않은_소셜_액세스_토큰 = "social_access_token"; protected LoginDto 유효하지_않은_소셜_액세스_토큰을_가진_로그인_정보 = LoginDto.of(유효하지_않은_소셜_액세스_토큰, 디바이스_토큰); @@ -28,5 +32,10 @@ public class AuthenticationControllerTestFixture { ); protected String 서비스_refresh_token = "blooming_refresh_token"; protected ReissueAccessTokenRequest access_token_재발급_요청 = new ReissueAccessTokenRequest(서비스_refresh_token); - protected TokenDto 서비스_토큰_정보 = new TokenDto("access token", "refresh token"); + protected TokenDto 서비스_토큰_정보 = new TokenDto(소셜_액세스_토큰, "refresh token"); + protected Long 사용자_아이디 = 1L; + protected AuthClaims 사용자_토큰_정보 = new AuthClaims(사용자_아이디); + protected LogoutRequest 로그아웃_정보_요청 = new LogoutRequest(서비스_refresh_token, 디바이스_토큰); + protected LogoutDto 로그아웃_정보_dto = new LogoutDto(서비스_refresh_token, 디바이스_토큰); + protected WithdrawRequest 탈퇴_정보_요청 = new WithdrawRequest(서비스_refresh_token); } diff --git a/src/test/java/com/backend/blooming/devicetoken/application/service/DeviceTokenServiceTest.java b/src/test/java/com/backend/blooming/devicetoken/application/service/DeviceTokenServiceTest.java index 89eabb09..6431823f 100644 --- a/src/test/java/com/backend/blooming/devicetoken/application/service/DeviceTokenServiceTest.java +++ b/src/test/java/com/backend/blooming/devicetoken/application/service/DeviceTokenServiceTest.java @@ -10,6 +10,8 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import java.util.List; + import static org.assertj.core.api.Assertions.assertThat; @IsolateDatabase @@ -26,7 +28,7 @@ class DeviceTokenServiceTest extends DeviceTokenServiceTestFixture { @Test void 디바이스_토큰을_저장한다() { // when - final Long actual = deviceTokenService.saveOrActive(사용자_아이디, 디바이스_토큰); + final Long actual = deviceTokenService.saveOrActivate(사용자_아이디, 디바이스_토큰); // then assertThat(actual).isPositive(); @@ -35,7 +37,7 @@ class DeviceTokenServiceTest extends DeviceTokenServiceTestFixture { @Test void 디바이스_토큰_저장시_비활성화된_동일한_토큰이_있다면_활성화한다() { // when - final Long actual = deviceTokenService.saveOrActive(사용자_아이디, 비활성화_디바이스_토큰.getToken()); + final Long actual = deviceTokenService.saveOrActivate(사용자_아이디, 비활성화_디바이스_토큰.getToken()); // then final DeviceToken deviceToken = deviceTokenRepository.findById(actual).get(); @@ -60,4 +62,24 @@ class DeviceTokenServiceTest extends DeviceTokenServiceTestFixture { softAssertions.assertThat(actual.deviceTokens().get(1).deviceToken()).isEqualTo(디바이스_토큰2.getToken()); }); } + + @Test + void 사용자의_특정_디바이스_토큰을_비활성화한다() { + // when + deviceTokenService.deactivate(사용자_아이디, 디바이스_토큰1.getToken()); + + // then + final DeviceToken actual = deviceTokenRepository.findByUserIdAndToken(사용자_아이디, 디바이스_토큰1.getToken()).get(); + assertThat(actual.isActive()).isFalse(); + } + + @Test + void 사용자의_모든_디바이스_토큰을_비활성화한다() { + // when + deviceTokenService.deactivateAllByUserId(사용자_아이디); + + // then + final List deviceTokens = deviceTokenRepository.findAllByUserIdAndActiveIsTrue(사용자_아이디); + assertThat(deviceTokens).isEmpty(); + } }