diff --git a/app/api/src/main/java/me/chan99k/learningmanager/controller/auth/AuthController.java b/app/api/src/main/java/me/chan99k/learningmanager/controller/auth/AuthController.java index 0111b50b..e5dfe717 100644 --- a/app/api/src/main/java/me/chan99k/learningmanager/controller/auth/AuthController.java +++ b/app/api/src/main/java/me/chan99k/learningmanager/controller/auth/AuthController.java @@ -1,6 +1,7 @@ package me.chan99k.learningmanager.controller.auth; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -11,7 +12,9 @@ import jakarta.validation.Valid; import me.chan99k.learningmanager.authentication.IssueToken; import me.chan99k.learningmanager.authentication.RefreshAccessToken; +import me.chan99k.learningmanager.authentication.RevokeAllTokens; import me.chan99k.learningmanager.authentication.RevokeToken; +import me.chan99k.learningmanager.security.CustomUserDetails; @Tag(name = "Authentication", description = "인증 API") @RestController @@ -20,15 +23,18 @@ public class AuthController { private final IssueToken issueToken; private final RefreshAccessToken refreshAccessToken; private final RevokeToken revokeToken; + private final RevokeAllTokens revokeAllTokens; public AuthController( IssueToken issueToken, RefreshAccessToken refreshAccessToken, - RevokeToken revokeToken + RevokeToken revokeToken, + RevokeAllTokens revokeAllTokens ) { this.issueToken = issueToken; this.refreshAccessToken = refreshAccessToken; this.revokeToken = revokeToken; + this.revokeAllTokens = revokeAllTokens; } @Operation(summary = "로그인", description = "이메일과 비밀번호로 로그인하여 Access Token과 Refresh Token을 발급받습니다.") @@ -55,4 +61,13 @@ public ResponseEntity revokeToken( revokeToken.revoke(request); return ResponseEntity.ok().build(); } + + @Operation(summary = "전체 세션 로그아웃", description = "모든 기기에서 로그아웃합니다. 현재 사용자의 모든 Refresh Token을 폐기합니다.") + @PostMapping("/token/revoke-all") + public ResponseEntity revokeAllTokens( + @AuthenticationPrincipal CustomUserDetails user + ) { + revokeAllTokens.revokeAll(user.getMemberId()); + return ResponseEntity.ok().build(); + } } diff --git a/app/api/src/test/java/me/chan99k/learningmanager/adapter/web/auth/AuthControllerTest.java b/app/api/src/test/java/me/chan99k/learningmanager/adapter/web/auth/AuthControllerTest.java index 547fbc2b..0d42e129 100644 --- a/app/api/src/test/java/me/chan99k/learningmanager/adapter/web/auth/AuthControllerTest.java +++ b/app/api/src/test/java/me/chan99k/learningmanager/adapter/web/auth/AuthControllerTest.java @@ -6,6 +6,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -14,13 +15,16 @@ import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; import com.fasterxml.jackson.databind.ObjectMapper; +import me.chan99k.learningmanager.adapter.web.attendance.MockCustomUserDetailsArgumentResolver; import me.chan99k.learningmanager.authentication.AuthProblemCode; import me.chan99k.learningmanager.authentication.IssueToken; import me.chan99k.learningmanager.authentication.JwtProvider; import me.chan99k.learningmanager.authentication.RefreshAccessToken; +import me.chan99k.learningmanager.authentication.RevokeAllTokens; import me.chan99k.learningmanager.authentication.RevokeToken; import me.chan99k.learningmanager.controller.auth.AuthController; import me.chan99k.learningmanager.exception.DomainException; @@ -53,6 +57,9 @@ public class AuthControllerTest { @MockBean private RevokeToken revokeToken; + @MockBean + private RevokeAllTokens revokeAllTokens; + @MockBean private JwtProvider jwtProvider; @@ -63,7 +70,6 @@ class IssueTokenTest { @Test @DisplayName("[Success] 유효한 자격증명으로 토큰 발급 성공 - 200 OK") void issue_token_test_01() throws Exception { - // Given IssueToken.Request request = new IssueToken.Request(TEST_EMAIL, TEST_PASSWORD); IssueToken.Response response = IssueToken.Response.of( TEST_ACCESS_TOKEN, TEST_REFRESH_TOKEN, EXPIRES_IN_SECONDS @@ -72,7 +78,6 @@ void issue_token_test_01() throws Exception { given(issueToken.issueToken(any(IssueToken.Request.class))) .willReturn(response); - // When & Then mockMvc.perform(post("/api/v1/auth/token") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) @@ -88,10 +93,8 @@ void issue_token_test_01() throws Exception { @Test @DisplayName("[Failure] 이메일 누락 - 400 Bad Request") void issue_token_test_02() throws Exception { - // Given String requestJson = "{\"password\":\"" + TEST_PASSWORD + "\"}"; - // When & Then mockMvc.perform(post("/api/v1/auth/token") .contentType(MediaType.APPLICATION_JSON) .content(requestJson)) @@ -102,10 +105,8 @@ void issue_token_test_02() throws Exception { @Test @DisplayName("[Failure] 비밀번호 누락 - 400 Bad Request") void issue_token_test_03() throws Exception { - // Given String requestJson = "{\"email\":\"" + TEST_EMAIL + "\"}"; - // When & Then mockMvc.perform(post("/api/v1/auth/token") .contentType(MediaType.APPLICATION_JSON) .content(requestJson)) @@ -116,13 +117,11 @@ void issue_token_test_03() throws Exception { @Test @DisplayName("[Failure] 잘못된 자격증명 - 401 Unauthorized") void issue_token_test_04() throws Exception { - // Given IssueToken.Request request = new IssueToken.Request(TEST_EMAIL, "wrongPassword"); given(issueToken.issueToken(any(IssueToken.Request.class))) .willThrow(new DomainException(AuthProblemCode.INVALID_CREDENTIALS)); - // When & Then mockMvc.perform(post("/api/v1/auth/token") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) @@ -136,13 +135,11 @@ void issue_token_test_04() throws Exception { @Test @DisplayName("[Failure] 예기치 못한 서버 오류 - 500 Internal Server Error") void issue_token_test_05() throws Exception { - // Given IssueToken.Request request = new IssueToken.Request(TEST_EMAIL, TEST_PASSWORD); given(issueToken.issueToken(any(IssueToken.Request.class))) .willThrow(new RuntimeException("데이터베이스 연결 오류")); - // When & Then mockMvc.perform(post("/api/v1/auth/token") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) @@ -161,7 +158,6 @@ class RefreshTokenTest { @Test @DisplayName("[Success] 유효한 리프레시 토큰으로 갱신 성공 - 200 OK") void refresh_token_test_01() throws Exception { - // Given RefreshAccessToken.Request request = new RefreshAccessToken.Request(TEST_REFRESH_TOKEN); String newAccessToken = "new-access-token"; String newRefreshToken = "new-refresh-token"; @@ -172,7 +168,6 @@ void refresh_token_test_01() throws Exception { given(refreshAccessToken.refresh(any(RefreshAccessToken.Request.class))) .willReturn(response); - // When & Then mockMvc.perform(post("/api/v1/auth/token/refresh") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) @@ -188,10 +183,8 @@ void refresh_token_test_01() throws Exception { @Test @DisplayName("[Failure] 리프레시 토큰 누락 - 400 Bad Request") void refresh_token_test_02() throws Exception { - // Given String requestJson = "{}"; - // When & Then mockMvc.perform(post("/api/v1/auth/token/refresh") .contentType(MediaType.APPLICATION_JSON) .content(requestJson)) @@ -202,13 +195,11 @@ void refresh_token_test_02() throws Exception { @Test @DisplayName("[Failure] 만료된 토큰 - 401 Unauthorized") void refresh_token_test_03() throws Exception { - // Given RefreshAccessToken.Request request = new RefreshAccessToken.Request("expired-token"); given(refreshAccessToken.refresh(any(RefreshAccessToken.Request.class))) .willThrow(new DomainException(AuthProblemCode.EXPIRED_TOKEN)); - // When & Then mockMvc.perform(post("/api/v1/auth/token/refresh") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) @@ -222,13 +213,11 @@ void refresh_token_test_03() throws Exception { @Test @DisplayName("[Failure] 폐기된 토큰 - 401 Unauthorized") void refresh_token_test_04() throws Exception { - // Given RefreshAccessToken.Request request = new RefreshAccessToken.Request("revoked-token"); given(refreshAccessToken.refresh(any(RefreshAccessToken.Request.class))) .willThrow(new DomainException(AuthProblemCode.REVOKED_TOKEN)); - // When & Then mockMvc.perform(post("/api/v1/auth/token/refresh") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) @@ -242,13 +231,11 @@ void refresh_token_test_04() throws Exception { @Test @DisplayName("[Failure] 존재하지 않는 토큰 - 404 Not Found") void refresh_token_test_05() throws Exception { - // Given RefreshAccessToken.Request request = new RefreshAccessToken.Request("not-found-token"); given(refreshAccessToken.refresh(any(RefreshAccessToken.Request.class))) .willThrow(new DomainException(AuthProblemCode.TOKEN_NOT_FOUND)); - // When & Then mockMvc.perform(post("/api/v1/auth/token/refresh") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) @@ -267,12 +254,10 @@ class RevokeTokenTest { @Test @DisplayName("[Success] 토큰 폐기 성공 - 200 OK") void revoke_token_test_01() throws Exception { - // Given RevokeToken.Request request = new RevokeToken.Request(TEST_REFRESH_TOKEN, "refresh_token"); doNothing().when(revokeToken).revoke(any(RevokeToken.Request.class)); - // When & Then mockMvc.perform(post("/api/v1/auth/token/revoke") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) @@ -283,12 +268,10 @@ void revoke_token_test_01() throws Exception { @Test @DisplayName("[Success] tokenTypeHint 없이 토큰 폐기 성공 - 200 OK") void revoke_token_test_02() throws Exception { - // Given RevokeToken.Request request = new RevokeToken.Request(TEST_REFRESH_TOKEN); doNothing().when(revokeToken).revoke(any(RevokeToken.Request.class)); - // When & Then mockMvc.perform(post("/api/v1/auth/token/revoke") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) @@ -299,10 +282,8 @@ void revoke_token_test_02() throws Exception { @Test @DisplayName("[Failure] 토큰 누락 - 400 Bad Request") void revoke_token_test_03() throws Exception { - // Given String requestJson = "{}"; - // When & Then mockMvc.perform(post("/api/v1/auth/token/revoke") .contentType(MediaType.APPLICATION_JSON) .content(requestJson)) @@ -313,12 +294,11 @@ void revoke_token_test_03() throws Exception { @Test @DisplayName("[Success] 존재하지 않는 토큰도 성공 응답 - 200 OK (RFC 7009 권장)") void revoke_token_test_04() throws Exception { - // Given - RFC 7009에 따르면 존재하지 않는 토큰 폐기 요청도 200 OK 반환 + // RFC 7009 에 따르면 존재하지 않는 토큰 폐기 요청도 200 OK 반환 RevokeToken.Request request = new RevokeToken.Request("not-found-token"); doNothing().when(revokeToken).revoke(any(RevokeToken.Request.class)); - // When & Then mockMvc.perform(post("/api/v1/auth/token/revoke") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) @@ -326,4 +306,42 @@ void revoke_token_test_04() throws Exception { .andExpect(status().isOk()); } } + + @Nested + @DisplayName("전체 토큰 폐기 API 테스트") + class RevokeAllTokensTest { + + private MockMvc standaloneMockMvc; + + @BeforeEach + void setUp() { + // Security가 비활성화된 상태에서 @AuthenticationPrincipal을 테스트하기 위해 + // standaloneSetup과 MockCustomUserDetailsArgumentResolver를 사용 + standaloneMockMvc = MockMvcBuilders + .standaloneSetup(new AuthController(issueToken, refreshAccessToken, revokeToken, revokeAllTokens)) + .setCustomArgumentResolvers(new MockCustomUserDetailsArgumentResolver()) + .build(); + } + + @Test + @DisplayName("[Success] 인증된 사용자의 전체 토큰 폐기 성공 - 200 OK") + void revoke_all_tokens_test_01() throws Exception { + doNothing().when(revokeAllTokens).revokeAll(anyLong()); + + standaloneMockMvc.perform(post("/api/v1/auth/token/revoke-all")) + .andDo(print()) + .andExpect(status().isOk()); + + then(revokeAllTokens).should().revokeAll(123L); // MockCustomUserDetailsArgumentResolver의 기본 memberId + } + + @Test + @DisplayName("[Success] 전체 토큰 폐기 시 revokeAllTokens 서비스가 호출된다") + void revoke_all_tokens_test_02() throws Exception { + doNothing().when(revokeAllTokens).revokeAll(anyLong()); + standaloneMockMvc.perform(post("/api/v1/auth/token/revoke-all")) + .andExpect(status().isOk()); + then(revokeAllTokens).should().revokeAll(123L); + } + } } diff --git a/core/provides/src/main/java/me/chan99k/learningmanager/authentication/RevokeAllTokens.java b/core/provides/src/main/java/me/chan99k/learningmanager/authentication/RevokeAllTokens.java new file mode 100644 index 00000000..f9d03f60 --- /dev/null +++ b/core/provides/src/main/java/me/chan99k/learningmanager/authentication/RevokeAllTokens.java @@ -0,0 +1,6 @@ +package me.chan99k.learningmanager.authentication; + +public interface RevokeAllTokens { + + void revokeAll(Long memberId); +} diff --git a/core/service/src/main/java/me/chan99k/learningmanager/authentication/RevokeAllTokensService.java b/core/service/src/main/java/me/chan99k/learningmanager/authentication/RevokeAllTokensService.java new file mode 100644 index 00000000..21f92b75 --- /dev/null +++ b/core/service/src/main/java/me/chan99k/learningmanager/authentication/RevokeAllTokensService.java @@ -0,0 +1,20 @@ +package me.chan99k.learningmanager.authentication; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +public class RevokeAllTokensService implements RevokeAllTokens { + + private final RefreshTokenRepository refreshTokenRepository; + + public RevokeAllTokensService(RefreshTokenRepository refreshTokenRepository) { + this.refreshTokenRepository = refreshTokenRepository; + } + + @Override + public void revokeAll(Long memberId) { + refreshTokenRepository.revokeAllByMemberId(memberId); + } +} diff --git a/core/service/src/test/java/me/chan99k/learningmanager/authentication/RevokeAllTokensServiceTest.java b/core/service/src/test/java/me/chan99k/learningmanager/authentication/RevokeAllTokensServiceTest.java new file mode 100644 index 00000000..bf5131ca --- /dev/null +++ b/core/service/src/test/java/me/chan99k/learningmanager/authentication/RevokeAllTokensServiceTest.java @@ -0,0 +1,40 @@ +package me.chan99k.learningmanager.authentication; + +import static org.mockito.BDDMockito.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class RevokeAllTokensServiceTest { + + private static final Long MEMBER_ID = 1L; + + @Mock + RefreshTokenRepository refreshTokenRepository; + + RevokeAllTokensService revokeAllTokensService; + + @BeforeEach + void setUp() { + revokeAllTokensService = new RevokeAllTokensService(refreshTokenRepository); + } + + @Nested + @DisplayName("revokeAll 메서드") + class RevokeAllTest { + + @Test + @DisplayName("회원 ID로 전체 토큰 폐기를 요청하면 저장소의 revokeAllByMemberId를 호출한다") + void calls_revoke_all_by_member_id_on_repository() { + revokeAllTokensService.revokeAll(MEMBER_ID); + + then(refreshTokenRepository).should().revokeAllByMemberId(MEMBER_ID); + } + } +} \ No newline at end of file