Skip to content

Commit dbf9069

Browse files
authored
Feat: 토큰 재발급 API 구현 (#75) (#78)
1 parent 0661ff5 commit dbf9069

File tree

7 files changed

+390
-34
lines changed

7 files changed

+390
-34
lines changed

src/main/java/com/back/domain/user/controller/AuthController.java

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,6 @@
55
import com.back.domain.user.dto.UserResponse;
66
import com.back.domain.user.service.UserService;
77
import com.back.global.common.dto.RsData;
8-
import io.swagger.v3.oas.annotations.Operation;
9-
import io.swagger.v3.oas.annotations.responses.ApiResponse;
10-
import io.swagger.v3.oas.annotations.responses.ApiResponses;
118
import jakarta.servlet.http.HttpServletRequest;
129
import jakarta.servlet.http.HttpServletResponse;
1310
import jakarta.validation.Valid;
@@ -19,6 +16,8 @@
1916
import org.springframework.web.bind.annotation.RequestMapping;
2017
import org.springframework.web.bind.annotation.RestController;
2118

19+
import java.util.Map;
20+
2221
@RestController
2322
@RequestMapping("/api/auth")
2423
@RequiredArgsConstructor
@@ -66,4 +65,17 @@ public ResponseEntity<RsData<Void>> logout(
6665
null
6766
));
6867
}
68+
69+
// 토큰 재발급
70+
@PostMapping("/refresh")
71+
public ResponseEntity<RsData<Map<String, String>>> refreshToken(
72+
HttpServletRequest request,
73+
HttpServletResponse response
74+
) {
75+
String newAccessToken = userService.refreshToken(request, response);
76+
return ResponseEntity.ok(RsData.success(
77+
"토큰이 재발급되었습니다.",
78+
Map.of("accessToken", newAccessToken)
79+
));
80+
}
6981
}

src/main/java/com/back/domain/user/controller/AuthControllerDocs.java

Lines changed: 114 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
import org.springframework.http.ResponseEntity;
1717
import org.springframework.web.bind.annotation.RequestBody;
1818

19+
import java.util.Map;
20+
1921
@Tag(name = "Auth API", description = "인증/인가 관련 API")
2022
public interface AuthControllerDocs {
2123

@@ -243,13 +245,13 @@ ResponseEntity<RsData<UserResponse>> login(
243245
content = @Content(
244246
mediaType = "application/json",
245247
examples = @ExampleObject(value = """
246-
{
247-
"success": true,
248-
"code": "SUCCESS_200",
249-
"message": "로그아웃 되었습니다.",
250-
"data": null
251-
}
252-
""")
248+
{
249+
"success": true,
250+
"code": "SUCCESS_200",
251+
"message": "로그아웃 되었습니다.",
252+
"data": null
253+
}
254+
""")
253255
)
254256
),
255257
@ApiResponse(
@@ -258,13 +260,13 @@ ResponseEntity<RsData<UserResponse>> login(
258260
content = @Content(
259261
mediaType = "application/json",
260262
examples = @ExampleObject(value = """
261-
{
262-
"success": false,
263-
"code": "AUTH_401",
264-
"message": "이미 만료되었거나 유효하지 않은 토큰입니다.",
265-
"data": null
266-
}
267-
""")
263+
{
264+
"success": false,
265+
"code": "AUTH_401",
266+
"message": "이미 만료되었거나 유효하지 않은 토큰입니다.",
267+
"data": null
268+
}
269+
""")
268270
)
269271
),
270272
@ApiResponse(
@@ -273,13 +275,13 @@ ResponseEntity<RsData<UserResponse>> login(
273275
content = @Content(
274276
mediaType = "application/json",
275277
examples = @ExampleObject(value = """
276-
{
277-
"success": false,
278-
"code": "COMMON_400",
279-
"message": "잘못된 요청입니다.",
280-
"data": null
281-
}
282-
""")
278+
{
279+
"success": false,
280+
"code": "COMMON_400",
281+
"message": "잘못된 요청입니다.",
282+
"data": null
283+
}
284+
""")
283285
)
284286
),
285287
@ApiResponse(
@@ -288,18 +290,102 @@ ResponseEntity<RsData<UserResponse>> login(
288290
content = @Content(
289291
mediaType = "application/json",
290292
examples = @ExampleObject(value = """
291-
{
292-
"success": false,
293-
"code": "COMMON_500",
294-
"message": "서버 오류가 발생했습니다.",
295-
"data": null
296-
}
297-
""")
293+
{
294+
"success": false,
295+
"code": "COMMON_500",
296+
"message": "서버 오류가 발생했습니다.",
297+
"data": null
298+
}
299+
""")
298300
)
299301
)
300302
})
301303
ResponseEntity<RsData<Void>> logout(
302304
HttpServletRequest request,
303305
HttpServletResponse response
304306
);
307+
308+
@Operation(
309+
summary = "토큰 재발급",
310+
description = "만료된 Access Token 대신 Refresh Token을 이용해 새로운 Access Token을 발급받습니다. " +
311+
"Refresh Token은 HttpOnly 쿠키에서 추출하며, 재발급 성공 시 응답 헤더와 본문에 새로운 Access Token을 담습니다."
312+
)
313+
@ApiResponses({
314+
@ApiResponse(
315+
responseCode = "200",
316+
description = "토큰 재발급 성공",
317+
content = @Content(
318+
mediaType = "application/json",
319+
examples = @ExampleObject(value = """
320+
{
321+
"success": true,
322+
"code": "SUCCESS_200",
323+
"message": "토큰이 재발급되었습니다.",
324+
"data": {
325+
"accessToken": "{newAccessToken}"
326+
}
327+
}
328+
""")
329+
)
330+
),
331+
@ApiResponse(
332+
responseCode = "400",
333+
description = "Refresh Token 없음 / 잘못된 요청",
334+
content = @Content(
335+
mediaType = "application/json",
336+
examples = @ExampleObject(value = """
337+
{
338+
"success": false,
339+
"code": "COMMON_400",
340+
"message": "잘못된 요청입니다.",
341+
"data": null
342+
}
343+
""")
344+
)
345+
),
346+
@ApiResponse(
347+
responseCode = "401",
348+
description = "Refresh Token 만료 또는 위조/무효",
349+
content = @Content(
350+
mediaType = "application/json",
351+
examples = {
352+
@ExampleObject(name = "Refresh Token 만료", value = """
353+
{
354+
"success": false,
355+
"code": "AUTH_401",
356+
"message": "만료된 리프레시 토큰입니다.",
357+
"data": null
358+
}
359+
"""),
360+
@ExampleObject(name = "Refresh Token 위조/무효", value = """
361+
{
362+
"success": false,
363+
"code": "AUTH_401",
364+
"message": "유효하지 않은 Refresh Token입니다.",
365+
"data": null
366+
}
367+
""")
368+
}
369+
)
370+
),
371+
@ApiResponse(
372+
responseCode = "500",
373+
description = "서버 내부 오류",
374+
content = @Content(
375+
mediaType = "application/json",
376+
examples = @ExampleObject(value = """
377+
{
378+
"success": false,
379+
"code": "COMMON_500",
380+
"message": "서버 오류가 발생했습니다.",
381+
"data": null
382+
}
383+
""")
384+
)
385+
)
386+
})
387+
ResponseEntity<RsData<Map<String, String>>> refreshToken(
388+
HttpServletRequest request,
389+
HttpServletResponse response
390+
);
305391
}

