diff --git a/src/docs/asciidoc/auth.adoc b/src/docs/asciidoc/auth.adoc index 683a2657..bf0d05db 100644 --- a/src/docs/asciidoc/auth.adoc +++ b/src/docs/asciidoc/auth.adoc @@ -15,3 +15,8 @@ operation::auth-controller-test/reissue[snippets='http-request,curl-request,requ === `POST` 게스트 토큰 발급 operation::auth-controller-test/guest-token[snippets='http-request,curl-request,http-response,response-fields'] + +[[로그아웃]] +=== `POST` 로그아웃 + +operation::auth-controller-test/sign-out[snippets='http-request,curl-request,request-cookies,request-headers,http-response,response-cookies'] diff --git a/src/main/java/com/swyp8team2/auth/application/AuthService.java b/src/main/java/com/swyp8team2/auth/application/AuthService.java index d19a6203..24b8c3fc 100644 --- a/src/main/java/com/swyp8team2/auth/application/AuthService.java +++ b/src/main/java/com/swyp8team2/auth/application/AuthService.java @@ -1,7 +1,6 @@ package com.swyp8team2.auth.application; import com.swyp8team2.auth.application.jwt.JwtService; -import com.swyp8team2.auth.application.jwt.TokenPair; import com.swyp8team2.auth.application.oauth.OAuthService; import com.swyp8team2.auth.application.oauth.dto.OAuthUserInfo; import com.swyp8team2.auth.domain.Provider; @@ -61,4 +60,9 @@ public String createGuestToken() { Long guestId = userService.createGuest(); return cryptoService.encrypt(String.valueOf(guestId)); } + + @Transactional + public void signOut(Long userId, String refreshToken) { + jwtService.signOut(userId, refreshToken); + } } diff --git a/src/main/java/com/swyp8team2/auth/application/jwt/JwtService.java b/src/main/java/com/swyp8team2/auth/application/jwt/JwtService.java index b7963ca8..75228fbd 100644 --- a/src/main/java/com/swyp8team2/auth/application/jwt/JwtService.java +++ b/src/main/java/com/swyp8team2/auth/application/jwt/JwtService.java @@ -44,4 +44,15 @@ public TokenResponse reissue(String refreshToken) { claim.id(), tokenPair.accessToken(), tokenPair.refreshToken()); return new TokenResponse(tokenPair, claim.idAsLong()); } + + @Transactional + public void signOut(Long userId, String refreshToken) { + RefreshToken token = refreshTokenRepository.findByUserId(userId) + .orElseThrow(() -> new BadRequestException(ErrorCode.REFRESH_TOKEN_NOT_FOUND)); + + if (!token.getToken().equals(refreshToken)) { + throw new BadRequestException(ErrorCode.REFRESH_TOKEN_MISMATCHED); + } + refreshTokenRepository.delete(token); + } } diff --git a/src/main/java/com/swyp8team2/auth/domain/RefreshTokenRepository.java b/src/main/java/com/swyp8team2/auth/domain/RefreshTokenRepository.java index b406ecfa..625a1c74 100644 --- a/src/main/java/com/swyp8team2/auth/domain/RefreshTokenRepository.java +++ b/src/main/java/com/swyp8team2/auth/domain/RefreshTokenRepository.java @@ -6,4 +6,6 @@ public interface RefreshTokenRepository extends JpaRepository { Optional findByUserId(Long userId); + + void deleteByUserId(Long userId); } diff --git a/src/main/java/com/swyp8team2/auth/presentation/AuthController.java b/src/main/java/com/swyp8team2/auth/presentation/AuthController.java index 249f2a7d..f0c6eeb4 100644 --- a/src/main/java/com/swyp8team2/auth/presentation/AuthController.java +++ b/src/main/java/com/swyp8team2/auth/presentation/AuthController.java @@ -3,6 +3,7 @@ import com.swyp8team2.auth.application.AuthService; import com.swyp8team2.auth.application.jwt.TokenPair; +import com.swyp8team2.auth.domain.UserInfo; import com.swyp8team2.auth.presentation.dto.GuestTokenResponse; import com.swyp8team2.auth.presentation.dto.OAuthSignInRequest; import com.swyp8team2.auth.presentation.dto.TokenResponse; @@ -15,6 +16,7 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.CookieValue; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -63,4 +65,18 @@ public ResponseEntity guestToken() { String guestToken = authService.createGuestToken(); return ResponseEntity.ok(new GuestTokenResponse(guestToken)); } + + @PostMapping("/sign-out") + public ResponseEntity signOut( + @CookieValue(name = CustomHeader.CustomCookie.REFRESH_TOKEN, required = false) String refreshToken, + HttpServletResponse response, + @AuthenticationPrincipal UserInfo userInfo + ) { + if (Objects.isNull(refreshToken)) { + throw new BadRequestException(ErrorCode.INVALID_REFRESH_TOKEN_HEADER); + } + refreshTokenCookieGenerator.removeCookie(response); + authService.signOut(userInfo.userId(), refreshToken); + return ResponseEntity.ok().build(); + } } diff --git a/src/main/java/com/swyp8team2/auth/presentation/RefreshTokenCookieGenerator.java b/src/main/java/com/swyp8team2/auth/presentation/RefreshTokenCookieGenerator.java index 8e0c50f8..825df15d 100644 --- a/src/main/java/com/swyp8team2/auth/presentation/RefreshTokenCookieGenerator.java +++ b/src/main/java/com/swyp8team2/auth/presentation/RefreshTokenCookieGenerator.java @@ -2,6 +2,7 @@ import com.swyp8team2.common.presentation.CustomHeader; import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletResponse; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; @@ -24,4 +25,19 @@ public Cookie createCookie(String refreshToken) { cookie.setMaxAge(60 * 60 * 24 * 14); return cookie; } + + public void removeCookie(HttpServletResponse response) { + Cookie cookie = new Cookie(CustomHeader.CustomCookie.REFRESH_TOKEN, null); + cookie.setHttpOnly(true); + cookie.setSecure(true); + if ("local".equals(activeProfile)) { + cookie.setSecure(false); + } else { + cookie.setSecure(true); + cookie.setAttribute("SameSite", "None"); + } + cookie.setPath("/"); + cookie.setMaxAge(0); + response.addCookie(cookie); + } } diff --git a/src/main/java/com/swyp8team2/auth/presentation/filter/GuestAuthFilter.java b/src/main/java/com/swyp8team2/auth/presentation/filter/GuestAuthFilter.java index f1286b03..a7f6a006 100644 --- a/src/main/java/com/swyp8team2/auth/presentation/filter/GuestAuthFilter.java +++ b/src/main/java/com/swyp8team2/auth/presentation/filter/GuestAuthFilter.java @@ -50,6 +50,9 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse SecurityContextHolder.getContext().setAuthentication(authentication); } catch (ApplicationException e) { request.setAttribute(EXCEPTION_KEY, e); + } catch (Exception e) { + log.debug("GuestAuthFilter error", e); + request.setAttribute(EXCEPTION_KEY, new BadRequestException(ErrorCode.INVALID_TOKEN)); } finally { doFilter(request, response, filterChain); } diff --git a/src/main/java/com/swyp8team2/common/config/SecurityConfig.java b/src/main/java/com/swyp8team2/common/config/SecurityConfig.java index 0994c3fe..f283fa95 100644 --- a/src/main/java/com/swyp8team2/common/config/SecurityConfig.java +++ b/src/main/java/com/swyp8team2/common/config/SecurityConfig.java @@ -113,8 +113,7 @@ public static MvcRequestMatcher[] getWhiteList(HandlerMappingIntrospector intros mvc.pattern(HttpMethod.GET, "/posts/shareUrl/{shareUrl}"), mvc.pattern(HttpMethod.GET, "/posts/{postId}"), mvc.pattern(HttpMethod.GET, "/posts/{postId}/comments"), -// mvc.pattern("/posts/{postId}/votes/guest/**"), - mvc.pattern("/auth/oauth2/**") + mvc.pattern("/auth/oauth2/**"), }; } diff --git a/src/main/java/com/swyp8team2/crypto/application/CryptoService.java b/src/main/java/com/swyp8team2/crypto/application/CryptoService.java index 9803147d..4ff34182 100644 --- a/src/main/java/com/swyp8team2/crypto/application/CryptoService.java +++ b/src/main/java/com/swyp8team2/crypto/application/CryptoService.java @@ -7,6 +7,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.security.crypto.encrypt.AesBytesEncryptor; +import org.springframework.util.StringUtils; import java.nio.charset.StandardCharsets; @@ -28,6 +29,9 @@ public String encrypt(String data) { public String decrypt(String encryptedData) { try { + if (!StringUtils.hasText(encryptedData)) { + throw new InternalServerException(ErrorCode.INVALID_TOKEN); + } byte[] decryptBytes = Base62.createInstance().decode(encryptedData.getBytes(StandardCharsets.UTF_8)); byte[] decrypt = encryptor.decrypt(decryptBytes); return new String(decrypt, StandardCharsets.UTF_8); diff --git a/src/main/java/com/swyp8team2/post/application/PostService.java b/src/main/java/com/swyp8team2/post/application/PostService.java index 72ff11fa..f48b9c13 100644 --- a/src/main/java/com/swyp8team2/post/application/PostService.java +++ b/src/main/java/com/swyp8team2/post/application/PostService.java @@ -21,7 +21,6 @@ import com.swyp8team2.user.domain.UserRepository; import com.swyp8team2.vote.domain.Vote; import com.swyp8team2.vote.domain.VoteRepository; -import lombok.RequiredArgsConstructor; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Slice; import org.springframework.stereotype.Service; @@ -113,7 +112,7 @@ private Boolean getVoted(PostImage image, Long userId, Long postId) { .orElse(false); } - public CursorBasePaginatedResponse findMyPosts(Long userId, Long cursor, int size) { + public CursorBasePaginatedResponse findUserPosts(Long userId, Long cursor, int size) { Slice postSlice = postRepository.findByUserId(userId, cursor, PageRequest.ofSize(size)); return CursorBasePaginatedResponse.of(postSlice.map(this::createSimplePostResponse) ); diff --git a/src/main/java/com/swyp8team2/post/presentation/PostController.java b/src/main/java/com/swyp8team2/post/presentation/PostController.java index 52c1cb81..625897dd 100644 --- a/src/main/java/com/swyp8team2/post/presentation/PostController.java +++ b/src/main/java/com/swyp8team2/post/presentation/PostController.java @@ -88,21 +88,21 @@ public ResponseEntity deletePost( return ResponseEntity.ok().build(); } - @GetMapping("/user/me") + @GetMapping("/users/{userId}") public ResponseEntity> findMyPosts( + @PathVariable("userId") Long userId, @RequestParam(name = "cursor", required = false) @Min(0) Long cursor, - @RequestParam(name = "size", required = false, defaultValue = "10") @Min(1) int size, - @AuthenticationPrincipal UserInfo userInfo + @RequestParam(name = "size", required = false, defaultValue = "10") @Min(1) int size ) { - return ResponseEntity.ok(postService.findMyPosts(userInfo.userId(), cursor, size)); + return ResponseEntity.ok(postService.findUserPosts(userId, cursor, size)); } - @GetMapping("/user/voted") + @GetMapping("/users/{userId}/voted") public ResponseEntity> findVotedPosts( + @PathVariable("userId") Long userId, @RequestParam(name = "cursor", required = false) @Min(0) Long cursor, - @RequestParam(name = "size", required = false, defaultValue = "10") @Min(1) int size, - @AuthenticationPrincipal UserInfo userInfo + @RequestParam(name = "size", required = false, defaultValue = "10") @Min(1) int size ) { - return ResponseEntity.ok(postService.findVotedPosts(userInfo.userId(), cursor, size)); + return ResponseEntity.ok(postService.findVotedPosts(userId, cursor, size)); } } diff --git a/src/test/java/com/swyp8team2/auth/application/JwtServiceTest.java b/src/test/java/com/swyp8team2/auth/application/JwtServiceTest.java index bd08fc2f..40cefbdb 100644 --- a/src/test/java/com/swyp8team2/auth/application/JwtServiceTest.java +++ b/src/test/java/com/swyp8team2/auth/application/JwtServiceTest.java @@ -17,6 +17,7 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; class JwtServiceTest extends IntegrationTest { @@ -103,4 +104,33 @@ void reissue_refreshTokenMismatched() throws Exception { .isInstanceOf(BadRequestException.class) .hasMessage(ErrorCode.REFRESH_TOKEN_MISMATCHED.getMessage()); } + + @Test + @DisplayName("로그아웃하면 refresh token을 db에서 삭제해야 함") + void signOut() throws Exception { + //given + long givenUserId = 1L; + String givenRefreshToken = "refreshToken"; + refreshTokenRepository.save(new RefreshToken(givenUserId, givenRefreshToken)); + + //when + jwtService.signOut(givenUserId, givenRefreshToken); + + //then + assertThat(refreshTokenRepository.findByUserId(givenUserId)).isEmpty(); + } + + @Test + @DisplayName("로그아웃 - 유저의 refresh token이 아닌 경우") + void signOut_invalidRefreshToken() throws Exception { + //given + long givenUserId = 1L; + String givenRefreshToken = "refreshToken"; + refreshTokenRepository.save(new RefreshToken(givenUserId, givenRefreshToken)); + + //when then + assertThatThrownBy(() -> jwtService.signOut(givenUserId, "differentToken")) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.REFRESH_TOKEN_MISMATCHED.getMessage()); + } } diff --git a/src/test/java/com/swyp8team2/auth/presentation/AuthControllerTest.java b/src/test/java/com/swyp8team2/auth/presentation/AuthControllerTest.java index a9a9aa4f..60390dfb 100644 --- a/src/test/java/com/swyp8team2/auth/presentation/AuthControllerTest.java +++ b/src/test/java/com/swyp8team2/auth/presentation/AuthControllerTest.java @@ -11,19 +11,24 @@ import com.swyp8team2.common.exception.ErrorResponse; import com.swyp8team2.common.presentation.CustomHeader; import com.swyp8team2.support.RestDocsTest; +import com.swyp8team2.support.WithMockUserInfo; import jakarta.servlet.http.Cookie; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.security.test.context.support.WithAnonymousUser; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.ArgumentMatchers.nullable; import static org.mockito.BDDMockito.given; import static org.springframework.restdocs.cookies.CookieDocumentation.cookieWithName; import static org.springframework.restdocs.cookies.CookieDocumentation.requestCookies; import static org.springframework.restdocs.cookies.CookieDocumentation.responseCookies; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; @@ -170,4 +175,31 @@ void guestToken() throws Exception { ) )); } + + @Test + @WithMockUserInfo + @DisplayName("로그아웃") + void signOut() throws Exception { + //given + + //when then + mockMvc.perform(post("/auth/sign-out") + .cookie(new Cookie(CustomHeader.CustomCookie.REFRESH_TOKEN, "refreshToken")) + .header(HttpHeaders.AUTHORIZATION, "Bearer accessToken")) + .andExpect(status().isOk()) + .andExpect(cookie().httpOnly(CustomHeader.CustomCookie.REFRESH_TOKEN, true)) + .andExpect(cookie().path(CustomHeader.CustomCookie.REFRESH_TOKEN, "/")) + .andExpect(cookie().secure(CustomHeader.CustomCookie.REFRESH_TOKEN, true)) + .andExpect(cookie().attribute(CustomHeader.CustomCookie.REFRESH_TOKEN, "SameSite", "None")) + .andExpect(cookie().maxAge(CustomHeader.CustomCookie.REFRESH_TOKEN, 0)) + .andDo(restDocs.document( + requestHeaders(authorizationHeader()), + requestCookies( + cookieWithName(CustomHeader.CustomCookie.REFRESH_TOKEN).description("리프레시 토큰") + ), + responseCookies( + cookieWithName(CustomHeader.CustomCookie.REFRESH_TOKEN).description("리프레시 토큰") + ) + )); + } } diff --git a/src/test/java/com/swyp8team2/crypto/application/CryptoServiceTest.java b/src/test/java/com/swyp8team2/crypto/application/CryptoServiceTest.java index 90e26e0d..89b9fd1e 100644 --- a/src/test/java/com/swyp8team2/crypto/application/CryptoServiceTest.java +++ b/src/test/java/com/swyp8team2/crypto/application/CryptoServiceTest.java @@ -60,4 +60,16 @@ void decrypt_invalidToken() { .isInstanceOf(BadRequestException.class) .hasMessage(ErrorCode.INVALID_TOKEN.getMessage()); } + + @Test + @DisplayName("복호화 - empty string") + void decrypt_emptyString() { + // given + String invalid = ""; + + // when then + assertThatThrownBy(() -> cryptoService.decrypt(invalid)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.INVALID_TOKEN.getMessage()); + } } diff --git a/src/test/java/com/swyp8team2/post/application/PostServiceTest.java b/src/test/java/com/swyp8team2/post/application/PostServiceTest.java index 4d06c2f3..af97d0a1 100644 --- a/src/test/java/com/swyp8team2/post/application/PostServiceTest.java +++ b/src/test/java/com/swyp8team2/post/application/PostServiceTest.java @@ -25,7 +25,6 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; import java.util.ArrayList; import java.util.List; @@ -156,15 +155,15 @@ void findById() throws Exception { } @Test - @DisplayName("내가 작성한 게시글 조회 - 커서 null인 경우") - void findMyPosts() throws Exception { + @DisplayName("유저가 작성한 게시글 조회 - 커서 null인 경우") + void findUserPosts() throws Exception { //given User user = userRepository.save(createUser(1)); List posts = createPosts(user); int size = 10; //when - var response = postService.findMyPosts(user.getId(), null, size); + var response = postService.findUserPosts(user.getId(), null, size); //then assertAll( @@ -175,15 +174,15 @@ void findMyPosts() throws Exception { } @Test - @DisplayName("내가 작성한 게시글 조회 - 커서 있는 경우") - void findMyPosts2() throws Exception { + @DisplayName("유저가 작성한 게시글 조회 - 커서 있는 경우") + void findUserPosts2() throws Exception { //given User user = userRepository.save(createUser(1)); List posts = createPosts(user); int size = 10; //when - var response = postService.findMyPosts(user.getId(), posts.get(3).getId(), size); + var response = postService.findUserPosts(user.getId(), posts.get(3).getId(), size); //then assertAll( @@ -204,7 +203,7 @@ private List createPosts(User user) { } @Test - @DisplayName("내가 투표한 게시글 조회 - 커서 null인 경우") + @DisplayName("유저가 투표한 게시글 조회 - 커서 null인 경우") void findVotedPosts() throws Exception { //given User user = userRepository.save(createUser(1)); diff --git a/src/test/java/com/swyp8team2/post/presentation/PostControllerTest.java b/src/test/java/com/swyp8team2/post/presentation/PostControllerTest.java index aff536f4..befad202 100644 --- a/src/test/java/com/swyp8team2/post/presentation/PostControllerTest.java +++ b/src/test/java/com/swyp8team2/post/presentation/PostControllerTest.java @@ -248,7 +248,7 @@ void deletePost() throws Exception { @Test @WithMockUserInfo - @DisplayName("내가 작성한 게시글 조회") + @DisplayName("유저가 작성한 게시글 조회") void findMyPost() throws Exception { //given var response = new CursorBasePaginatedResponse<>( @@ -263,15 +263,16 @@ void findMyPost() throws Exception { ) ) ); - given(postService.findMyPosts(1L, null, 10)) + given(postService.findUserPosts(1L, null, 10)) .willReturn(response); //when then - mockMvc.perform(get("/posts/user/me") + mockMvc.perform(RestDocumentationRequestBuilders.get("/posts/users/{userId}", 1) .header(HttpHeaders.AUTHORIZATION, "Bearer token")) .andExpect(status().isOk()) .andExpect(content().json(objectMapper.writeValueAsString(response))) .andDo(restDocs.document( + pathParameters(parameterWithName("userId").description("유저 Id")), requestHeaders(authorizationHeader()), queryParameters(cursorQueryParams()), responseFields( @@ -303,7 +304,7 @@ void findMyPost() throws Exception { @Test @WithMockUserInfo - @DisplayName("내가 참여한 게시글 조회") + @DisplayName("유저가 참여한 게시글 조회") void findVotedPost() throws Exception { //given var response = new CursorBasePaginatedResponse<>( @@ -322,11 +323,12 @@ void findVotedPost() throws Exception { .willReturn(response); //when then - mockMvc.perform(get("/posts/user/voted") + mockMvc.perform(RestDocumentationRequestBuilders.get("/posts/users/{userId}/voted", 1) .header(HttpHeaders.AUTHORIZATION, "Bearer token")) .andExpect(status().isOk()) .andExpect(content().json(objectMapper.writeValueAsString(response))) .andDo(restDocs.document( + pathParameters(parameterWithName("userId").description("유저 Id")), requestHeaders(authorizationHeader()), queryParameters(cursorQueryParams()), responseFields(