Skip to content

Commit 7754e2a

Browse files
authored
[REFACTOR/#101] 리프레시 토큰 및 state 인증 시 쿠키 기반으로 롤백 (#102)
## 🔗 Related Issue <!-- 이슈 번호를 작성하여 종료시켜주세요 --> - Closes #101 ## 📝 Summary <!-- 작업한 기능을 설명해주세요 --> 이전에 작업했던 쿠키 기반 인증 방식을 되돌리고, CORS 설정에 프론트엔드 배포 주소를 추가하였습니다. - 쿠키 기반 인증 방식 - 로그인 시 리프레시 토큰은 JSON Body가 아닌 쿠키로 생성하도록 변경 - 카카오 콜백 API 호출 시 state 값은 쿼리 파라미터가 아닌 쿠키에서 받아오도록 변경 - 토큰 재발급 시 리프레시 토큰은 쿼리 파라미터가 아닌 쿠키에서 받아오도록 변경 - CORS 설정 - 기존에는 와일드카드(`*`)로 모든 도메인을 허용했었으나, 프론트엔드 배포 주소(`https://valuedi-web.vercel.app/`)와 로컬 개발환경(`http://localhost:5173`)만 허용하도록 수정 ## 🔄 Changes <!-- 구체적으로 어떤 파일/로직이 변경되었는지 체크해주세요 --> - [ ] API 변경 (추가/수정) - [ ] 데이터 및 도메인 변경 (DB, 비즈니스 로직) - [ ] 설정 또는 인프라 관련 변경 - [x] 리팩토링 ## 💬 Questions & Review Points <!-- 리뷰어가 특별히 봐주었으면 하는 부분이 있다면 작성해주세요 --> ## 📸 API Test Results (Swagger) <!-- API 테스트 스크린샷 첨부 --> ## ✅ Checklist - [x] API 테스트 완료 - [ ] 테스트 결과 사진 첨부 - [x] 빌드 성공 확인 (./gradlew build)
1 parent 7878efc commit 7754e2a

6 files changed

Lines changed: 76 additions & 43 deletions

File tree

src/main/java/org/umc/valuedi/domain/auth/controller/AuthController.java

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
package org.umc.valuedi.domain.auth.controller;
22

3+
import jakarta.servlet.http.HttpServletResponse;
34
import lombok.RequiredArgsConstructor;
45
import org.springframework.http.HttpStatus;
56
import org.springframework.http.ResponseEntity;
67
import org.springframework.validation.annotation.Validated;
78
import org.springframework.web.bind.annotation.*;
89
import org.umc.valuedi.global.external.kakao.config.KakaoProperties;
9-
import org.umc.valuedi.domain.auth.converter.AuthConverter;
1010
import org.umc.valuedi.domain.auth.dto.req.AuthReqDTO;
1111
import org.umc.valuedi.domain.auth.dto.res.AuthResDTO;
1212
import org.umc.valuedi.domain.auth.exception.AuthException;
@@ -16,6 +16,7 @@
1616
import org.umc.valuedi.domain.auth.service.query.AuthQueryService;
1717
import org.umc.valuedi.global.apiPayload.ApiResponse;
1818
import org.umc.valuedi.global.security.annotation.CurrentMember;
19+
import org.umc.valuedi.global.security.jwt.JwtUtil;
1920
import org.umc.valuedi.global.security.util.CookieUtil;
2021

2122
import java.util.UUID;
@@ -30,27 +31,43 @@ public class AuthController implements AuthControllerDocs {
3031
private final AuthQueryService authQueryService;
3132
private final KakaoProperties kakaoProperties;
3233
private final CookieUtil cookieUtil;
34+
private final JwtUtil jwtUtil;
3335

3436
@Override
3537
@GetMapping("/oauth/kakao/login")
36-
public ApiResponse<AuthResDTO.LoginUrlDTO> kakaoLogin() {
38+
public ApiResponse<String> kakaoLogin(HttpServletResponse response) {
3739
String state = UUID.randomUUID().toString();
40+
cookieUtil.addCookie(response, "oauth_state", state, 600, "/auth/oauth/kakao/callback");
41+
3842
String loginUrl = kakaoProperties.getKakaoAuthUrl(state);
39-
return ApiResponse.onSuccess(AuthSuccessCode.KAKAO_AUTH_URL_SUCCESS, AuthConverter.toLoginUrlDTO(loginUrl, state));
43+
return ApiResponse.onSuccess(AuthSuccessCode.KAKAO_AUTH_URL_SUCCESS, loginUrl);
4044
}
4145

4246
@Override
4347
@GetMapping("/oauth/kakao/callback")
4448
public ApiResponse<AuthResDTO.LoginResultDTO> kakaoCallback(
4549
@RequestParam("code") String code,
4650
@RequestParam("state") String state,
47-
@RequestParam("originalState") String originalState
51+
@CookieValue(name = "oauth_state", required = false) String oauthState,
52+
HttpServletResponse response
4853
) {
49-
if (originalState == null || !originalState.equals(state)) {
54+
cookieUtil.deleteCookie(response, "oauth_state", "/auth/oauth/kakao/callback");
55+
56+
if (oauthState == null || !oauthState.equals(state)) {
5057
throw new AuthException(AuthErrorCode.INVALID_STATE);
5158
}
5259

5360
AuthResDTO.LoginResultDTO result = authCommandService.loginKakao(code);
61+
62+
// 리프레시 토큰은 쿠키에 저장
63+
cookieUtil.addCookie(
64+
response,
65+
"refreshToken",
66+
result.refreshToken(),
67+
(int) jwtUtil.getRefreshTokenExpiration() / 1000,
68+
"/auth/token/refresh"
69+
);
70+
5471
return ApiResponse.onSuccess(AuthSuccessCode.LOGIN_OK, result);
5572
}
5673

@@ -91,31 +108,54 @@ public ResponseEntity<ApiResponse<AuthResDTO.RegisterResDTO>> signUp(
91108
@Override
92109
@PostMapping("/login")
93110
public ApiResponse<AuthResDTO.LoginResultDTO> localLogin(
94-
@RequestBody AuthReqDTO.LocalLoginDTO dto
111+
@RequestBody AuthReqDTO.LocalLoginDTO dto,
112+
HttpServletResponse response
95113
) {
96114

97115
AuthResDTO.LoginResultDTO result = authCommandService.loginLocal(dto);
116+
117+
// 리프레시 토큰은 쿠키에 저장
118+
cookieUtil.addCookie(
119+
response,
120+
"refreshToken",
121+
result.refreshToken(),
122+
(int) jwtUtil.getRefreshTokenExpiration() / 1000,
123+
"/auth/token/refresh"
124+
);
125+
98126
return ApiResponse.onSuccess(AuthSuccessCode.LOGIN_OK, result);
99127
}
100128

101129
@Override
102130
@PostMapping("/token/refresh")
103131
public ApiResponse<AuthResDTO.LoginResultDTO> tokenReissue(
104132
@RequestHeader(value = "Authorization", required = false) String accessToken,
105-
@RequestParam String refreshToken
133+
@CookieValue(name = "refreshToken") String refreshToken,
134+
HttpServletResponse response
106135
) {
107136
AuthResDTO.LoginResultDTO result = authCommandService.tokenReissue(accessToken, refreshToken);
137+
138+
// 리프레시 토큰은 쿠키에 저장
139+
cookieUtil.addCookie(
140+
response,
141+
"refreshToken",
142+
result.refreshToken(),
143+
(int) jwtUtil.getRefreshTokenExpiration() / 1000,
144+
"/auth/token/refresh"
145+
);
146+
108147
return ApiResponse.onSuccess(AuthSuccessCode.TOKEN_REISSUE_SUCCESS, result);
109148
}
110149

111150
@Override
112151
@PostMapping("/logout")
113152
public ApiResponse<Void> logout(
114153
@CurrentMember Long memberId,
115-
@RequestHeader("Authorization") String accessToken
154+
@RequestHeader("Authorization") String accessToken,
155+
HttpServletResponse response
116156
) {
117157
authCommandService.logout(memberId, accessToken);
118-
158+
cookieUtil.deleteCookie(response, "refreshToken", "/auth/token/refresh");
119159
return ApiResponse.onSuccess(AuthSuccessCode.LOGOUT_OK, null);
120160
}
121161

src/main/java/org/umc/valuedi/domain/auth/controller/AuthControllerDocs.java

Lines changed: 17 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,16 @@
1111
import jakarta.validation.Valid;
1212
import jakarta.validation.constraints.NotBlank;
1313
import org.springframework.http.ResponseEntity;
14-
import org.springframework.web.bind.annotation.RequestHeader;
1514
import org.umc.valuedi.domain.auth.dto.req.AuthReqDTO;
1615
import org.umc.valuedi.domain.auth.dto.res.AuthResDTO;
1716
import org.umc.valuedi.global.apiPayload.ApiResponse;
18-
import org.umc.valuedi.global.security.annotation.CurrentMember;
19-
import org.umc.valuedi.global.security.principal.CustomUserDetails;
2017

2118
@Tag(name = "Auth", description = "Auth 관련 API (로그인, 회원가입 등)")
2219
public interface AuthControllerDocs {
2320

2421
@Operation(
2522
summary = "카카오 로그인 URL 생성 API",
26-
description = "카카오 로그인 페이지로 이동하기 위한 URL을 생성하고, 보안을 위한 state 값을 함께 응답합니다.")
23+
description = "카카오 로그인 페이지로 이동하기 위한 URL을 생성하고, 보안을 위한 state 값을 쿠키에 저장합니다.")
2724
@ApiResponses({
2825
@io.swagger.v3.oas.annotations.responses.ApiResponse(
2926
responseCode = "200",
@@ -32,26 +29,24 @@ public interface AuthControllerDocs {
3229
schema = @Schema(implementation = ApiResponse.class),
3330
examples = @ExampleObject(
3431
name = "카카오 로그인 URL 생성 예시",
32+
description = "생성된 URL에는 보안을 위한 state 파라미터가 추가되어 있습니다.",
3533
value = """
3634
{
3735
"isSuccess": true,
3836
"code": "AUTH200_1",
3937
"message": "카카오 로그인 URL이 성공적으로 생성되었습니다.",
40-
"result": {
41-
"url": "https://kauth.kakao.com/oauth/authorize?response_type=code&client_id={clientId}&redirect_uri={redirectUri}&state=6af5e726-...",
42-
"state": "6af5e726-7df6-4d03-9829-4b7d4ad792ec"
43-
}
38+
"result": "https://kauth.kakao.com/oauth/authorize?response_type=code&client_id={clientId}&redirect_uri={redirectUri}&state=bba27165-..."
4439
}
4540
"""
4641
)
4742
)
4843
)
4944
})
50-
ApiResponse<AuthResDTO.LoginUrlDTO> kakaoLogin();
45+
ApiResponse<String> kakaoLogin(HttpServletResponse response);
5146

5247
@Operation(
5348
summary = "카카오 로그인 콜백 API",
54-
description = "카카오로부터 인가 코드를 받아 로그인을 완료하고 JWT를 발급합니다. \n기존에 카카오로 로그인한 적 없는 경우, 회원가입 처리 후 JWT를 발급합니다.")
49+
description = "카카오로부터 인가 코드를 받아 로그인을 완료하고 JWT를 발급합니다. \n기존에 카카오로 로그인한 적 없는 경우, 회원가입 처리 후 JWT를 발급합니다. \n리프레시 토큰은 쿠키에 저장됩니다.")
5550
@ApiResponses({
5651
@io.swagger.v3.oas.annotations.responses.ApiResponse(
5752
responseCode = "200",
@@ -67,7 +62,6 @@ public interface AuthControllerDocs {
6762
"message": "로그인에 성공했습니다.",
6863
"result": {
6964
"accessToken": "eyJhbGciOiJIUzI1NiJ9...",
70-
"refreshToken": "eyJhbGciOiJIUzI1NiJ9...",
7165
"memberId": 1
7266
}
7367
}
@@ -130,8 +124,9 @@ ApiResponse<AuthResDTO.LoginResultDTO> kakaoCallback(
130124
String code,
131125
@Parameter(description = "카카오에서 전달한 state 값")
132126
String state,
133-
@Parameter(description = "클라이언트가 저장해둔 원본 state 값")
134-
String originalState
127+
@Parameter(hidden = true)
128+
String oauthState,
129+
HttpServletResponse response
135130
);
136131

137132
@Operation(summary = "아이디 중복 확인 API", description = "사용자가 입력한 아이디의 중복 여부를 확인합니다.")
@@ -364,7 +359,7 @@ public ResponseEntity<ApiResponse<AuthResDTO.RegisterResDTO>> signUp(
364359

365360
@Operation(
366361
summary = "로컬 계정 로그인 API",
367-
description = "로컬 계정으로 로그인을 시도합니다. 로그인이 완료되면 JWT를 발급합니다.")
362+
description = "로컬 계정으로 로그인을 시도합니다. 로그인이 완료되면 JWT를 발급합니다. \n리프레시 토큰은 쿠키에 저장됩니다.")
368363
@ApiResponses({
369364
@io.swagger.v3.oas.annotations.responses.ApiResponse(
370365
responseCode = "200",
@@ -380,7 +375,6 @@ public ResponseEntity<ApiResponse<AuthResDTO.RegisterResDTO>> signUp(
380375
"message": "로그인에 성공했습니다.",
381376
"result": {
382377
"accessToken": "eyJhbGciOiJIUzI1NiJ9...",
383-
"refreshToken": "eyJhbGciOiJIUzI1NiJ9...",
384378
"memberId": 1
385379
}
386380
}
@@ -439,12 +433,13 @@ public ResponseEntity<ApiResponse<AuthResDTO.RegisterResDTO>> signUp(
439433
)
440434
})
441435
public ApiResponse<AuthResDTO.LoginResultDTO> localLogin(
442-
@Valid AuthReqDTO.LocalLoginDTO dto
436+
@Valid AuthReqDTO.LocalLoginDTO dto,
437+
HttpServletResponse response
443438
);
444439

445440
@Operation(
446441
summary = "토큰 재발급 API",
447-
description = "리프레시 토큰으로 새로운 액세스 토큰과 리프레시 토큰을 발급합니다. \n요청 헤더에 만료되지 않은 액세스 토큰이 있다면 이를 무효화합니다.")
442+
description = "쿠키에 저장된 리프레시 토큰으로 새로운 액세스 토큰과 리프레시 토큰을 발급합니다. \n요청 헤더에 만료되지 않은 액세스 토큰이 있다면 이를 무효화하며, 새로 발급된 리프레시 토큰은 쿠키에 저장됩니다.")
448443
@ApiResponses({
449444
@io.swagger.v3.oas.annotations.responses.ApiResponse(
450445
responseCode = "200",
@@ -460,7 +455,6 @@ public ApiResponse<AuthResDTO.LoginResultDTO> localLogin(
460455
"message": "토큰 재발급에 성공했습니다.",
461456
"result": {
462457
"accessToken": "eyJhbGciOiJIUzI1NiJ9...",
463-
"refreshToken": "eyJhbGciOiJIUzI1NiJ9...",
464458
"memberId": 1
465459
}
466460
}
@@ -472,7 +466,9 @@ public ApiResponse<AuthResDTO.LoginResultDTO> localLogin(
472466
public ApiResponse<AuthResDTO.LoginResultDTO> tokenReissue(
473467
@Parameter(hidden = true)
474468
String accessToken,
475-
String refreshToken
469+
@Parameter(hidden = true)
470+
String refreshToken,
471+
HttpServletResponse response
476472
);
477473

478474
@Operation(
@@ -501,7 +497,8 @@ public ApiResponse<AuthResDTO.LoginResultDTO> tokenReissue(
501497
public ApiResponse<Void> logout(
502498
Long memberId,
503499
@Parameter(hidden = true)
504-
String accessToken
500+
String accessToken,
501+
HttpServletResponse response
505502
);
506503

507504
@Operation(

src/main/java/org/umc/valuedi/domain/auth/converter/AuthConverter.java

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,6 @@
1212

1313
public class AuthConverter {
1414

15-
// 카카오 로그인 URL과 state 값을 하나의 DTO로 변환
16-
public static AuthResDTO.LoginUrlDTO toLoginUrlDTO(String loginUrl, String state) {
17-
return new AuthResDTO.LoginUrlDTO(loginUrl, state);
18-
}
19-
2015
// 로컬 회원가입 정보 바탕으로 Member 엔티티 생성
2116
public static Member toGeneralMember(AuthReqDTO.RegisterReqDTO dto, String encodedPassword) {
2217
return Member.builder()

src/main/java/org/umc/valuedi/domain/auth/dto/res/AuthResDTO.java

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@ public class AuthResDTO {
88
@Builder
99
public record LoginResultDTO (
1010
String accessToken,
11+
12+
// 리프레시 토큰은 쿠키로 전달하고 JSON body에는 저장하지 않음
13+
@JsonIgnore
1114
String refreshToken,
15+
1216
Long memberId
1317
) {}
1418

@@ -22,10 +26,4 @@ public record AuthStatusDTO (
2226
Boolean isLogin,
2327
Long memberId
2428
) {}
25-
26-
@Builder
27-
public record LoginUrlDTO(
28-
String url,
29-
String state
30-
) {}
3129
}

src/main/java/org/umc/valuedi/global/config/SecurityConfig.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,10 @@ public JwtAuthFilter jwtAuthFilter() {
9595
public CorsConfigurationSource corsConfigurationSource() {
9696
CorsConfiguration configuration = new CorsConfiguration();
9797

98-
configuration.setAllowedOriginPatterns(List.of("*"));
98+
configuration.setAllowedOrigins(Arrays.asList(
99+
"https://valuedi-web.vercel.app",
100+
"http://localhost:5173"
101+
));
99102

100103
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
101104
configuration.setAllowedHeaders(Arrays.asList("*"));

src/main/java/org/umc/valuedi/global/security/util/CookieUtil.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@ public class CookieUtil {
1111
public void addCookie(HttpServletResponse response, String name, String value, int maxAge, String path) {
1212
ResponseCookie cookie = ResponseCookie.from(name, value)
1313
.httpOnly(true)
14-
.secure(false) // 개발 서버 테스트를 위해 임시로 HTTP 허용. 실제 배포 시에는 true로 변경
14+
.secure(true)
1515
.path(path)
1616
.maxAge(maxAge)
17-
.sameSite("Lax")
17+
.sameSite("None")
1818
.build();
1919

2020
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());

0 commit comments

Comments
 (0)