Skip to content

Commit 6b5b0c4

Browse files
authored
BE,FE/fix login token cookie auth (#203)
* [BE] fix:쿠키기반 인증 변경, 로그아웃 처리 수정 * fix:쿠키 기반 변경 및 로그인/로그아웃 버튼 수정
1 parent 253a973 commit 6b5b0c4

9 files changed

Lines changed: 258 additions & 164 deletions

File tree

backend/src/main/java/org/sejongisc/backend/auth/controller/AuthController.java

Lines changed: 62 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -129,19 +129,38 @@ public ResponseEntity<LoginResponse> login(@Valid @RequestBody LoginRequest requ
129129

130130
LoginResponse response = loginService.login(request);
131131

132-
ResponseCookie cookie = ResponseCookie.from("refresh", response.getRefreshToken())
132+
// accessToken을 HttpOnly 쿠키로 설정
133+
ResponseCookie accessCookie = ResponseCookie.from("access", response.getAccessToken())
133134
.httpOnly(true)
134135
.secure(true)
135136
.sameSite("None")
136137
.path("/")
137-
.maxAge(60L * 60 * 24 * 14)
138+
.maxAge(60L * 60) // 1 hour
138139
.build();
139140

141+
// refreshToken을 HttpOnly 쿠키로 설정
142+
ResponseCookie refreshCookie = ResponseCookie.from("refresh", response.getRefreshToken())
143+
.httpOnly(true)
144+
.secure(true)
145+
.sameSite("None")
146+
.path("/")
147+
.maxAge(60L * 60 * 24 * 14) // 2 weeks
148+
.build();
149+
150+
// JSON 응답에서 토큰 제거, 유저 정보만 포함
151+
LoginResponse safeResponse = LoginResponse.builder()
152+
.userId(response.getUserId())
153+
.email(response.getEmail())
154+
.name(response.getName())
155+
.role(response.getRole())
156+
.phoneNumber(response.getPhoneNumber())
157+
.point(response.getPoint())
158+
.build();
140159

141160
return ResponseEntity.ok()
142-
.header(HttpHeaders.SET_COOKIE, cookie.toString())
143-
.header(HttpHeaders.AUTHORIZATION, "Bearer " + response.getAccessToken())
144-
.body(response);
161+
.header(HttpHeaders.SET_COOKIE, accessCookie.toString())
162+
.header(HttpHeaders.SET_COOKIE, refreshCookie.toString())
163+
.body(safeResponse);
145164
}
146165

147166
@Operation(
@@ -203,8 +222,19 @@ public ResponseEntity<?> reissue(
203222
response.header(HttpHeaders.SET_COOKIE, cookie.toString());
204223
}
205224

206-
// 응답 반환
207-
return response.body(Map.of("accessToken", tokens.get("accessToken")));
225+
// accessToken을 HttpOnly 쿠키로 설정
226+
ResponseCookie accessCookie = ResponseCookie.from("access", tokens.get("accessToken"))
227+
.httpOnly(true)
228+
.secure(true)
229+
.sameSite("None")
230+
.path("/")
231+
.maxAge(60L * 60) // 1 hour
232+
.build();
233+
234+
response.header(HttpHeaders.SET_COOKIE, accessCookie.toString());
235+
236+
// JSON에서 accessToken 제거
237+
return response.body(Map.of("message", "토큰 갱신 성공"));
208238

209239
} catch (Exception e) {
210240
log.warn("토큰 재발급 실패: {}", e.getMessage());
@@ -215,7 +245,7 @@ public ResponseEntity<?> reissue(
215245

216246
@Operation(
217247
summary = "로그아웃 API",
218-
description = "Access Token을 무효화하고 Refresh Token 쿠키를 삭제합니다.",
248+
description = "Access Token을 무효화하고 Access/Refresh Token 쿠키를 삭제합니다. 토큰이 없어도 정상적으로 처리됩니다.",
219249
responses = {
220250
@ApiResponse(
221251
responseCode = "200",
@@ -226,45 +256,38 @@ public ResponseEntity<?> reissue(
226256
"message": "로그아웃 성공"
227257
}
228258
"""))
229-
),
230-
@ApiResponse(
231-
responseCode = "400",
232-
description = "Authorization 헤더 형식이 잘못됨",
233-
content = @Content(mediaType = "application/json",
234-
examples = @ExampleObject(value = """
235-
{
236-
"message": "잘못된 Authorization 헤더 형식입니다."
237-
}
238-
"""))
239259
)
240260
}
241261
)
242262
@PostMapping("/logout")
243263
public ResponseEntity<?> logout(
244-
@Parameter(description = "Bearer 토큰", example = "Bearer eyJhbGciOiJIUzI1NiJ9...")
245-
@RequestHeader(value = "Authorization", required = false) String authorizationHeader
264+
@Parameter(description = "Access Token 쿠키", example = "access=abc123")
265+
@CookieValue(value = "access", required = false) String accessToken,
266+
@Parameter(description = "Refresh Token 쿠키", example = "refresh=abc123")
267+
@CookieValue(value = "refresh", required = false) String refreshToken
246268
) {
247-
// 헤더 유효성 검사
248-
if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) {
249-
return ResponseEntity.badRequest()
250-
.body(Map.of("message", "잘못된 Authorization 헤더 형식입니다."));
269+
// 토큰이 없어도 로그아웃 처리 (멱등성 보장)
270+
if (accessToken != null && !accessToken.isEmpty()) {
271+
try {
272+
loginService.logout(accessToken);
273+
} catch (JwtException | IllegalArgumentException e) {
274+
log.warn("Invalid or expired JWT during logout: {}", e.getMessage());
275+
} catch (Exception e) {
276+
log.error("Unexpected error during logout", e);
277+
}
251278
}
252279

253-
String token = authorizationHeader.substring(7);
254-
255-
// 예외 처리 및 멱등성 보장
256-
try {
257-
loginService.logout(token);
258-
} catch (JwtException | IllegalArgumentException e) {
259-
// 이미 만료되었거나 잘못된 토큰이라도 200 OK로 응답 (멱등성 보장)
260-
log.warn("Invalid or expired JWT during logout: {}", e.getMessage());
261-
} catch (Exception e) {
262-
log.error("Unexpected error during logout", e);
263-
// 내부 예외는 500으로 보내지 않고 안전하게 처리
264-
}
280+
// Access Token 쿠키 삭제
281+
ResponseCookie deleteAccessCookie = ResponseCookie.from("access", "")
282+
.httpOnly(true)
283+
.secure(true)
284+
.sameSite("None")
285+
.path("/")
286+
.maxAge(0)
287+
.build();
265288

266289
// Refresh Token 쿠키 삭제
267-
ResponseCookie deleteCookie = ResponseCookie.from("refresh", "")
290+
ResponseCookie deleteRefreshCookie = ResponseCookie.from("refresh", "")
268291
.httpOnly(true)
269292
.secure(true)
270293
.sameSite("None")
@@ -273,7 +296,8 @@ public ResponseEntity<?> logout(
273296
.build();
274297

275298
return ResponseEntity.ok()
276-
.header(HttpHeaders.SET_COOKIE, deleteCookie.toString())
299+
.header(HttpHeaders.SET_COOKIE, deleteAccessCookie.toString())
300+
.header(HttpHeaders.SET_COOKIE, deleteRefreshCookie.toString())
277301
.body(Map.of("message", "로그아웃 성공"));
278302
}
279303

backend/src/main/java/org/sejongisc/backend/common/auth/config/OAuth2SuccessHandler.java

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,6 @@ public void onAuthenticationSuccess(
6464

6565
String providerStr = (String) attrs.get("provider");
6666
String providerUid = (String) attrs.get("providerUid");
67-
6867
if (providerStr == null) {
6968
throw new IllegalStateException("OAuth provider attribute missing from attributes");
7069
}
@@ -98,7 +97,6 @@ public void onAuthenticationSuccess(
9897

9998
// 4. RefreshToken 생성
10099
String refreshToken = jwtProvider.createRefreshToken(user.getUserId());
101-
102100
// 5. RefreshToken 저장(DB or Redis)
103101
refreshTokenService.saveOrUpdateToken(user.getUserId(), refreshToken);
104102

@@ -126,23 +124,32 @@ public void onAuthenticationSuccess(
126124

127125

128126
// 6. HttpOnly 쿠키로 refreshToken 저장
129-
ResponseCookie accessCookie = ResponseCookie.from("access", accessToken)
127+
ResponseCookie.ResponseCookieBuilder accessCookieBuilder = ResponseCookie.from("access", accessToken)
130128
.httpOnly(true)
131129
.secure(secure) // 로컬=false, 배포=true
132130
.sameSite(sameSite) // 로컬= "Lax", 배포="None"
133-
.domain(domain)
134131
.path("/")
135-
.maxAge(60L * 60) // 1 hour
136-
.build();
132+
.maxAge(60L * 60); // 1 hour
133+
134+
// 로컬 환경에서는 domain 설정하지 않음
135+
if (isProd || isDev) {
136+
accessCookieBuilder.domain(domain);
137+
}
137138

138-
ResponseCookie refreshCookie = ResponseCookie.from("refresh", refreshToken)
139+
ResponseCookie.ResponseCookieBuilder refreshCookieBuilder = ResponseCookie.from("refresh", refreshToken)
139140
.httpOnly(true)
140141
.secure(secure)
141142
.sameSite(sameSite)
142-
.domain(domain)
143143
.path("/")
144-
.maxAge(60L * 60 * 24 * 14) // 2 weeks
145-
.build();
144+
.maxAge(60L * 60 * 24 * 14); // 2 weeks
145+
146+
// 로컬 환경에서는 domain 설정하지 않음
147+
if (isProd || isDev) {
148+
refreshCookieBuilder.domain(domain);
149+
}
150+
151+
ResponseCookie accessCookie = accessCookieBuilder.build();
152+
ResponseCookie refreshCookie = refreshCookieBuilder.build();
146153

147154

148155
response.addHeader(HttpHeaders.SET_COOKIE, accessCookie.toString());

frontend/src/components/Header.jsx

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,46 @@
11
import styles from './Header.module.css';
2-
import { useNavigate, Link } from 'react-router-dom';
2+
import { useNavigate, useLocation, Link } from 'react-router-dom';
33
import Logo from '../assets/logo.png';
4+
import { useState, useEffect } from 'react';
5+
import { api } from '../utils/axios';
6+
import { toast } from 'react-toastify';
47

58
const Header = ({ isRoot, onToggleSidebar, isOpen }) => {
69
const nav = useNavigate();
10+
const location = useLocation();
11+
const [isLoggedIn, setIsLoggedIn] = useState(false);
12+
const [loading, setLoading] = useState(true);
13+
14+
// 로그인 상태 확인 - location 변경 시마다 재확인
15+
useEffect(() => {
16+
const checkLoginStatus = async () => {
17+
try {
18+
await api.get('/api/user/details');
19+
setIsLoggedIn(true);
20+
} catch (error) {
21+
setIsLoggedIn(false);
22+
} finally {
23+
setLoading(false);
24+
}
25+
};
26+
27+
checkLoginStatus();
28+
}, [location.pathname]);
29+
30+
const logout = async () => {
31+
try {
32+
await api.post('/api/auth/logout');
33+
} catch (error) {
34+
console.log('로그아웃 API 호출 실패:', error.message);
35+
} finally {
36+
// localStorage 유저 정보 삭제
37+
localStorage.removeItem('user');
38+
39+
setIsLoggedIn(false);
40+
nav('/');
41+
toast.success('로그아웃 되었습니다.');
42+
}
43+
};
744

845
return (
946
<header className={`${styles.header} ${isRoot ? styles.transparent : ''}`}>
@@ -25,9 +62,17 @@ const Header = ({ isRoot, onToggleSidebar, isOpen }) => {
2562
</div>
2663

2764
<div className={styles.authLinks}>
28-
<Link to="/login">로그인</Link>
29-
<span>|</span>
30-
<Link to="/signup">회원가입</Link>
65+
{isLoggedIn ? (
66+
<button onClick={logout} className={styles.logoutButton}>
67+
로그아웃
68+
</button>
69+
) : (
70+
<>
71+
<Link to="/login">로그인</Link>
72+
<span>|</span>
73+
<Link to="/signup">회원가입</Link>
74+
</>
75+
)}
3176
</div>
3277
</header>
3378
);

frontend/src/components/Header.module.css

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,26 @@
9191
opacity: 0.5;
9292
}
9393

94+
.logoutButton {
95+
background: none;
96+
border: none;
97+
color: #ffffff;
98+
font-size: 14px;
99+
cursor: pointer;
100+
padding: 0;
101+
transition: opacity 0.2s ease;
102+
white-space: nowrap;
103+
font-family: inherit;
104+
}
105+
106+
.logoutButton:hover {
107+
opacity: 0.7;
108+
}
109+
110+
.logoutButton:active {
111+
opacity: 0.5;
112+
}
113+
94114
/* 태블릿 이상 */
95115
@media (min-width: 768px) {
96116
.header {

0 commit comments

Comments
 (0)