src/main/java/com/back/domain/user/service/UserService.java

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
import com.back.domain.user.repository.UserTokenRepository;
1313
import com.back.global.exception.CustomException;
1414
import com.back.global.exception.ErrorCode;
15-
import com.back.global.security.CurrentUser;
1615
import com.back.global.security.JwtTokenProvider;
1716
import com.back.global.util.CookieUtil;
1817
import jakarta.servlet.http.Cookie;
@@ -161,6 +160,47 @@ public void logout(HttpServletRequest request, HttpServletResponse response) {
161160
CookieUtil.clearCookie(response, "refreshToken", "/api/auth");
162161
}
163162

163+
/**
164+
* 토큰 재발급 서비스
165+
* 1. 쿠키에서 Refresh Token 추출
166+
* 2. Refresh Token 검증 (만료/위조 확인)
167+
* 3. DB에 저장된 Refresh Token 여부 확인
168+
* 4. 새 Access Token 발급
169+
*/
170+
public String refreshToken(HttpServletRequest request, HttpServletResponse response) {
171+
// Refresh Token 검증
172+
String refreshToken = resolveRefreshToken(request);
173+
174+
// Refresh Token 존재 여부 확인
175+
if (refreshToken == null) {
176+
throw new CustomException(ErrorCode.BAD_REQUEST);
177+
}
178+
179+
// Refresh Token 검증
180+
if (!jwtTokenProvider.validateToken(refreshToken)) {
181+
throw new CustomException(ErrorCode.INVALID_TOKEN);
182+
}
183+
184+
// DB에서 Refresh Token 조회
185+
UserToken userToken = userTokenRepository.findByRefreshToken(refreshToken)
186+
.orElseThrow(() -> new CustomException(ErrorCode.INVALID_TOKEN));
187+
188+
// 사용자 정보 조회
189+
User user = userToken.getUser();
190+
191+
// 새로운 Access Token 발급
192+
String newAccessToken = jwtTokenProvider.createAccessToken(
193+
user.getId(),
194+
user.getUsername(),
195+
user.getRole().name()
196+
);
197+
198+
// 새로운 Access Token을 응답 헤더에 설정
199+
response.setHeader("Authorization", "Bearer " + newAccessToken);
200+
201+
return newAccessToken;
202+
}
203+
164204
/**
165205
* 회원가입 시 중복 검증
166206
* - username, email, nickname

src/main/java/com/back/global/security/JwtTokenProvider.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,8 +116,10 @@ public boolean validateToken(String token) {
116116
.build()
117117
.parseSignedClaims(token);
118118
return true;
119-
} catch (JwtException e) {
120-
return false;
119+
} catch (ExpiredJwtException e) {
120+
throw new CustomException(ErrorCode.EXPIRED_REFRESH_TOKEN);
121+
} catch (JwtException | IllegalArgumentException e) {
122+
throw new CustomException(ErrorCode.INVALID_TOKEN);
121123
}
122124
}
123125

0 commit comments

Comments
 (0)