Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/docs/asciidoc/auth.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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);
}
}
11 changes: 11 additions & 0 deletions src/main/java/com/swyp8team2/auth/application/jwt/JwtService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,6 @@

public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
Optional<RefreshToken> findByUserId(Long userId);

void deleteByUserId(Long userId);
}
16 changes: 16 additions & 0 deletions src/main/java/com/swyp8team2/auth/presentation/AuthController.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -63,4 +65,18 @@ public ResponseEntity<GuestTokenResponse> guestToken() {
String guestToken = authService.createGuestToken();
return ResponseEntity.ok(new GuestTokenResponse(guestToken));
}

@PostMapping("/sign-out")
public ResponseEntity<Void> 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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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/**"),
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -113,7 +112,7 @@ private Boolean getVoted(PostImage image, Long userId, Long postId) {
.orElse(false);
}

public CursorBasePaginatedResponse<SimplePostResponse> findMyPosts(Long userId, Long cursor, int size) {
public CursorBasePaginatedResponse<SimplePostResponse> findUserPosts(Long userId, Long cursor, int size) {
Slice<Post> postSlice = postRepository.findByUserId(userId, cursor, PageRequest.ofSize(size));
return CursorBasePaginatedResponse.of(postSlice.map(this::createSimplePostResponse)
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,21 +88,21 @@ public ResponseEntity<PostResponse> deletePost(
return ResponseEntity.ok().build();
}

@GetMapping("/user/me")
@GetMapping("/users/{userId}")
public ResponseEntity<CursorBasePaginatedResponse<SimplePostResponse>> 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<CursorBasePaginatedResponse<SimplePostResponse>> 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));
}
}
30 changes: 30 additions & 0 deletions src/test/java/com/swyp8team2/auth/application/JwtServiceTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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("리프레시 토큰")
)
));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Post> 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(
Expand All @@ -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<Post> 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(
Expand All @@ -204,7 +203,7 @@ private List<Post> createPosts(User user) {
}

@Test
@DisplayName("내가 투표한 게시글 조회 - 커서 null인 경우")
@DisplayName("유저가 투표한 게시글 조회 - 커서 null인 경우")
void findVotedPosts() throws Exception {
//given
User user = userRepository.save(createUser(1));
Expand Down
Loading