Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: RefreshToken 전환 #503

Merged
merged 2 commits into from
Oct 18, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
3 changes: 2 additions & 1 deletion backend/src/main/java/zipgo/auth/dto/AuthCredentials.java
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
package zipgo.auth.dto;

public record AuthCredentials(
Long id
Long id,
String refreshToken
) {

}
6 changes: 4 additions & 2 deletions backend/src/main/java/zipgo/auth/dto/LoginResponse.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@

public record LoginResponse(
String accessToken,
String refreshToken,
AuthResponse authResponse
) {

public static LoginResponse of(String token, Member member, List<Pet> pets) {
public static LoginResponse of(TokenDto tokenDto, Member member, List<Pet> pets) {
return new LoginResponse(
token,
tokenDto.accessToken(),
tokenDto.refreshToken(),
AuthResponse.of(member, pets)
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,7 @@
import java.util.List;

import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseCookie;
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.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
Expand All @@ -18,22 +16,17 @@
import zipgo.auth.dto.LoginResponse;
import zipgo.auth.dto.TokenDto;
import zipgo.auth.support.JwtProvider;
import zipgo.auth.support.RefreshTokenCookieProvider;
import zipgo.member.application.MemberQueryService;
import zipgo.member.domain.Member;
import zipgo.pet.application.PetQueryService;
import zipgo.pet.domain.Pet;

import static org.springframework.http.HttpHeaders.SET_COOKIE;
import static zipgo.auth.support.RefreshTokenCookieProvider.REFRESH_TOKEN;

@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {

private final JwtProvider jwtProvider;
private final RefreshTokenCookieProvider refreshTokenCookieProvider;
private final AuthServiceFacade authServiceFacade;
private final MemberQueryService memberQueryService;
private final PetQueryService petQueryService;
Expand All @@ -44,30 +37,26 @@ public ResponseEntity<LoginResponse> login(
@RequestParam("redirect-uri") String redirectUri
) {
TokenDto tokenDto = authServiceFacade.login(authCode, redirectUri);
ResponseCookie cookie = refreshTokenCookieProvider.createCookie(tokenDto.refreshToken());

String memberId = jwtProvider.getPayload(tokenDto.accessToken());
Member member = memberQueryService.findById(Long.valueOf(memberId));
List<Pet> pets = petQueryService.readMemberPets(member.getId());

return ResponseEntity.ok()
.header(SET_COOKIE, cookie.toString())
.body(LoginResponse.of(tokenDto.accessToken(), member, pets));
return ResponseEntity.ok(LoginResponse.of(tokenDto, member, pets));
}

@GetMapping("/refresh")
public ResponseEntity<AccessTokenResponse> renewTokens(@CookieValue(value = REFRESH_TOKEN) String refreshToken) {
String accessToken = authServiceFacade.renewAccessTokenBy(refreshToken);
public ResponseEntity<AccessTokenResponse> renewTokens(
@Auth AuthCredentials authCredentials
) {
String accessToken = authServiceFacade.renewAccessTokenBy(authCredentials.refreshToken());
return ResponseEntity.ok(AccessTokenResponse.from(accessToken));
}

@PostMapping("/logout")
public ResponseEntity<Void> logout(@Auth AuthCredentials authCredentials) {
authServiceFacade.logout(authCredentials.id());
ResponseCookie logoutCookie = refreshTokenCookieProvider.createLogoutCookie();
return ResponseEntity.ok()
.header(SET_COOKIE, logoutCookie.toString())
.build();
return ResponseEntity.noContent().build();
}

@GetMapping
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import zipgo.auth.dto.AuthCredentials;
import zipgo.auth.support.BearerTokenExtractor;
import zipgo.auth.support.JwtProvider;
import zipgo.auth.support.ZipgoTokenExtractor;

@Component
@RequiredArgsConstructor
Expand All @@ -33,10 +34,11 @@ public Object resolveArgument(
WebDataBinderFactory binderFactory
) {
HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
String token = BearerTokenExtractor.extract(Objects.requireNonNull(request));
String id = jwtProvider.getPayload(token);
String accessToken = BearerTokenExtractor.extract(Objects.requireNonNull(request));
String refreshToken = ZipgoTokenExtractor.extract(Objects.requireNonNull(request));

return new AuthCredentials(Long.valueOf(id));
String id = jwtProvider.getPayload(accessToken);
return new AuthCredentials(Long.valueOf(id), refreshToken);
}

}
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
package zipgo.auth.presentation;

import jakarta.servlet.http.HttpServletRequest;
import java.util.Objects;

import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.core.MethodParameter;
import org.springframework.stereotype.Component;
Expand All @@ -13,6 +14,7 @@
import zipgo.auth.exception.TokenInvalidException;
import zipgo.auth.support.BearerTokenExtractor;
import zipgo.auth.support.JwtProvider;
import zipgo.auth.support.ZipgoTokenExtractor;
import zipgo.common.logging.LoggingUtils;

import static org.springframework.http.HttpHeaders.AUTHORIZATION;
Expand All @@ -21,6 +23,8 @@
@RequiredArgsConstructor
public class OptionalJwtArgumentResolver implements HandlerMethodArgumentResolver {

private static final String ZIPGO_HEADER = "Refresh";

private final JwtProvider jwtProvider;

@Override
Expand All @@ -40,10 +44,14 @@ public Object resolveArgument(
if (request.getHeader(AUTHORIZATION) == null) {
return null;
}
if (request.getHeader(ZIPGO_HEADER) == null) {
return null;
}
try {
String token = BearerTokenExtractor.extract(Objects.requireNonNull(request));
String id = jwtProvider.getPayload(token);
return new AuthCredentials(Long.valueOf(id));
String accessToken = BearerTokenExtractor.extract(Objects.requireNonNull(request));
String refreshToken = ZipgoTokenExtractor.extract(Objects.requireNonNull(request));
String id = jwtProvider.getPayload(accessToken);
return new AuthCredentials(Long.valueOf(id), refreshToken);
} catch (TokenInvalidException e) {
LoggingUtils.warn(e);
return null;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package zipgo.auth.support;

import jakarta.servlet.http.HttpServletRequest;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import zipgo.auth.exception.TokenInvalidException;
import zipgo.auth.exception.TokenMissingException;

@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class ZipgoTokenExtractor {

private static final String ZIPGO_HEADER = "Refresh";
private static final String ZIPGO_TYPE = "Zipgo ";
private static final String ZIPGO_JWT_REGEX = "^Zipgo [A-Za-z0-9-_=]+\\.[A-Za-z0-9-_=]+\\.?[A-Za-z0-9-_.+/=]*$";

public static String extract(HttpServletRequest request) {
String authorization = request.getHeader(ZIPGO_HEADER);
validate(authorization);
return authorization.replace(ZIPGO_TYPE, "").trim();
}

private static void validate(String authorization) {
if (authorization == null) {
throw new TokenMissingException();
}
if (!authorization.matches(ZIPGO_JWT_REGEX)) {
throw new TokenInvalidException();
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@
@ExtendWith({MockitoExtension.class, RestDocumentationExtension.class})
class AuthControllerMockArgumentResolverTest {

private static final String REFRESH_HEADER = "Refresh";

private MockMvc mockMvc;
private HandlerMethodArgumentResolver mockArgumentResolver = mock(JwtMandatoryArgumentResolver.class);

Expand Down Expand Up @@ -73,15 +75,16 @@ void setUp(RestDocumentationContextProvider restDocumentationContextProvider) {
when(mockArgumentResolver.supportsParameter(any()))
.thenReturn(true);
when(mockArgumentResolver.resolveArgument(any(), any(), any(), any()))
.thenReturn(new AuthCredentials(1L));
.thenReturn(new AuthCredentials(1L, "asd1.asd2.asd3"));
when(memberQueryService.findById(1L))
.thenReturn(식별자_있는_멤버());
when(petQueryService.readMemberPets(1L))
.thenReturn(List.of(PetFixture.반려동물(식별자_있는_멤버(), 견종(대형견()))));

// when
var 요청 = mockMvc.perform(get("/auth")
.header(AUTHORIZATION, "Bearer 1a.2a.3b"))
.header(AUTHORIZATION, "Bearer 1a.2a.3b")
.header("Refresh", "Zipgo asd1.asd2.asd3"))
.andDo(문서_생성());

// then
Expand All @@ -97,7 +100,8 @@ void setUp(RestDocumentationContextProvider restDocumentationContextProvider) {
return MockMvcRestDocumentationWrapper.document("사용자 정보 확인",
문서_정보,
requestHeaders(
headerWithName(AUTHORIZATION).description("Bearer + accessToken")
headerWithName(AUTHORIZATION).description("Bearer + accessToken"),
headerWithName(REFRESH_HEADER).description("Zipgo + refreshToken")
),
responseFields(
fieldWithPath("id").description("사용자 식별자").type(JsonFieldType.NUMBER),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,6 @@ class AuthControllerMockTest extends MockMvcTest {
var 토큰 = TokenDto.of("accessTokenValue", "refreshTokenValue");
when(authServiceFacade.login("인가_코드", "리다이렉트 유알아이"))
.thenReturn(토큰);
var 리프레시_토큰_쿠키 = ResponseCookie.from("refreshToken", 토큰.refreshToken()).build();
when(refreshTokenCookieProvider.createCookie(토큰.refreshToken()))
.thenReturn(리프레시_토큰_쿠키);
when(jwtProvider.getPayload(토큰.accessToken()))
.thenReturn("1");
when(memberQueryService.findById(1L))
Expand All @@ -64,9 +61,6 @@ class AuthControllerMockTest extends MockMvcTest {
var 토큰 = TokenDto.of("accessTokenValue", "refreshTokenValue");
when(authServiceFacade.login("인가_코드", "리다이렉트 유알아이"))
.thenReturn(토큰);
var 리프레시_토큰_쿠키 = ResponseCookie.from("refreshToken", 토큰.refreshToken()).build();
when(refreshTokenCookieProvider.createCookie(토큰.refreshToken()))
.thenReturn(리프레시_토큰_쿠키);
when(jwtProvider.getPayload(토큰.accessToken()))
.thenReturn("1");
when(memberQueryService.findById(1L))
Expand Down Expand Up @@ -107,6 +101,7 @@ class AuthControllerMockTest extends MockMvcTest {
),
responseFields(
fieldWithPath("accessToken").description("accessToken").type(JsonFieldType.STRING),
fieldWithPath("refreshToken").description("refreshToken").type(JsonFieldType.STRING),
fieldWithPath("authResponse.id").description("사용자 식별자").type(JsonFieldType.NUMBER),
fieldWithPath("authResponse.name").description("사용자 이름").type(JsonFieldType.STRING),
fieldWithPath("authResponse.email").description("사용자 이메일").type(JsonFieldType.STRING),
Expand Down Expand Up @@ -134,6 +129,7 @@ class AuthControllerMockTest extends MockMvcTest {
),
responseFields(
fieldWithPath("accessToken").description("accessToken").type(JsonFieldType.STRING),
fieldWithPath("refreshToken").description("accessToken").type(JsonFieldType.STRING),
fieldWithPath("authResponse.id").description("사용자 식별자").type(JsonFieldType.NUMBER),
fieldWithPath("authResponse.name").description("사용자 이름").type(JsonFieldType.STRING),
fieldWithPath("authResponse.email").description("사용자 이메일").type(JsonFieldType.STRING),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,11 @@
import static com.epages.restdocs.apispec.RestAssuredRestDocumentationWrapper.document;
import static com.epages.restdocs.apispec.RestAssuredRestDocumentationWrapper.resourceDetails;
import static io.restassured.RestAssured.given;
import static org.springframework.http.HttpHeaders.SET_COOKIE;
import static org.springframework.http.HttpHeaders.AUTHORIZATION;
import static org.springframework.http.HttpStatus.FORBIDDEN;
import static org.springframework.http.HttpStatus.NO_CONTENT;
import static org.springframework.http.HttpStatus.OK;
import static org.springframework.http.HttpStatus.UNAUTHORIZED;
import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName;
import static org.springframework.restdocs.headers.HeaderDocumentation.responseHeaders;
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields;

Expand All @@ -36,15 +35,17 @@ class 토큰_갱신 {
@Test
void 엑세스_토큰을_갱신할_수_있다() {
// given
var 엑세스_토큰 = jwtProvider.createAccessToken(1L);
var 리프레시_토큰 = jwtProvider.createRefreshToken();
refreshTokenRepository.save(new RefreshToken(1L, 리프레시_토큰));

var 요청_준비 = given(spec)
.cookie("refreshToken", 리프레시_토큰)
.filter(토큰_갱신_성공_문서_생성());

// when
var 응답 = 요청_준비.when()
.header(AUTHORIZATION, "Bearer " + 엑세스_토큰)
.header("Refresh", "Zipgo " + 리프레시_토큰)
.get("/auth/refresh");

// then
Expand All @@ -55,13 +56,15 @@ class 토큰_갱신 {
@Test
void 실패하면_401_반환() {
var 토큰_생성기 = 유효기간_만료된_jwtProvider_생성();
var 유효기간_만료된_엑세스_토큰 = 토큰_생성기.createAccessToken(1L);
var 유효기간_만료된_리프레시_토큰 = 토큰_생성기.createRefreshToken();
var 요청_준비 = given(spec)
.cookie("refreshToken", 유효기간_만료된_리프레시_토큰)
.filter(토큰_갱신_실패_문서_생성());

// when
var 응답 = 요청_준비.when()
.header(AUTHORIZATION, "Bearer " + 유효기간_만료된_엑세스_토큰)
.header("Refresh", "Zipgo " + 유효기간_만료된_리프레시_토큰)
.get("/auth/refresh");

// then
Expand All @@ -88,30 +91,30 @@ class 로그아웃 {
void 로그아웃_성공() {
// given
var 엑세스_토큰 = jwtProvider.createAccessToken(1L);
var 리프레시_토큰 = jwtProvider.createRefreshToken();
var 요청_준비 = given(spec)
.header("Authorization", "Bearer " + 엑세스_토큰)
.header("Refresh", "Zipgo " + 리프레시_토큰)
.filter(로그아웃_성공_문서_생성());

// when
var 응답 = 요청_준비.when()
.post("/auth/logout");

// then
응답.then()
.cookie("refreshToken", "")
.statusCode(OK.value());
응답.then().statusCode(NO_CONTENT.value());
}

@Test
void 엑세스_토큰이_유효하지_않으면_로그아웃_실패() {
// given
var 요청_준비 = given(spec)
.header("Authorization", "Bearer " + "잘못된토큰이라네")
.header("Refresh", "Zipgo " + "잘못된토큰이라네")
.filter(로그아웃_실패_문서_생성());

// when
var 응답 = 요청_준비.when()
.post("/auth/logout");
var 응답 = 요청_준비.when().post("/auth/logout");

// then
응답.then().statusCode(FORBIDDEN.value());
Expand All @@ -134,10 +137,7 @@ class 로그아웃 {

private RestDocumentationFilter 로그아웃_성공_문서_생성() {
return document("로그아웃 성공", resourceDetails()
.summary("로그아웃"),
responseHeaders(
headerWithName(SET_COOKIE).description("로그아웃 리프레시 토큰 쿠키")
)
.summary("로그아웃")
);
}

Expand Down
Loading