Skip to content

Commit 151c9ad

Browse files
authored
refactor: accessToken 재발급을 refreshToken 쿠키를 통해 하도록 (#452)
* feat: 리프레시 토큰 추출 구현 * test: 중복 사용되는 변수를 상수로 추출 * test: 리프레시 토큰 추출 테스트 * refactor: 쿠키의 리프레시 토큰으로 액세스 토큰 재발급하도록 * chore: 사용하지 않는 요청 Dto 제거 * refactor: auth 토큰에 SiteUser가 사용됨을 명시, 중복코드 제거 - AS-IS: long으로 SiteUser를 조회, SiteUser를 Subject로 만들어 authTokenProvider의 함수에 넘겨줬다. - TO-BS: authToken에는 어차피 '의미상' SiteUser라는 개념이 사용될 수 밖에 없다. 따라서 관련 로직을 함수 내부로 옮긴다. 이로 인해서 중복 코드를 줄일수도 있다. * test: 테스트 코드에 반영 * refactor: 리프레시 토큰 값이 비어있는 경우도 예외 처리 * test: 비어있는 리프레시 토큰 테스트 케이스 추가 * style: style 적용 안된 채로 rebase한 것들 reformat
1 parent 07d7dcb commit 151c9ad

File tree

11 files changed

+158
-74
lines changed

11 files changed

+158
-74
lines changed

src/main/java/com/example/solidconnection/auth/controller/AuthController.java

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import com.example.solidconnection.auth.dto.EmailSignInRequest;
44
import com.example.solidconnection.auth.dto.EmailSignUpTokenRequest;
55
import com.example.solidconnection.auth.dto.EmailSignUpTokenResponse;
6-
import com.example.solidconnection.auth.dto.ReissueRequest;
76
import com.example.solidconnection.auth.dto.ReissueResponse;
87
import com.example.solidconnection.auth.dto.SignInResponse;
98
import com.example.solidconnection.auth.dto.SignUpRequest;
@@ -19,6 +18,7 @@
1918
import com.example.solidconnection.common.exception.ErrorCode;
2019
import com.example.solidconnection.common.resolver.AuthorizedUser;
2120
import com.example.solidconnection.siteuser.domain.AuthType;
21+
import jakarta.servlet.http.HttpServletRequest;
2222
import jakarta.servlet.http.HttpServletResponse;
2323
import jakarta.validation.Valid;
2424
import lombok.RequiredArgsConstructor;
@@ -118,10 +118,10 @@ public ResponseEntity<Void> quit(
118118

119119
@PostMapping("/reissue")
120120
public ResponseEntity<ReissueResponse> reissueToken(
121-
@AuthorizedUser long siteUserId,
122-
@Valid @RequestBody ReissueRequest reissueRequest
121+
HttpServletRequest request
123122
) {
124-
ReissueResponse reissueResponse = authService.reissue(siteUserId, reissueRequest);
123+
String refreshToken = refreshTokenCookieManager.getRefreshToken(request);
124+
ReissueResponse reissueResponse = authService.reissue(refreshToken);
125125
return ResponseEntity.ok(reissueResponse);
126126
}
127127

src/main/java/com/example/solidconnection/auth/controller/RefreshTokenCookieManager.java

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
package com.example.solidconnection.auth.controller;
22

3+
import static com.example.solidconnection.common.exception.ErrorCode.REFRESH_TOKEN_NOT_EXISTS;
4+
35
import com.example.solidconnection.auth.controller.config.RefreshTokenCookieProperties;
46
import com.example.solidconnection.auth.domain.TokenType;
7+
import com.example.solidconnection.common.exception.CustomException;
8+
import jakarta.servlet.http.Cookie;
9+
import jakarta.servlet.http.HttpServletRequest;
510
import jakarta.servlet.http.HttpServletResponse;
11+
import java.util.Arrays;
612
import lombok.RequiredArgsConstructor;
713
import org.springframework.http.HttpHeaders;
814
import org.springframework.http.ResponseCookie;
@@ -44,4 +50,26 @@ private void setRefreshTokenCookie(
4450
.build();
4551
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
4652
}
53+
54+
public String getRefreshToken(HttpServletRequest request) {
55+
// 쿠키가 없거나 비어있는 경우 예외 발생
56+
Cookie[] cookies = request.getCookies();
57+
if (cookies == null || cookies.length == 0) {
58+
throw new CustomException(REFRESH_TOKEN_NOT_EXISTS);
59+
}
60+
61+
// refreshToken 쿠키가 없는 경우 예외 발생
62+
Cookie refreshTokenCookie = Arrays.stream(cookies)
63+
.filter(cookie -> COOKIE_NAME.equals(cookie.getName()))
64+
.findFirst()
65+
.orElseThrow(() -> new CustomException(REFRESH_TOKEN_NOT_EXISTS));
66+
67+
// 쿠키 값이 비어있는 경우 예외 발생
68+
String refreshToken = refreshTokenCookie.getValue();
69+
if (refreshToken == null || refreshToken.isBlank()) {
70+
throw new CustomException(REFRESH_TOKEN_NOT_EXISTS);
71+
}
72+
return refreshToken;
73+
}
4774
}
75+

src/main/java/com/example/solidconnection/auth/dto/ReissueRequest.java

Lines changed: 0 additions & 9 deletions
This file was deleted.

src/main/java/com/example/solidconnection/auth/service/AuthService.java

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import static com.example.solidconnection.common.exception.ErrorCode.REFRESH_TOKEN_EXPIRED;
44
import static com.example.solidconnection.common.exception.ErrorCode.USER_NOT_FOUND;
55

6-
import com.example.solidconnection.auth.dto.ReissueRequest;
76
import com.example.solidconnection.auth.dto.ReissueResponse;
87
import com.example.solidconnection.auth.token.TokenBlackListService;
98
import com.example.solidconnection.common.exception.CustomException;
@@ -28,12 +27,8 @@ public class AuthService {
2827
* - 리프레시 토큰을 삭제한다.
2928
* */
3029
public void signOut(String token) {
31-
Subject subject = authTokenProvider.parseSubject(token);
32-
long siteUserId = Long.parseLong(subject.value());
33-
SiteUser siteUser = siteUserRepository.findById(siteUserId)
34-
.orElseThrow(() -> new CustomException(USER_NOT_FOUND));
35-
36-
AccessToken accessToken = authTokenProvider.generateAccessToken(subject, siteUser.getRole());
30+
SiteUser siteUser = authTokenProvider.parseSiteUser(token);
31+
AccessToken accessToken = authTokenProvider.generateAccessToken(siteUser);
3732
authTokenProvider.deleteRefreshTokenByAccessToken(accessToken);
3833
tokenBlackListService.addToBlacklist(accessToken);
3934
}
@@ -58,17 +53,14 @@ public void quit(long siteUserId, String token) {
5853
* - 유효한 리프레시토큰이면, 액세스 토큰을 재발급한다.
5954
* - 그렇지 않으면 예외를 발생시킨다.
6055
* */
61-
public ReissueResponse reissue(long siteUserId, ReissueRequest reissueRequest) {
56+
public ReissueResponse reissue(String requestedRefreshToken) {
6257
// 리프레시 토큰 확인
63-
String requestedRefreshToken = reissueRequest.refreshToken();
6458
if (!authTokenProvider.isValidRefreshToken(requestedRefreshToken)) {
6559
throw new CustomException(REFRESH_TOKEN_EXPIRED);
6660
}
6761
// 액세스 토큰 재발급
68-
SiteUser siteUser = siteUserRepository.findById(siteUserId)
69-
.orElseThrow(() -> new CustomException(USER_NOT_FOUND));
70-
Subject subject = authTokenProvider.parseSubject(requestedRefreshToken);
71-
AccessToken newAccessToken = authTokenProvider.generateAccessToken(subject, siteUser.getRole());
62+
SiteUser siteUser = authTokenProvider.parseSiteUser(requestedRefreshToken);
63+
AccessToken newAccessToken = authTokenProvider.generateAccessToken(siteUser);
7264
return ReissueResponse.from(newAccessToken);
7365
}
7466
}

src/main/java/com/example/solidconnection/auth/service/AuthTokenProvider.java

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
package com.example.solidconnection.auth.service;
22

3+
import static com.example.solidconnection.common.exception.ErrorCode.USER_NOT_FOUND;
4+
35
import com.example.solidconnection.auth.domain.TokenType;
6+
import com.example.solidconnection.common.exception.CustomException;
47
import com.example.solidconnection.siteuser.domain.Role;
58
import com.example.solidconnection.siteuser.domain.SiteUser;
9+
import com.example.solidconnection.siteuser.repository.SiteUserRepository;
610
import java.util.Map;
711
import java.util.Objects;
812
import lombok.RequiredArgsConstructor;
@@ -17,15 +21,21 @@ public class AuthTokenProvider {
1721

1822
private final RedisTemplate<String, String> redisTemplate;
1923
private final TokenProvider tokenProvider;
24+
private final SiteUserRepository siteUserRepository;
2025

21-
public AccessToken generateAccessToken(Subject subject, Role role) {
26+
public AccessToken generateAccessToken(SiteUser siteUser) {
27+
Subject subject = toSubject(siteUser);
28+
Role role = siteUser.getRole();
2229
String token = tokenProvider.generateToken(
23-
subject.value(), Map.of(ROLE_CLAIM_KEY, role.name()), TokenType.ACCESS
30+
subject.value(),
31+
Map.of(ROLE_CLAIM_KEY, role.name()),
32+
TokenType.ACCESS
2433
);
2534
return new AccessToken(subject, role, token);
2635
}
2736

28-
public RefreshToken generateAndSaveRefreshToken(Subject subject) {
37+
public RefreshToken generateAndSaveRefreshToken(SiteUser siteUser) {
38+
Subject subject = toSubject(siteUser);
2939
String token = tokenProvider.generateToken(subject.value(), TokenType.REFRESH);
3040
tokenProvider.saveToken(token, TokenType.REFRESH);
3141
return new RefreshToken(subject, token);
@@ -49,9 +59,11 @@ public void deleteRefreshTokenByAccessToken(AccessToken accessToken) {
4959
redisTemplate.delete(refreshTokenKey);
5060
}
5161

52-
public Subject parseSubject(String token) {
62+
public SiteUser parseSiteUser(String token) {
5363
String subject = tokenProvider.parseSubject(token);
54-
return new Subject(subject);
64+
long siteUserId = Long.parseLong(subject);
65+
return siteUserRepository.findById(siteUserId)
66+
.orElseThrow(() -> new CustomException(USER_NOT_FOUND));
5567
}
5668

5769
public Subject toSubject(SiteUser siteUser) {

src/main/java/com/example/solidconnection/auth/service/SignInService.java

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,8 @@ public class SignInService {
1515
@Transactional
1616
public SignInResponse signIn(SiteUser siteUser) {
1717
resetQuitedAt(siteUser);
18-
Subject subject = authTokenProvider.toSubject(siteUser);
19-
AccessToken accessToken = authTokenProvider.generateAccessToken(subject, siteUser.getRole());
20-
RefreshToken refreshToken = authTokenProvider.generateAndSaveRefreshToken(subject);
18+
AccessToken accessToken = authTokenProvider.generateAccessToken(siteUser);
19+
RefreshToken refreshToken = authTokenProvider.generateAndSaveRefreshToken(siteUser);
2120
return SignInResponse.of(accessToken, refreshToken);
2221
}
2322

src/main/java/com/example/solidconnection/common/exception/ErrorCode.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ public enum ErrorCode {
5656
ACCESS_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED.value(), "액세스 토큰이 만료되었습니다. 재발급 api를 호출해주세요."),
5757
REFRESH_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED.value(), "리프레시 토큰이 만료되었습니다. 다시 로그인을 진행해주세요."),
5858
ACCESS_DENIED(HttpStatus.FORBIDDEN.value(), "접근 권한이 없습니다."),
59+
REFRESH_TOKEN_NOT_EXISTS(HttpStatus.BAD_REQUEST.value(), "리프레시 토큰이 존재하지 않습니다."),
5960
PASSWORD_MISMATCH(HttpStatus.BAD_REQUEST.value(), "비밀번호가 일치하지 않습니다."),
6061
PASSWORD_NOT_CHANGED(HttpStatus.BAD_REQUEST.value(), "현재 비밀번호와 새 비밀번호가 동일합니다."),
6162
PASSWORD_NOT_CONFIRMED(HttpStatus.BAD_REQUEST.value(), "새 비밀번호가 일치하지 않습니다."),

src/test/java/com/example/solidconnection/auth/controller/RefreshTokenCookieManagerTest.java

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,33 @@
11
package com.example.solidconnection.auth.controller;
22

33
import static org.assertj.core.api.Assertions.assertThat;
4+
import static org.assertj.core.api.Assertions.assertThatCode;
45
import static org.junit.jupiter.api.Assertions.assertAll;
56
import static org.mockito.BDDMockito.given;
67

78
import com.example.solidconnection.auth.controller.config.RefreshTokenCookieProperties;
89
import com.example.solidconnection.auth.domain.TokenType;
10+
import com.example.solidconnection.common.exception.CustomException;
11+
import com.example.solidconnection.common.exception.ErrorCode;
912
import com.example.solidconnection.support.TestContainerSpringBootTest;
13+
import jakarta.servlet.http.Cookie;
1014
import org.junit.jupiter.api.BeforeEach;
1115
import org.junit.jupiter.api.DisplayName;
16+
import org.junit.jupiter.api.Nested;
1217
import org.junit.jupiter.api.Test;
18+
import org.junit.jupiter.params.ParameterizedTest;
19+
import org.junit.jupiter.params.provider.ValueSource;
1320
import org.springframework.beans.factory.annotation.Autowired;
1421
import org.springframework.boot.test.mock.mockito.MockBean;
22+
import org.springframework.mock.web.MockHttpServletRequest;
1523
import org.springframework.mock.web.MockHttpServletResponse;
1624

1725
@DisplayName("리프레시 토큰 쿠키 매니저 테스트")
1826
@TestContainerSpringBootTest
1927
class RefreshTokenCookieManagerTest {
2028

29+
private static final String REFRESH_TOKEN_COOKIE_NAME = "refreshToken";
30+
2131
@Autowired
2232
private RefreshTokenCookieManager cookieManager;
2333

@@ -46,7 +56,7 @@ void setUp() {
4656
String header = response.getHeader("Set-Cookie");
4757
assertAll(
4858
() -> assertThat(header).isNotNull(),
49-
() -> assertThat(header).contains("refreshToken=" + refreshToken),
59+
() -> assertThat(header).contains(REFRESH_TOKEN_COOKIE_NAME + "=" + refreshToken),
5060
() -> assertThat(header).contains("HttpOnly"),
5161
() -> assertThat(header).contains("Secure"),
5262
() -> assertThat(header).contains("Path=/"),
@@ -68,14 +78,67 @@ void setUp() {
6878
String header = response.getHeader("Set-Cookie");
6979
assertAll(
7080
() -> assertThat(header).isNotNull(),
71-
() -> assertThat(header).contains("refreshToken="),
81+
() -> assertThat(header).contains(REFRESH_TOKEN_COOKIE_NAME + "="),
7282
() -> assertThat(header).contains("HttpOnly"),
7383
() -> assertThat(header).contains("Secure"),
7484
() -> assertThat(header).contains("Path=/"),
7585
() -> assertThat(header).contains("Max-Age=0"),
76-
() -> assertThat(header).contains("SameSite=Strict"),
7786
() -> assertThat(header).contains("Domain=" + domain),
7887
() -> assertThat(header).contains("SameSite=" + sameSite)
7988
);
8089
}
90+
91+
@Nested
92+
class 쿠키에서_리프레시_토큰을_추출한다 {
93+
94+
@Test
95+
void 리프레시_토큰이_있으면_정상_반환한다() {
96+
// given
97+
MockHttpServletRequest request = new MockHttpServletRequest();
98+
String refreshToken = "test-refresh-token";
99+
request.setCookies(new Cookie(REFRESH_TOKEN_COOKIE_NAME, refreshToken));
100+
101+
// when
102+
String retrievedToken = cookieManager.getRefreshToken(request);
103+
104+
// then
105+
assertThat(retrievedToken).isEqualTo(refreshToken);
106+
}
107+
108+
@Test
109+
void 쿠키가_없으면_예외가_발생한다() {
110+
// given
111+
MockHttpServletRequest request = new MockHttpServletRequest();
112+
113+
// when & then
114+
assertThatCode(() -> cookieManager.getRefreshToken(request))
115+
.isInstanceOf(CustomException.class)
116+
.hasMessageContaining(ErrorCode.REFRESH_TOKEN_NOT_EXISTS.getMessage());
117+
}
118+
119+
@Test
120+
void 리프레시_토큰_쿠키가_없으면_예외가_발생한다() {
121+
// given
122+
MockHttpServletRequest request = new MockHttpServletRequest();
123+
request.setCookies(new Cookie("otherCookie", "some-value"));
124+
125+
// when & then
126+
assertThatCode(() -> cookieManager.getRefreshToken(request))
127+
.isInstanceOf(CustomException.class)
128+
.hasMessageContaining(ErrorCode.REFRESH_TOKEN_NOT_EXISTS.getMessage());
129+
}
130+
131+
@ParameterizedTest
132+
@ValueSource(strings = {"", " "})
133+
void 리프레시_토큰_쿠키가_비어있으면_예외가_발생한다(String token) {
134+
// given
135+
MockHttpServletRequest request = new MockHttpServletRequest();
136+
request.setCookies(new Cookie(REFRESH_TOKEN_COOKIE_NAME, token));
137+
138+
// when & then
139+
assertThatCode(() -> cookieManager.getRefreshToken(request))
140+
.isInstanceOf(CustomException.class)
141+
.hasMessageContaining(ErrorCode.REFRESH_TOKEN_NOT_EXISTS.getMessage());
142+
}
143+
}
81144
}

src/test/java/com/example/solidconnection/auth/service/AuthServiceTest.java

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
import static org.junit.jupiter.api.Assertions.assertAll;
77

88
import com.example.solidconnection.auth.domain.TokenType;
9-
import com.example.solidconnection.auth.dto.ReissueRequest;
109
import com.example.solidconnection.auth.dto.ReissueResponse;
1110
import com.example.solidconnection.auth.token.TokenBlackListService;
1211
import com.example.solidconnection.common.exception.CustomException;
@@ -45,14 +44,12 @@ class AuthServiceTest {
4544
private SiteUserRepository siteUserRepository;
4645

4746
private SiteUser siteUser;
48-
private Subject subject;
4947
private AccessToken accessToken;
5048

5149
@BeforeEach
5250
void setUp() {
5351
siteUser = siteUserFixture.사용자();
54-
subject = authTokenProvider.toSubject(siteUser);
55-
accessToken = authTokenProvider.generateAccessToken(subject, siteUser.getRole());
52+
accessToken = authTokenProvider.generateAccessToken(siteUser);
5653
}
5754

5855
@Test
@@ -61,7 +58,7 @@ void setUp() {
6158
authService.signOut(accessToken.token());
6259

6360
// then
64-
String refreshTokenKey = TokenType.REFRESH.addPrefix(subject.value());
61+
String refreshTokenKey = TokenType.REFRESH.addPrefix(accessToken.subject().value());
6562
assertAll(
6663
() -> assertThat(redisTemplate.opsForValue().get(refreshTokenKey)).isNull(),
6764
() -> assertThat(tokenBlackListService.isTokenBlacklisted(accessToken.token())).isTrue()
@@ -75,7 +72,7 @@ void setUp() {
7572

7673
// then
7774
LocalDate tomorrow = LocalDate.now().plusDays(1);
78-
String refreshTokenKey = TokenType.REFRESH.addPrefix(subject.value());
75+
String refreshTokenKey = TokenType.REFRESH.addPrefix(accessToken.subject().value());
7976
SiteUser actualSitUser = siteUserRepository.findById(siteUser.getId()).orElseThrow();
8077
assertAll(
8178
() -> assertThat(actualSitUser.getQuitedAt()).isEqualTo(tomorrow),
@@ -90,26 +87,24 @@ class 토큰을_재발급한다 {
9087
@Test
9188
void 요청의_리프레시_토큰이_저장되어_있으면_액세스_토큰을_재발급한다() {
9289
// given
93-
RefreshToken refreshToken = authTokenProvider.generateAndSaveRefreshToken(new Subject("subject"));
94-
ReissueRequest reissueRequest = new ReissueRequest(refreshToken.token());
90+
RefreshToken refreshToken = authTokenProvider.generateAndSaveRefreshToken(siteUser);
9591

9692
// when
97-
ReissueResponse reissuedAccessToken = authService.reissue(siteUser.getId(), reissueRequest);
93+
ReissueResponse reissuedAccessToken = authService.reissue(refreshToken.token());
9894

99-
// then - 요청의 리프레시 토큰과 재발급한 액세스 토큰의 subject 가 동일해야 한다.
100-
Subject expectedSubject = authTokenProvider.parseSubject(refreshToken.token());
101-
Subject actualSubject = authTokenProvider.parseSubject(reissuedAccessToken.accessToken());
102-
assertThat(actualSubject).isEqualTo(expectedSubject);
95+
// then - 요청의 리프레시 토큰과 재발급한 액세스 토큰의 주체가 동일해야 한다.
96+
SiteUser actualSiteUser = authTokenProvider.parseSiteUser(refreshToken.token());
97+
SiteUser expectedSiteUser = authTokenProvider.parseSiteUser(reissuedAccessToken.accessToken());
98+
assertThat(actualSiteUser.getId()).isEqualTo(expectedSiteUser.getId());
10399
}
104100

105101
@Test
106102
void 요청의_리프레시_토큰이_저장되어있지_않다면_예외가_발생한다() {
107103
// given
108104
String invalidRefreshToken = accessToken.token();
109-
ReissueRequest reissueRequest = new ReissueRequest(invalidRefreshToken);
110105

111106
// when, then
112-
assertThatCode(() -> authService.reissue(siteUser.getId(), reissueRequest))
107+
assertThatCode(() -> authService.reissue(invalidRefreshToken))
113108
.isInstanceOf(CustomException.class)
114109
.hasMessage(REFRESH_TOKEN_EXPIRED.getMessage());
115110
}

0 commit comments

Comments
 (0)