diff --git a/api/src/main/java/dev/hooon/auth/AuthApiController.java b/api/src/main/java/dev/hooon/auth/AuthApiController.java index def6a417..d9f81d96 100644 --- a/api/src/main/java/dev/hooon/auth/AuthApiController.java +++ b/api/src/main/java/dev/hooon/auth/AuthApiController.java @@ -1,5 +1,6 @@ package dev.hooon.auth; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -10,9 +11,10 @@ import dev.hooon.auth.application.AuthService; import dev.hooon.auth.dto.TokenReIssueRequest; import dev.hooon.auth.dto.request.AuthRequest; - import dev.hooon.auth.dto.response.AuthResponse; +import dev.hooon.auth.jwt.JwtAuthorization; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; @@ -37,6 +39,16 @@ public ResponseEntity login( return ResponseEntity.ok(authResponse); } + @PostMapping("/logout") + @Operation(summary = "로그아웃 API", description = "로그아웃을 한다") + @ApiResponse(responseCode = "200", useReturnTypeSchema = true) + public ResponseEntity logout( + @Parameter(hidden = true) @JwtAuthorization Long userId + ) { + authService.logout(userId); + return ResponseEntity.ok(HttpStatus.OK); + } + @NoAuth @PostMapping("/token") @Operation(summary = "토큰 재발급 API", description = "토큰을 재발급한다") diff --git a/api/src/test/java/dev/hooon/auth/AuthApiControllerTest.java b/api/src/test/java/dev/hooon/auth/AuthApiControllerTest.java index 8089b577..39cc6cc6 100644 --- a/api/src/test/java/dev/hooon/auth/AuthApiControllerTest.java +++ b/api/src/test/java/dev/hooon/auth/AuthApiControllerTest.java @@ -26,19 +26,18 @@ class AuthApiControllerTest extends ApiTestSupport { private UserService userService; @Autowired private AuthService authService; + private AuthRequest authRequest; @BeforeEach void setUp() { UserJoinRequest userJoinRequest = new UserJoinRequest("user@example.com", "password123", "name123"); userService.join(userJoinRequest); + authRequest = new AuthRequest("user@example.com", "password123"); } @Test @DisplayName("[로그인 API를 호출하면 토큰이 응답된다]") void loginTest() throws Exception { - // given - AuthRequest authRequest = new AuthRequest("user@example.com", "password123"); - // when ResultActions actions = mockMvc.perform( post("/api/auth/login") @@ -56,7 +55,6 @@ void loginTest() throws Exception { @DisplayName("[토큰 재발급 API를 호출하면 새로운 엑세스 토큰이 응답된다]") void reIssueAccessTokenTest() throws Exception { // given - AuthRequest authRequest = new AuthRequest("user@example.com", "password123"); AuthResponse authResponse = authService.login(authRequest); String refreshToken = authResponse.refreshToken(); TokenReIssueRequest tokenReIssueRequest = new TokenReIssueRequest(refreshToken); @@ -72,4 +70,21 @@ void reIssueAccessTokenTest() throws Exception { actions.andExpect(status().isOk()) .andExpect(content().string(not(emptyOrNullString()))); } + + @Test + @DisplayName("[로그아웃 API를 호출하면 200 OK 응답이 반환된다]") + void logoutTest() throws Exception { + // given + AuthResponse authResponse = authService.login(authRequest); + String accessToken = authResponse.accessToken(); + + // when + ResultActions actions = mockMvc.perform( + post("/api/auth/logout") + .header("Authorization", accessToken) + ); + + // then + actions.andExpect(status().isOk()); + } } diff --git a/core/src/main/java/dev/hooon/auth/application/AuthService.java b/core/src/main/java/dev/hooon/auth/application/AuthService.java index bfa1fb4b..36498bfe 100644 --- a/core/src/main/java/dev/hooon/auth/application/AuthService.java +++ b/core/src/main/java/dev/hooon/auth/application/AuthService.java @@ -1,4 +1,3 @@ - package dev.hooon.auth.application; import static dev.hooon.auth.exception.AuthErrorCode.*; @@ -9,10 +8,13 @@ import org.springframework.transaction.annotation.Transactional; import dev.hooon.auth.domain.entity.Auth; +import dev.hooon.auth.domain.entity.BlacklistToken; import dev.hooon.auth.domain.repository.AuthRepository; +import dev.hooon.auth.domain.repository.BlacklistRepository; import dev.hooon.auth.dto.request.AuthRequest; import dev.hooon.auth.dto.response.AuthResponse; import dev.hooon.auth.entity.EncryptHelper; +import dev.hooon.auth.exception.AuthException; import dev.hooon.common.exception.NotFoundException; import dev.hooon.user.application.UserService; import lombok.RequiredArgsConstructor; @@ -25,6 +27,7 @@ public class AuthService { private final JwtProvider jwtProvider; private final AuthRepository authRepository; private final EncryptHelper encryptHelper; + private final BlacklistRepository blacklistRepository; private Auth getAuthByRefreshToken(String refreshToken) { return authRepository.findByRefreshToken(refreshToken) @@ -37,7 +40,7 @@ private AuthResponse saveAuth(Long userId) { Optional auth = authRepository.findByUserId(userId); auth.ifPresentOrElse( - (none) -> authRepository.updateRefreshToken(auth.get().getId(), refreshToken), + existingAuth -> authRepository.updateRefreshToken(existingAuth.getId(), refreshToken), () -> { Auth newAuth = Auth.of(userId, refreshToken); authRepository.save(newAuth); @@ -60,7 +63,23 @@ public AuthResponse login(AuthRequest authRequest) { throw new NotFoundException(FAILED_LOGIN_BY_ANYTHING); } + @Transactional + public void logout(Long userId) { + authRepository.findByUserId(userId).ifPresentOrElse( + auth -> + blacklistRepository.save(BlacklistToken.of(auth.getRefreshToken())), + () -> { + throw new NotFoundException(NOT_FOUND_USER_ID); + } + ); + } + public String createAccessTokenByRefreshToken(String refreshToken) { + boolean isBlacklisted = blacklistRepository.existsByRefreshToken(refreshToken); + if (isBlacklisted) { + throw new AuthException(BLACKLISTED_TOKEN); + } + Auth auth = getAuthByRefreshToken(refreshToken); Long userId = userService.getUserById(auth.getUserId()).getId(); return jwtProvider.createAccessToken(userId); diff --git a/core/src/main/java/dev/hooon/auth/domain/entity/Auth.java b/core/src/main/java/dev/hooon/auth/domain/entity/Auth.java index 92fe7afb..62f536f5 100644 --- a/core/src/main/java/dev/hooon/auth/domain/entity/Auth.java +++ b/core/src/main/java/dev/hooon/auth/domain/entity/Auth.java @@ -1,11 +1,8 @@ package dev.hooon.auth.domain.entity; -import static dev.hooon.common.exception.CommonValidationError.*; import static jakarta.persistence.GenerationType.*; -import org.springframework.util.Assert; - -import dev.hooon.user.domain.entity.UserRole; +import dev.hooon.common.entity.TimeBaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; @@ -18,7 +15,7 @@ @Getter @NoArgsConstructor @Table(name = "auth_table") -public class Auth { +public class Auth extends TimeBaseEntity { @Id @GeneratedValue(strategy = IDENTITY) diff --git a/core/src/main/java/dev/hooon/auth/domain/entity/BlacklistToken.java b/core/src/main/java/dev/hooon/auth/domain/entity/BlacklistToken.java new file mode 100644 index 00000000..0f926cc5 --- /dev/null +++ b/core/src/main/java/dev/hooon/auth/domain/entity/BlacklistToken.java @@ -0,0 +1,35 @@ +package dev.hooon.auth.domain.entity; + +import static jakarta.persistence.GenerationType.*; + +import dev.hooon.common.entity.TimeBaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +@Table(name = "blacklist_token_table") +public class BlacklistToken extends TimeBaseEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + @Column(name = "blacklist_token_id") + private Long id; + + @Column(name = "blacklist_token_refresh_token", nullable = false, unique = true) + private String refreshToken; + + private BlacklistToken(String refreshToken) { + this.refreshToken = refreshToken; + } + + public static BlacklistToken of(String refreshToken) { + return new BlacklistToken(refreshToken); + } +} diff --git a/core/src/main/java/dev/hooon/auth/domain/repository/BlacklistRepository.java b/core/src/main/java/dev/hooon/auth/domain/repository/BlacklistRepository.java new file mode 100644 index 00000000..e58d53ce --- /dev/null +++ b/core/src/main/java/dev/hooon/auth/domain/repository/BlacklistRepository.java @@ -0,0 +1,10 @@ +package dev.hooon.auth.domain.repository; + +import dev.hooon.auth.domain.entity.BlacklistToken; + +public interface BlacklistRepository { + + boolean existsByRefreshToken(String refreshToken); + + void save(BlacklistToken blacklistToken); +} diff --git a/core/src/main/java/dev/hooon/auth/exception/AuthErrorCode.java b/core/src/main/java/dev/hooon/auth/exception/AuthErrorCode.java index 687ee99f..dbaa0217 100644 --- a/core/src/main/java/dev/hooon/auth/exception/AuthErrorCode.java +++ b/core/src/main/java/dev/hooon/auth/exception/AuthErrorCode.java @@ -17,7 +17,8 @@ public enum AuthErrorCode implements ErrorCode { NOT_FOUND_USER_ID("해당 유저의 인증 데이터가 존재하지 않습니다.", "A_007"), TOKEN_EXPIRED("토큰이 만료 시간을 초과했습니다.", "A_008"), UNSUPPORTED_TOKEN("토큰 유형이 지원되지 않습니다.", "A_010"), - MALFORMED_TOKEN("토큰의 구조가 올바르지 않습니다.", "A_011"); + MALFORMED_TOKEN("토큰의 구조가 올바르지 않습니다.", "A_011"), + BLACKLISTED_TOKEN("해당 토큰은 블랙리스트에 등록되어있으므로 유효하지 않습니다.", "A_012"); private final String message; private final String code; diff --git a/core/src/main/java/dev/hooon/auth/infrastructure/BlacklistJpaRepository.java b/core/src/main/java/dev/hooon/auth/infrastructure/BlacklistJpaRepository.java new file mode 100644 index 00000000..a8e0c294 --- /dev/null +++ b/core/src/main/java/dev/hooon/auth/infrastructure/BlacklistJpaRepository.java @@ -0,0 +1,9 @@ +package dev.hooon.auth.infrastructure; + +import org.springframework.data.jpa.repository.JpaRepository; + +import dev.hooon.auth.domain.entity.BlacklistToken; + +public interface BlacklistJpaRepository extends JpaRepository { + boolean existsByRefreshToken(String refreshToken); +} diff --git a/core/src/main/java/dev/hooon/auth/infrastructure/adaptor/BlacklistRepositoryAdaptor.java b/core/src/main/java/dev/hooon/auth/infrastructure/adaptor/BlacklistRepositoryAdaptor.java new file mode 100644 index 00000000..b8ed2108 --- /dev/null +++ b/core/src/main/java/dev/hooon/auth/infrastructure/adaptor/BlacklistRepositoryAdaptor.java @@ -0,0 +1,25 @@ +package dev.hooon.auth.infrastructure.adaptor; + +import org.springframework.stereotype.Repository; + +import dev.hooon.auth.domain.entity.BlacklistToken; +import dev.hooon.auth.domain.repository.BlacklistRepository; +import dev.hooon.auth.infrastructure.BlacklistJpaRepository; +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class BlacklistRepositoryAdaptor implements BlacklistRepository { + + private final BlacklistJpaRepository blacklistJpaRepository; + + @Override + public boolean existsByRefreshToken(String refreshToken) { + return blacklistJpaRepository.existsByRefreshToken(refreshToken); + } + + @Override + public void save(BlacklistToken blacklistToken) { + blacklistJpaRepository.save(blacklistToken); + } +} diff --git a/core/src/test/java/dev/hooon/auth/application/AuthServiceTest.java b/core/src/test/java/dev/hooon/auth/application/AuthServiceTest.java index ea2f9a02..e43a44b9 100644 --- a/core/src/test/java/dev/hooon/auth/application/AuthServiceTest.java +++ b/core/src/test/java/dev/hooon/auth/application/AuthServiceTest.java @@ -1,5 +1,7 @@ package dev.hooon.auth.application; +import static dev.hooon.auth.exception.AuthErrorCode.*; +import static org.assertj.core.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; @@ -13,7 +15,9 @@ import org.mockito.junit.jupiter.MockitoExtension; import dev.hooon.auth.domain.entity.Auth; +import dev.hooon.auth.domain.entity.BlacklistToken; import dev.hooon.auth.domain.repository.AuthRepository; +import dev.hooon.auth.domain.repository.BlacklistRepository; import dev.hooon.auth.dto.request.AuthRequest; import dev.hooon.auth.dto.response.AuthResponse; import dev.hooon.auth.entity.EncryptHelper; @@ -36,6 +40,8 @@ class AuthServiceTest { private JwtProvider jwtProvider; @Mock private EncryptHelper encryptHelper; + @Mock + private BlacklistRepository blacklistRepository; @Test @DisplayName("[로그인 성공 시 토큰을 발급한다]") @@ -74,4 +80,32 @@ void loginFailTest() { // when & then assertThrows(NotFoundException.class, () -> authService.login(authRequest)); } + + @Test + @DisplayName("로그아웃 성공 시 블랙리스트에 토큰을 추가한다") + void logoutSuccessTest() { + // given + Long userId = 1L; + Auth auth = Auth.of(userId, "refresh-token"); + when(authRepository.findByUserId(userId)).thenReturn(Optional.of(auth)); + + // when + authService.logout(userId); + + // then + verify(blacklistRepository).save(any(BlacklistToken.class)); + } + + @Test + @DisplayName("로그아웃 실패 시 NotFoundException을 던진다") + void logoutFailTest() { + // given + Long userId = 1L; + when(authRepository.findByUserId(userId)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> authService.logout(userId)) + .isInstanceOf(NotFoundException.class) + .hasMessageContaining(NOT_FOUND_USER_ID.getMessage()); + } }