Skip to content

Commit ef03b63

Browse files
committed
feat: Refresh Token 기반 토큰 재발급 API 추가 (#55)
- JWT 검증 파이프라인 최적화
1 parent 7b23745 commit ef03b63

9 files changed

Lines changed: 299 additions & 31 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package com.sofa.linkiving.domain.auth.controller;
2+
3+
import com.sofa.linkiving.global.common.BaseResponse;
4+
5+
import io.swagger.v3.oas.annotations.Operation;
6+
import io.swagger.v3.oas.annotations.Parameter;
7+
import io.swagger.v3.oas.annotations.tags.Tag;
8+
import jakarta.servlet.http.HttpServletRequest;
9+
import jakarta.servlet.http.HttpServletResponse;
10+
11+
@Tag(name = "Auth", description = "인증 및 토큰 관리 API")
12+
public interface AuthApi {
13+
@Operation(summary = "토큰 재발급", description = "Refresh Token을 검증하여 Access/Refresh Token을 재발급합니다.")
14+
BaseResponse<Void> reissue(
15+
@Parameter(description = "리프레시 토큰") String refreshToken,
16+
HttpServletRequest request,
17+
HttpServletResponse response
18+
);
19+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package com.sofa.linkiving.domain.auth.controller;
2+
3+
import org.springframework.web.bind.annotation.CookieValue;
4+
import org.springframework.web.bind.annotation.PostMapping;
5+
import org.springframework.web.bind.annotation.RequestMapping;
6+
import org.springframework.web.bind.annotation.RestController;
7+
8+
import com.sofa.linkiving.domain.auth.dto.internal.TokenDto;
9+
import com.sofa.linkiving.domain.auth.service.AuthService;
10+
import com.sofa.linkiving.global.common.BaseResponse;
11+
import com.sofa.linkiving.global.util.CookieUtils;
12+
13+
import jakarta.servlet.http.HttpServletRequest;
14+
import jakarta.servlet.http.HttpServletResponse;
15+
import lombok.RequiredArgsConstructor;
16+
17+
@RestController
18+
@RequestMapping("/v1/auth")
19+
@RequiredArgsConstructor
20+
public class AuthController implements AuthApi {
21+
private final AuthService authService;
22+
private final CookieUtils cookieUtils;
23+
24+
@Override
25+
@PostMapping("/reissue")
26+
public BaseResponse<Void> reissue(
27+
@CookieValue(value = "refreshToken", required = false) String refreshToken,
28+
HttpServletRequest request,
29+
HttpServletResponse response
30+
) {
31+
TokenDto newTokens = authService.reissue(refreshToken);
32+
33+
cookieUtils.addCookie(request, response, "accessToken", newTokens.accessToken(), newTokens.accessExp());
34+
cookieUtils.addCookie(request, response, "refreshToken", newTokens.refreshToken(), newTokens.refreshExp());
35+
36+
return BaseResponse.noContent("토큰 재발급 완료");
37+
}
38+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package com.sofa.linkiving.domain.auth.dto.internal;
2+
3+
public record TokenDto(
4+
String accessToken,
5+
int accessExp,
6+
String refreshToken,
7+
int refreshExp
8+
) {
9+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package com.sofa.linkiving.domain.auth.service;
2+
3+
import org.springframework.stereotype.Service;
4+
5+
import com.sofa.linkiving.domain.auth.dto.internal.TokenDto;
6+
import com.sofa.linkiving.security.jwt.JwtProperties;
7+
import com.sofa.linkiving.security.jwt.JwtTokenProvider;
8+
9+
import lombok.RequiredArgsConstructor;
10+
11+
@Service
12+
@RequiredArgsConstructor
13+
public class AuthService {
14+
private final JwtTokenProvider jwtTokenProvider;
15+
private final JwtProperties jwtProperties;
16+
17+
public TokenDto reissue(String refreshToken) {
18+
19+
String email = jwtTokenProvider.validateRefreshToken(refreshToken);
20+
21+
String newAccessToken = jwtTokenProvider.createAccessToken(email);
22+
String newRefreshToken = jwtTokenProvider.createRefreshToken(email);
23+
24+
int accessExp = (int)(jwtProperties.accessTokenValidTime() / 1000);
25+
int refreshExp = (int)(jwtProperties.refreshTokenValidTime() / 1000);
26+
27+
return new TokenDto(newAccessToken, accessExp, newRefreshToken, refreshExp);
28+
}
29+
}

src/main/java/com/sofa/linkiving/security/auth/config/SecurityConstants.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,10 @@ public abstract class SecurityConstants {
2222
"/v1/member/signup", "/v1/member/login", "/mock/**",
2323

2424
/* oauth2 */
25-
"/oauth2/**"
25+
"/oauth2/**",
26+
27+
/* auth */
28+
"/v1/auth/reissue"
2629
};
2730

2831
private static final String[] SEMI_PERMIT_URLS = {

src/main/java/com/sofa/linkiving/security/jwt/JwtTokenProvider.java

Lines changed: 22 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -114,53 +114,49 @@ public String resolveToken(HttpServletRequest request) {
114114
return null;
115115
}
116116

117-
public Date getExpiration(String token) {
118-
return parseClaims(token).getExpiration();
119-
}
117+
private Claims validateToken(String token) {
118+
if (token == null || token.isBlank()) {
119+
throw new CustomJwtException(JwtErrorCode.EMPTY_TOKEN);
120+
}
120121

121-
public String getUserIdFromToken(String token) {
122-
return parseClaims(token).getSubject();
122+
try {
123+
return parseClaims(token);
124+
} catch (ExpiredJwtException e) {
125+
throw new CustomJwtException(JwtErrorCode.EXPIRED_JWT_TOKEN);
126+
} catch (SecurityException | MalformedJwtException | IllegalArgumentException e) {
127+
throw new CustomJwtException(JwtErrorCode.INVALID_JWT_TOKEN);
128+
} catch (UnsupportedJwtException e) {
129+
throw new CustomJwtException(JwtErrorCode.UNSUPPORTED_JWT_TOKEN);
130+
}
123131
}
124132

125-
public void validateRefreshToken(String refreshToken, String userId) {
126-
Claims claims = parseClaims(refreshToken);
133+
public String validateRefreshToken(String refreshToken) {
134+
Claims claims = validateToken(refreshToken);
135+
127136
String tokenType = claims.get(JwtKeys.Claims.TOKEN_TYPE, String.class);
128137

129138
if (!JwtKeys.TokenType.REFRESH.equals(tokenType)) {
130139
throw new CustomJwtException(JwtErrorCode.INVALID_REFRESH);
131140
}
132141

142+
String userId = claims.getSubject();
143+
133144
if (redisService.hasNoKey(RedisKeyRegistry.REFRESH_TOKEN, userId)) {
134145
throw new CustomJwtException(JwtErrorCode.CANNOT_REFRESH);
135146
}
136147

137148
String redisToken = redisService.get(RedisKeyRegistry.REFRESH_TOKEN, userId);
138-
139149
if (!redisToken.equals(refreshToken)) {
140150
throw new CustomJwtException(JwtErrorCode.INVALID_JWT_TOKEN);
141151
}
142152

153+
return userId;
143154
}
144155

145156
public boolean validateAccessToken(String token) {
146-
if (token == null || token.isBlank()) {
147-
throw new CustomJwtException(JwtErrorCode.EMPTY_TOKEN);
148-
}
157+
Claims claims = validateToken(token);
149158

150-
try {
151-
Claims claims = parseClaims(token);
152-
boolean notExpired = !claims.getExpiration().before(new Date());
153-
154-
String tokenType = claims.get(JwtKeys.Claims.TOKEN_TYPE, String.class);
155-
boolean isAccess = JwtKeys.TokenType.ACCESS.equals(tokenType);
156-
157-
return notExpired && isAccess;
158-
} catch (SecurityException | MalformedJwtException | IllegalArgumentException e) {
159-
throw new CustomJwtException(JwtErrorCode.INVALID_JWT_TOKEN);
160-
} catch (ExpiredJwtException e) {
161-
throw new CustomJwtException(JwtErrorCode.EXPIRED_JWT_TOKEN);
162-
} catch (UnsupportedJwtException e) {
163-
throw new CustomJwtException(JwtErrorCode.UNSUPPORTED_JWT_TOKEN);
164-
}
159+
String tokenType = claims.get(JwtKeys.Claims.TOKEN_TYPE, String.class);
160+
return JwtKeys.TokenType.ACCESS.equals(tokenType);
165161
}
166162
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package com.sofa.linkiving.domain.auth.integration;
2+
3+
import static org.mockito.ArgumentMatchers.*;
4+
import static org.mockito.BDDMockito.*;
5+
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.*;
6+
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
7+
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*;
8+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
9+
10+
import org.junit.jupiter.api.BeforeEach;
11+
import org.junit.jupiter.api.DisplayName;
12+
import org.junit.jupiter.api.Test;
13+
import org.springframework.beans.factory.annotation.Autowired;
14+
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
15+
import org.springframework.boot.test.context.SpringBootTest;
16+
import org.springframework.http.MediaType;
17+
import org.springframework.test.context.ActiveProfiles;
18+
import org.springframework.test.context.bean.override.mockito.MockitoBean;
19+
import org.springframework.test.web.servlet.MockMvc;
20+
import org.springframework.transaction.annotation.Transactional;
21+
22+
import com.sofa.linkiving.domain.member.entity.Member;
23+
import com.sofa.linkiving.domain.member.repository.MemberRepository;
24+
import com.sofa.linkiving.infra.redis.RedisService;
25+
import com.sofa.linkiving.security.jwt.JwtTokenProvider;
26+
27+
import jakarta.servlet.http.Cookie;
28+
29+
@Transactional
30+
@SpringBootTest
31+
@AutoConfigureMockMvc
32+
@ActiveProfiles("test")
33+
public class AuthApiIntegrationTest {
34+
35+
@Autowired
36+
private MockMvc mockMvc;
37+
38+
@Autowired
39+
private MemberRepository memberRepository;
40+
41+
@Autowired
42+
private JwtTokenProvider jwtTokenProvider;
43+
44+
@MockitoBean
45+
private RedisService redisService;
46+
47+
private Member testMember;
48+
49+
@BeforeEach
50+
void setUp() {
51+
testMember = memberRepository.save(Member.builder()
52+
.email("auth@test.com")
53+
.password("password")
54+
.build());
55+
}
56+
57+
@Test
58+
@DisplayName("유효한 RefreshToken을 쿠키에 담아 재발급 요청 시 새로운 토큰 쿠키와 200 OK를 반환한다")
59+
void shouldReissueTokensAndReturnOk() throws Exception {
60+
// given
61+
String email = testMember.getEmail();
62+
String validRefreshToken = jwtTokenProvider.createRefreshToken(email);
63+
Cookie refreshTokenCookie = new Cookie("refreshToken", validRefreshToken);
64+
65+
given(redisService.hasNoKey(any(), eq(email))).willReturn(false);
66+
given(redisService.get(any(), eq(email))).willReturn(validRefreshToken);
67+
68+
// when & then
69+
mockMvc.perform(
70+
post("/v1/auth/reissue")
71+
.cookie(refreshTokenCookie)
72+
.with(csrf())
73+
.accept(MediaType.APPLICATION_JSON)
74+
)
75+
.andDo(print())
76+
.andExpect(status().isOk())
77+
.andExpect(jsonPath("$.success").value(true))
78+
.andExpect(jsonPath("$.message").value("토큰 재발급 완료"))
79+
.andExpect(cookie().exists("accessToken"))
80+
.andExpect(cookie().exists("refreshToken"));
81+
}
82+
83+
@Test
84+
@DisplayName("RefreshToken 쿠키 없이 재발급 요청 시 에러를 반환한다")
85+
void shouldFailWhenRefreshTokenCookieIsMissing() throws Exception {
86+
// given - Cookie 설정 없음
87+
88+
// when & then
89+
mockMvc.perform(
90+
post("/v1/auth/reissue")
91+
.with(csrf())
92+
.accept(MediaType.APPLICATION_JSON)
93+
)
94+
.andDo(print())
95+
.andExpect(status().isUnauthorized())
96+
.andExpect(jsonPath("$.data").value("J-003"));
97+
}
98+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package com.sofa.linkiving.domain.auth.service;
2+
3+
import static org.assertj.core.api.Assertions.*;
4+
import static org.mockito.BDDMockito.*;
5+
6+
import org.junit.jupiter.api.DisplayName;
7+
import org.junit.jupiter.api.Test;
8+
import org.junit.jupiter.api.extension.ExtendWith;
9+
import org.mockito.InjectMocks;
10+
import org.mockito.Mock;
11+
import org.mockito.junit.jupiter.MockitoExtension;
12+
13+
import com.sofa.linkiving.domain.auth.dto.internal.TokenDto;
14+
import com.sofa.linkiving.security.jwt.JwtProperties;
15+
import com.sofa.linkiving.security.jwt.JwtTokenProvider;
16+
import com.sofa.linkiving.security.jwt.error.CustomJwtException;
17+
import com.sofa.linkiving.security.jwt.error.JwtErrorCode;
18+
19+
@ExtendWith(MockitoExtension.class)
20+
@DisplayName("AuthService 단위 테스트")
21+
public class AuthServiceTest {
22+
23+
@InjectMocks
24+
private AuthService authService;
25+
26+
@Mock
27+
private JwtTokenProvider jwtTokenProvider;
28+
29+
@Mock
30+
private JwtProperties jwtProperties;
31+
32+
@Test
33+
@DisplayName("유효한 RefreshToken이 주어지면 새로운 토큰 쌍을 발급한다")
34+
void shouldReissueTokensSuccessfully() {
35+
// given
36+
37+
String oldRefreshToken = "old-refresh-token";
38+
39+
String newAccessToken = "new-access-token";
40+
String newRefreshToken = "new-refresh-token";
41+
42+
given(jwtTokenProvider.validateRefreshToken(oldRefreshToken)).willReturn("test@test.com");
43+
given(jwtTokenProvider.createAccessToken("test@test.com")).willReturn(newAccessToken);
44+
given(jwtTokenProvider.createRefreshToken("test@test.com")).willReturn(newRefreshToken);
45+
46+
// 1시간 = 3600000ms, 2주 = 1209600000ms
47+
given(jwtProperties.accessTokenValidTime()).willReturn(3600000L);
48+
given(jwtProperties.refreshTokenValidTime()).willReturn(1209600000L);
49+
50+
// when
51+
TokenDto result = authService.reissue(oldRefreshToken);
52+
53+
// then
54+
assertThat(result).isNotNull();
55+
assertThat(result.accessToken()).isEqualTo(newAccessToken);
56+
assertThat(result.accessExp()).isEqualTo(3600);
57+
assertThat(result.refreshToken()).isEqualTo(newRefreshToken);
58+
assertThat(result.refreshExp()).isEqualTo(1209600);
59+
60+
verify(jwtTokenProvider, times(1)).validateRefreshToken(oldRefreshToken);
61+
}
62+
63+
@Test
64+
@DisplayName("RefreshToken 검증에 실패하면 예외가 발생한다")
65+
void shouldThrowExceptionWhenRefreshTokenIsInvalid() {
66+
// given
67+
String invalidToken = "invalid-token";
68+
69+
willThrow(new CustomJwtException(JwtErrorCode.INVALID_JWT_TOKEN))
70+
.given(jwtTokenProvider).validateRefreshToken(invalidToken);
71+
72+
// when & then
73+
assertThatThrownBy(() -> authService.reissue(invalidToken))
74+
.isInstanceOf(RuntimeException.class);
75+
}
76+
}

src/test/java/com/sofa/linkiving/global/security/jwt/JwtTokenProviderTest.java

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ void shouldThrowCannotRefreshWhenNoTokenInRedis() {
174174
.willReturn(true);
175175

176176
// when & then
177-
assertThatThrownBy(() -> provider.validateRefreshToken(refreshToken, userId))
177+
assertThatThrownBy(() -> provider.validateRefreshToken(refreshToken))
178178
.isInstanceOf(CustomJwtException.class)
179179
.extracting("errorCode")
180180
.isEqualTo(JwtErrorCode.CANNOT_REFRESH);
@@ -190,7 +190,7 @@ void shouldThrowWhenRefreshTokenTypeIsInvalid() {
190190
String accessToken = provider.createAccessToken("member");
191191

192192
// when & then
193-
assertThatThrownBy(() -> provider.validateRefreshToken(accessToken, "member-5"))
193+
assertThatThrownBy(() -> provider.validateRefreshToken(accessToken))
194194
.isInstanceOf(CustomJwtException.class)
195195
.extracting("errorCode")
196196
.isEqualTo(JwtErrorCode.INVALID_REFRESH);
@@ -211,7 +211,7 @@ void shouldThrowWhenRefreshTokenMismatch() {
211211
.willReturn("another-token");
212212

213213
// when & then
214-
assertThatThrownBy(() -> provider.validateRefreshToken(refreshToken, userId))
214+
assertThatThrownBy(() -> provider.validateRefreshToken(refreshToken))
215215
.isInstanceOf(CustomJwtException.class)
216216
.extracting("errorCode")
217217
.isEqualTo(JwtErrorCode.INVALID_JWT_TOKEN);
@@ -231,7 +231,7 @@ void shouldValidateRefreshTokenSuccessfully() {
231231
given(redisService.get(RedisKeyRegistry.REFRESH_TOKEN, userId)).willReturn(refreshToken);
232232

233233
// when & then
234-
assertThatCode(() -> provider.validateRefreshToken(refreshToken, userId))
234+
assertThatCode(() -> provider.validateRefreshToken(refreshToken))
235235
.doesNotThrowAnyException();
236236
}
237237
}

0 commit comments

Comments
 (0)