Skip to content

Commit

Permalink
feat: #48 사용자 로그아웃, 탈퇴 기능 구현 (#48)
Browse files Browse the repository at this point in the history
* feat: 사용자의 특정 디바이스 토큰 비활성화 기능 추가

* feat: 블랙 리스트 토큰 및 조회 기능 추가

* feat: 블랙 리스트 토큰을 저장하는 기능 추가

* feat: 로그아웃 기능 서비스 추가

* feat: 로그아웃 기능 컨트로러 추가

* feat: 탈퇴 기능 서비스 구현

* feat: 탈퇴 기능 컨트롤러 구현

* feat: 이미 블랙 리스트에 등록된 토큰에 대한 예외 처리 추가

* test: import 문의 와일드 카드 제거

* style: 개행 추가
  • Loading branch information
JJ503 authored Feb 7, 2024
1 parent e204348 commit 46a1e6f
Show file tree
Hide file tree
Showing 22 changed files with 527 additions and 17 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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) {
Expand Down Expand Up @@ -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);
}
}

Expand All @@ -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());
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
}
Original file line number Diff line number Diff line change
@@ -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());
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,7 @@ public class AuthenticationWebMvcConfiguration implements WebMvcConfigurer {
@Override
public void addInterceptors(final InterceptorRegistry registry) {
registry.addInterceptor(authenticationInterceptor)
.addPathPatterns("/**")
.excludePathPatterns("/auth/**");
.addPathPatterns("/**");
}

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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<BlackListToken, Long> {

Optional<BlackListToken> findByToken(final String token);

boolean existsByToken(final String token);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,21 @@
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;
import com.backend.blooming.authentication.presentation.dto.response.TokenResponse;
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;
Expand Down Expand Up @@ -45,4 +51,26 @@ public ResponseEntity<TokenResponse> reissueAccessToken(

return ResponseEntity.ok(TokenResponse.from(tokenDto));
}

@PostMapping(value = "/logout", headers = "X-API-VERSION=1")
public ResponseEntity<Void> 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<Void> withdraw(
@Authenticated final AuthenticatedUser authenticatedUser,
@RequestBody final WithdrawRequest withdrawRequest
) {
authenticationService.withdraw(authenticatedUser.userId(), withdrawRequest.refreshToken());

return ResponseEntity.noContent()
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package com.backend.blooming.authentication.presentation.dto;

public record LogoutRequest(String refreshToken, String deviceToken) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package com.backend.blooming.authentication.presentation.dto;

public record WithdrawRequest(String refreshToken) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.Optional;

@Service
@Transactional
Expand All @@ -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);

Expand Down Expand Up @@ -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> deviceToken = deviceTokenRepository.findByUserIdAndToken(userId, token);
deviceToken.ifPresent(DeviceToken::deactivate);
}

public void deactivateAllByUserId(final Long userId) {
final List<DeviceToken> deviceTokens = deviceTokenRepository.findAllByUserIdAndActiveIsTrue(userId);
deviceTokens.forEach(DeviceToken::deactivate);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ public enum ExceptionMessage {
EXPIRED_TOKEN("기한이 만료된 토큰입니다."),
UNSUPPORTED_OAUTH_TYPE("지원하지 않는 소셜 로그인 방식입니다."),

// 블랙 리스트 토큰
ALREADY_REGISTER_BLACK_LIST_TOKEN("이미 등록된 블랙 리스트 토큰입니다."),

// 사용자
NOT_FOUND_USER("사용자를 조회할 수 없습니다."),
NULL_OR_EMPTY_EMAIL("이메일은 비어있을 수 없습니다."),
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -170,4 +171,14 @@ public ResponseEntity<ExceptionResponse> handleDeleteFriendForbiddenException(
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(new ExceptionResponse(exception.getMessage()));
}

@ExceptionHandler(AlreadyRegisterBlackListTokenException.class)
public ResponseEntity<ExceptionResponse> 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()));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -34,6 +41,12 @@ class AuthenticationServiceTest extends AuthenticationServiceTestFixture {
@Autowired
private UserRepository userRepository;

@Autowired
private DeviceTokenRepository deviceTokenRepository;

@Autowired
private BlackListTokenRepository blackListTokenRepository;

@Test
void 로그인시_존재하지_않는_사용자인_경우_해당_사용자를_저장후_토큰_정보를_반환한다() {
// given
Expand Down Expand Up @@ -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<DeviceToken> 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);
}
}
Loading

0 comments on commit 46a1e6f

Please sign in to comment.