Skip to content

Commit

Permalink
logout시 refreshtoken이 삭제되는 api 구현 (#632)
Browse files Browse the repository at this point in the history
* feat: 로그아웃 API 구현

* refactor: refreshtoken delete api 이름 변경

* refactor: dev에 localhost:3000 cors 추가

* refactor: 리뷰반영 deleteMapping -> patchMapping으로 변경

* test: doPring 제거

* test: 인스턴스 변수 띄어쓰기 추가

* test: repositoryTestConfig에 persistMember 추가 및 리팩토링

* test: given when then 에서 컨벤션 일관화
  • Loading branch information
cookienc authored Oct 10, 2023
1 parent cca4347 commit 5809f4a
Show file tree
Hide file tree
Showing 21 changed files with 320 additions and 31 deletions.
2 changes: 1 addition & 1 deletion backend/baton/secret
25 changes: 25 additions & 0 deletions backend/baton/src/docs/asciidoc/OauthLogoutApi.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
ifndef::snippets[]
:snippets: ../../../build/generated-snippets
endif::[]
:doctype: book
:icons: font
:source-highlighter: highlight.js
:toc: left
:toclevels: 3
:sectlinks:
:operation-http-request-title: Example Request
:operation-http-response-title: Example Response

==== *로그아웃 API*

===== *Http Request*

include::{snippets}/../../build/generated-snippets/oauth-logout-api-test/logout/http-request.adoc[]

===== *Http Request Headers*

include::{snippets}/../../build/generated-snippets/oauth-logout-api-test/logout/request-headers.adoc[]

===== *Http Response*

include::{snippets}/../../build/generated-snippets/oauth-logout-api-test/logout/http-response.adoc[]
1 change: 1 addition & 0 deletions backend/baton/src/docs/asciidoc/index.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ include::GithubBranchCreateApi.adoc[]
== *[ 로그인 ]*

include::GithubOauthApi.adoc[]
include::OauthLogoutApi.adoc[]
include::RefreshTokenApi.adoc[]

== *[ 프로필 ]*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,21 @@
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import touch.baton.domain.common.exception.ClientErrorCode;
import touch.baton.domain.common.exception.ClientRequestException;
import touch.baton.domain.member.command.Member;
import touch.baton.domain.oauth.command.AuthorizationHeader;
import touch.baton.domain.oauth.command.OauthType;
import touch.baton.domain.oauth.command.service.OauthCommandService;
import touch.baton.domain.oauth.command.token.RefreshToken;
import touch.baton.domain.oauth.command.token.Tokens;
import touch.baton.domain.oauth.query.controller.resolver.AuthMemberPrincipal;

import java.io.IOException;
import java.time.Duration;
Expand Down Expand Up @@ -93,4 +96,11 @@ private void setCookie(final HttpServletResponse response, final RefreshToken re
.build();
response.addHeader("Set-Cookie", responseCookie.toString());
}

@PatchMapping("/logout")
public ResponseEntity<Void> logout(@AuthMemberPrincipal final Member member) {
oauthCommandService.logout(member);

return ResponseEntity.noContent().build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,6 @@ public interface RefreshTokenCommandRepository extends JpaRepository<RefreshToke
Optional<RefreshToken> findByToken(final Token token);

Optional<RefreshToken> findByMember(final Member member);

void deleteByMember(final Member member);
}
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ public class OauthCommandService {
public String readAuthCodeRedirect(final OauthType oauthType) {
return authCodeRequestUrlProviderComposite.findRequestUrl(oauthType);
}

public Tokens login(final OauthType oauthType, final String code) {
final OauthInformation oauthInformation = oauthInformationClientComposite.fetchInformation(oauthType, code);

Expand Down Expand Up @@ -125,7 +125,7 @@ private Tokens createTokens(final SocialId socialId, final Member member) {
refreshTokenCommandRepository.save(refreshToken);
return new Tokens(accessToken, refreshToken);
}

public Tokens reissueAccessToken(final AuthorizationHeader authHeader, final String refreshToken) {
final Claims claims = jwtDecoder.parseExpiredAuthorizationHeader(authHeader);
final SocialId socialId = new SocialId(claims.get("socialId", String.class));
Expand Down Expand Up @@ -159,4 +159,8 @@ private AccessToken createAccessToken(final SocialId socialId) {
);
return new AccessToken(jwtToken);
}

public void logout(final Member member) {
refreshTokenCommandRepository.deleteByMember(member);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,25 @@ public static ExtractableResponse<Response> get(final String uri, final String a
.extract();
}

public static ExtractableResponse<Response> patch(final String uri) {
return RestAssured
.given().log().ifValidationFails()
.when().log().ifValidationFails()
.patch(uri)
.then().log().ifError()
.extract();
}

public static ExtractableResponse<Response> patch(final String uri, final String accessToken) {
return RestAssured
.given().log().ifValidationFails()
.auth().preemptive().oauth2(accessToken)
.when().log().ifValidationFails()
.patch(uri)
.then().log().ifError()
.extract();
}

public static ExtractableResponse<Response> patch(final String uri, final String accessToken, final PathParams pathParams) {
return RestAssured
.given().log().ifValidationFails()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,18 @@ public static class OauthClientRequestBuilder {
return this;
}

public OauthClientRequestBuilder 로그아웃을_요청한다(final AccessToken 액세스_토큰) {
response = AssuredSupport.patch("/api/v1/oauth/logout", 액세스_토큰.getValue());

return this;
}

public OauthClientRequestBuilder 액세스_토큰_없이_로그아웃을_요청한다() {
response = AssuredSupport.patch("/api/v1/oauth/logout");

return this;
}

public OauthServerResponseBuilder 서버_응답() {
return new OauthServerResponseBuilder(response);
}
Expand Down Expand Up @@ -153,5 +165,11 @@ public OauthServerResponseBuilder(final ExtractableResponse<Response> response)
softly.assertThat(response.jsonPath().getString("errorCode")).isEqualTo(clientErrorCode.getErrorCode());
});
}

public void 로그아웃이_성공한다() {
assertSoftly(softly -> {
softly.assertThat(response.statusCode()).isEqualTo(HttpStatus.NO_CONTENT.value());
});
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package touch.baton.assure.oauth;

import org.junit.jupiter.api.Test;
import touch.baton.config.AssuredTestConfig;
import touch.baton.config.infra.auth.oauth.authcode.FakeAuthCodes;
import touch.baton.domain.common.exception.ClientErrorCode;
import touch.baton.domain.oauth.command.OauthType;
import touch.baton.domain.oauth.command.token.Tokens;
import touch.baton.fixture.domain.MemberFixture;

@SuppressWarnings("NonAsciiCharacters")
class OauthDeleteAssuredTest extends AssuredTestConfig {

@Test
void 로그아웃을_성공한다() {
// given
OauthAssuredSupport
.클라이언트_요청()
.소셜_로그인을_위한_리다이렉트_URL_요청한다(OauthType.GITHUB)

.서버_응답()
.소셜_로그인을_위한_리다이렉트_URL_요청_성공을_검증한다();

final Tokens 액세스_토큰과_리프레시_토큰 = OauthAssuredSupport
.클라이언트_요청()
.AuthCode_통해_소셜_토큰을_발급_받은__사용자를_회원_한다(OauthType.GITHUB, FakeAuthCodes.ethanAuthCode())

.서버_응답()
.AuthCode_통해_소셜_토큰_발급__사용자_회원입에_성공한다()
.액세스_토큰과_리프레시_토큰을_반환한다(MemberFixture.createEthan());

// when, then
OauthAssuredSupport
.클라이언트_요청()
.로그아웃을_요청한다(액세스_토큰과_리프레시_토큰.accessToken())

.서버_응답()
.로그아웃이_성공한다();
}

@Test
void 액세스_토큰이_없이_로그아웃을_요청하면_실패한다() {
// given
OauthAssuredSupport
.클라이언트_요청()
.소셜_로그인을_위한_리다이렉트_URL_요청한다(OauthType.GITHUB)

.서버_응답()
.소셜_로그인을_위한_리다이렉트_URL_요청_성공을_검증한다();

final Tokens 액세스_토큰과_리프레시_토큰 = OauthAssuredSupport
.클라이언트_요청()
.AuthCode_통해_소셜_토큰을_발급_받은__사용자를_회원_한다(OauthType.GITHUB, FakeAuthCodes.ethanAuthCode())

.서버_응답()
.AuthCode_통해_소셜_토큰_발급__사용자_회원입에_성공한다()
.액세스_토큰과_리프레시_토큰을_반환한다(MemberFixture.createEthan());

// when, then
OauthAssuredSupport
.클라이언트_요청()
.액세스_토큰_없이_로그아웃을_요청한다()

.서버_응답()
.오류가_발생한다(ClientErrorCode.OAUTH_AUTHORIZATION_VALUE_IS_NULL);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ public abstract class RepositoryTestConfig {
@Autowired
protected EntityManager em;

protected Member persistMember(final Member member) {
em.persist(member);
return member;
}

protected Runner persistRunner(final Member member) {
em.persist(member);
final Runner runner = RunnerFixture.createRunner(member);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package touch.baton.document.oauth;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import touch.baton.config.RestdocsConfig;
import touch.baton.domain.member.command.Member;
import touch.baton.fixture.domain.MemberFixture;

import java.util.Optional;

import static org.apache.http.HttpHeaders.AUTHORIZATION;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName;
import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders;
import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

class OauthLogoutApiTest extends RestdocsConfig {

@DisplayName("로그아웃을 하면 리프레시 토큰이 삭제된다.")
@Test
void logout() throws Exception {
// given, when
final Member ethan = MemberFixture.createEthan();
final String accessToken = getAccessTokenBySocialId(ethan.getSocialId().getValue());
given(oauthMemberCommandRepository.findBySocialId(any())).willReturn(Optional.ofNullable(ethan));

// then
mockMvc.perform(patch("/api/v1/oauth/logout")
.header(AUTHORIZATION, "Bearer " + accessToken))
.andExpect(status().isNoContent())
.andDo(restDocs.document(
requestHeaders(
headerWithName("Authorization").description("Access Token")
)
));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ class GithubOauthApiTest extends RestdocsConfig {
@DisplayName("Github 소셜 로그인을 위한 AuthCode 를 받을 수 있도록 사용자를 redirect 한다.")
@Test
void github_redirect_auth_code() throws Exception {
// given & when
// given, when
when(oauthCommandService.readAuthCodeRedirect(GITHUB))
.thenReturn("https://test-redirect-url.com");

Expand All @@ -58,7 +58,7 @@ void github_redirect_auth_code() throws Exception {
@DisplayName("Github 소셜 로그인을 위해 AuthCode 를 받아 SocialToken 으로 교환하여 Github 프로필 정보를 찾아오고 미가입 사용자일 경우 자동으로 회원가입을 진행하고 JWT 로 변환하여 클라이언트에게 넘겨준다.")
@Test
void github_login() throws Exception {
// given & when
// given, when
final RefreshToken refreshToken = RefreshToken.builder()
.member(mock(Member.class))
.token(new Token("mock refresh token"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ class RefreshTokenApiTest extends RestdocsConfig {
@DisplayName("만료된 jwt 토큰과 refresh token 으로 refresh 요청을 하면 새로운 토큰들이 반환된다.")
@Test
void refresh() throws Exception {
// given & when
// given, when
final RefreshToken refreshToken = RefreshToken.builder()
.token(new Token("refresh-token"))
.member(MemberFixture.createEthan())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@
import static org.springframework.restdocs.payload.JsonFieldType.STRING;
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static touch.baton.fixture.vo.TagNameFixture.tagName;
Expand All @@ -48,7 +47,6 @@ void readMyProfileByToken() throws Exception {
// then
mockMvc.perform(get("/api/v1/profile/runner/me")
.header(AUTHORIZATION, "Bearer " + token))
.andDo(print())
.andDo(restDocs.document(
requestHeaders(
headerWithName(AUTHORIZATION).description("Bearer JWT")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import java.util.Optional;

import static java.time.LocalDateTime.now;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.SoftAssertions.assertSoftly;
import static touch.baton.fixture.vo.ExpireDateFixture.expireDate;
import static touch.baton.fixture.vo.TokenFixture.token;
Expand All @@ -32,10 +33,8 @@ class RefreshTokenCommandRepositoryTest extends RepositoryTestConfig {
@Test
void findByToken() {
// given
final Member ethan = MemberFixture.createEthan();
final Member ditoo = MemberFixture.createDitoo();
em.persist(ethan);
em.persist(ditoo);
final Member ethan = persistMember(MemberFixture.createEthan());
final Member ditoo = persistMember(MemberFixture.createDitoo());

final LocalDateTime expireDate = createExpireDate(now().plusDays(30));

Expand All @@ -57,17 +56,15 @@ void findByToken() {
softly.assertThat(actual).isPresent();
softly.assertThat(actual.get().getToken()).isEqualTo(expected.getToken());
softly.assertThat(actual.get().getExpireDate()).isEqualTo(expected.getExpireDate());
} );
});
}

@DisplayName("리프레시 토큰을 사용자로 찾을 수 있다.")
@Test
void findByMember() {
// given
final Member owner = MemberFixture.createEthan();
final Member notOwner = MemberFixture.createDitoo();
em.persist(owner);
em.persist(notOwner);
final Member owner = persistMember(MemberFixture.createEthan());
final Member notOwner = persistMember(MemberFixture.createDitoo());

final LocalDateTime expireDate = createExpireDate(now().plusDays(30));

Expand All @@ -87,6 +84,27 @@ void findByMember() {
softly.assertThat(actual).isPresent();
softly.assertThat(actual.get().getToken()).isEqualTo(expected.getToken());
softly.assertThat(actual.get().getExpireDate()).isEqualTo(expected.getExpireDate());
} );
});
}

@DisplayName("사용자를 이용해 리프레시 토큰을 삭제할 수 있다.")
@Test
void logout() {
// given
final Member owner = persistMember(MemberFixture.createEthan());

final LocalDateTime expireDate = createExpireDate(now().plusDays(30));

final RefreshToken expected = RefreshTokenFixture.create(owner, token("ethan RefreshToken"), expireDate(expireDate));
em.persist(expected);

em.flush();
em.clear();

// when
refreshTokenCommandRepository.deleteByMember(owner);

// then
assertThat(refreshTokenCommandRepository.findByMember(owner)).isNotPresent();
}
}
Loading

0 comments on commit 5809f4a

Please sign in to comment.