Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -129,19 +129,38 @@ public ResponseEntity<LoginResponse> login(@Valid @RequestBody LoginRequest requ

LoginResponse response = loginService.login(request);

ResponseCookie cookie = ResponseCookie.from("refresh", response.getRefreshToken())
// accessToken์„ HttpOnly ์ฟ ํ‚ค๋กœ ์„ค์ •
ResponseCookie accessCookie = ResponseCookie.from("access", response.getAccessToken())
.httpOnly(true)
.secure(true)
.sameSite("None")
.path("/")
.maxAge(60L * 60 * 24 * 14)
.maxAge(60L * 60) // 1 hour
.build();

// refreshToken์„ HttpOnly ์ฟ ํ‚ค๋กœ ์„ค์ •
ResponseCookie refreshCookie = ResponseCookie.from("refresh", response.getRefreshToken())
.httpOnly(true)
.secure(true)
.sameSite("None")
.path("/")
.maxAge(60L * 60 * 24 * 14) // 2 weeks
.build();
Comment thread
Kosw6 marked this conversation as resolved.

// JSON ์‘๋‹ต์—์„œ ํ† ํฐ ์ œ๊ฑฐ, ์œ ์ € ์ •๋ณด๋งŒ ํฌํ•จ
LoginResponse safeResponse = LoginResponse.builder()
.userId(response.getUserId())
.email(response.getEmail())
.name(response.getName())
.role(response.getRole())
.phoneNumber(response.getPhoneNumber())
.point(response.getPoint())
.build();

return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, cookie.toString())
.header(HttpHeaders.AUTHORIZATION, "Bearer " + response.getAccessToken())
.body(response);
.header(HttpHeaders.SET_COOKIE, accessCookie.toString())
.header(HttpHeaders.SET_COOKIE, refreshCookie.toString())
.body(safeResponse);
}

@Operation(
Expand Down Expand Up @@ -203,8 +222,19 @@ public ResponseEntity<?> reissue(
response.header(HttpHeaders.SET_COOKIE, cookie.toString());
}

// ์‘๋‹ต ๋ฐ˜ํ™˜
return response.body(Map.of("accessToken", tokens.get("accessToken")));
// accessToken์„ HttpOnly ์ฟ ํ‚ค๋กœ ์„ค์ •
ResponseCookie accessCookie = ResponseCookie.from("access", tokens.get("accessToken"))
.httpOnly(true)
.secure(true)
.sameSite("None")
.path("/")
.maxAge(60L * 60) // 1 hour
.build();

response.header(HttpHeaders.SET_COOKIE, accessCookie.toString());
Comment thread
Kosw6 marked this conversation as resolved.

// JSON์—์„œ accessToken ์ œ๊ฑฐ
return response.body(Map.of("message", "ํ† ํฐ ๊ฐฑ์‹  ์„ฑ๊ณต"));

} catch (Exception e) {
log.warn("ํ† ํฐ ์žฌ๋ฐœ๊ธ‰ ์‹คํŒจ: {}", e.getMessage());
Expand All @@ -215,7 +245,7 @@ public ResponseEntity<?> reissue(

@Operation(
summary = "๋กœ๊ทธ์•„์›ƒ API",
description = "Access Token์„ ๋ฌดํšจํ™”ํ•˜๊ณ  Refresh Token ์ฟ ํ‚ค๋ฅผ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค.",
description = "Access Token์„ ๋ฌดํšจํ™”ํ•˜๊ณ  Access/Refresh Token ์ฟ ํ‚ค๋ฅผ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค. ํ† ํฐ์ด ์—†์–ด๋„ ์ •์ƒ์ ์œผ๋กœ ์ฒ˜๋ฆฌ๋ฉ๋‹ˆ๋‹ค.",
responses = {
@ApiResponse(
responseCode = "200",
Expand All @@ -226,45 +256,38 @@ public ResponseEntity<?> reissue(
"message": "๋กœ๊ทธ์•„์›ƒ ์„ฑ๊ณต"
}
"""))
),
@ApiResponse(
responseCode = "400",
description = "Authorization ํ—ค๋” ํ˜•์‹์ด ์ž˜๋ชป๋จ",
content = @Content(mediaType = "application/json",
examples = @ExampleObject(value = """
{
"message": "์ž˜๋ชป๋œ Authorization ํ—ค๋” ํ˜•์‹์ž…๋‹ˆ๋‹ค."
}
"""))
)
}
)
@PostMapping("/logout")
public ResponseEntity<?> logout(
@Parameter(description = "Bearer ํ† ํฐ", example = "Bearer eyJhbGciOiJIUzI1NiJ9...")
@RequestHeader(value = "Authorization", required = false) String authorizationHeader
@Parameter(description = "Access Token ์ฟ ํ‚ค", example = "access=abc123")
@CookieValue(value = "access", required = false) String accessToken,
@Parameter(description = "Refresh Token ์ฟ ํ‚ค", example = "refresh=abc123")
@CookieValue(value = "refresh", required = false) String refreshToken
) {
// ํ—ค๋” ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ
if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) {
return ResponseEntity.badRequest()
.body(Map.of("message", "์ž˜๋ชป๋œ Authorization ํ—ค๋” ํ˜•์‹์ž…๋‹ˆ๋‹ค."));
// ํ† ํฐ์ด ์—†์–ด๋„ ๋กœ๊ทธ์•„์›ƒ ์ฒ˜๋ฆฌ (๋ฉฑ๋“ฑ์„ฑ ๋ณด์žฅ)
if (accessToken != null && !accessToken.isEmpty()) {
try {
loginService.logout(accessToken);
} catch (JwtException | IllegalArgumentException e) {
log.warn("Invalid or expired JWT during logout: {}", e.getMessage());
} catch (Exception e) {
log.error("Unexpected error during logout", e);
}
}
Comment thread
Kosw6 marked this conversation as resolved.

String token = authorizationHeader.substring(7);

// ์˜ˆ์™ธ ์ฒ˜๋ฆฌ ๋ฐ ๋ฉฑ๋“ฑ์„ฑ ๋ณด์žฅ
try {
loginService.logout(token);
} catch (JwtException | IllegalArgumentException e) {
// ์ด๋ฏธ ๋งŒ๋ฃŒ๋˜์—ˆ๊ฑฐ๋‚˜ ์ž˜๋ชป๋œ ํ† ํฐ์ด๋ผ๋„ 200 OK๋กœ ์‘๋‹ต (๋ฉฑ๋“ฑ์„ฑ ๋ณด์žฅ)
log.warn("Invalid or expired JWT during logout: {}", e.getMessage());
} catch (Exception e) {
log.error("Unexpected error during logout", e);
// ๋‚ด๋ถ€ ์˜ˆ์™ธ๋Š” 500์œผ๋กœ ๋ณด๋‚ด์ง€ ์•Š๊ณ  ์•ˆ์ „ํ•˜๊ฒŒ ์ฒ˜๋ฆฌ
}
// Access Token ์ฟ ํ‚ค ์‚ญ์ œ
ResponseCookie deleteAccessCookie = ResponseCookie.from("access", "")
.httpOnly(true)
.secure(true)
.sameSite("None")
.path("/")
.maxAge(0)
.build();

// Refresh Token ์ฟ ํ‚ค ์‚ญ์ œ
ResponseCookie deleteCookie = ResponseCookie.from("refresh", "")
ResponseCookie deleteRefreshCookie = ResponseCookie.from("refresh", "")
.httpOnly(true)
.secure(true)
.sameSite("None")
Expand All @@ -273,7 +296,8 @@ public ResponseEntity<?> logout(
.build();

return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, deleteCookie.toString())
.header(HttpHeaders.SET_COOKIE, deleteAccessCookie.toString())
.header(HttpHeaders.SET_COOKIE, deleteRefreshCookie.toString())
.body(Map.of("message", "๋กœ๊ทธ์•„์›ƒ ์„ฑ๊ณต"));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,6 @@ public void onAuthenticationSuccess(

String providerStr = (String) attrs.get("provider");
String providerUid = (String) attrs.get("providerUid");

if (providerStr == null) {
throw new IllegalStateException("OAuth provider attribute missing from attributes");
}
Expand Down Expand Up @@ -98,7 +97,6 @@ public void onAuthenticationSuccess(

// 4. RefreshToken ์ƒ์„ฑ
String refreshToken = jwtProvider.createRefreshToken(user.getUserId());

// 5. RefreshToken ์ €์žฅ(DB or Redis)
refreshTokenService.saveOrUpdateToken(user.getUserId(), refreshToken);

Expand Down Expand Up @@ -126,23 +124,32 @@ public void onAuthenticationSuccess(


// 6. HttpOnly ์ฟ ํ‚ค๋กœ refreshToken ์ €์žฅ
ResponseCookie accessCookie = ResponseCookie.from("access", accessToken)
ResponseCookie.ResponseCookieBuilder accessCookieBuilder = ResponseCookie.from("access", accessToken)
.httpOnly(true)
.secure(secure) // ๋กœ์ปฌ=false, ๋ฐฐํฌ=true
.sameSite(sameSite) // ๋กœ์ปฌ= "Lax", ๋ฐฐํฌ="None"
.domain(domain)
.path("/")
.maxAge(60L * 60) // 1 hour
.build();
.maxAge(60L * 60); // 1 hour

// ๋กœ์ปฌ ํ™˜๊ฒฝ์—์„œ๋Š” domain ์„ค์ •ํ•˜์ง€ ์•Š์Œ
if (isProd || isDev) {
accessCookieBuilder.domain(domain);
}

ResponseCookie refreshCookie = ResponseCookie.from("refresh", refreshToken)
ResponseCookie.ResponseCookieBuilder refreshCookieBuilder = ResponseCookie.from("refresh", refreshToken)
.httpOnly(true)
.secure(secure)
.sameSite(sameSite)
.domain(domain)
.path("/")
.maxAge(60L * 60 * 24 * 14) // 2 weeks
.build();
.maxAge(60L * 60 * 24 * 14); // 2 weeks

// ๋กœ์ปฌ ํ™˜๊ฒฝ์—์„œ๋Š” domain ์„ค์ •ํ•˜์ง€ ์•Š์Œ
if (isProd || isDev) {
refreshCookieBuilder.domain(domain);
}

ResponseCookie accessCookie = accessCookieBuilder.build();
ResponseCookie refreshCookie = refreshCookieBuilder.build();


response.addHeader(HttpHeaders.SET_COOKIE, accessCookie.toString());
Expand Down
53 changes: 49 additions & 4 deletions frontend/src/components/Header.jsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,46 @@
import styles from './Header.module.css';
import { useNavigate, Link } from 'react-router-dom';
import { useNavigate, useLocation, Link } from 'react-router-dom';
import Logo from '../assets/logo.png';
import { useState, useEffect } from 'react';
import { api } from '../utils/axios';
import { toast } from 'react-toastify';

const Header = ({ isRoot, onToggleSidebar, isOpen }) => {
const nav = useNavigate();
const location = useLocation();
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [loading, setLoading] = useState(true);

// ๋กœ๊ทธ์ธ ์ƒํƒœ ํ™•์ธ - location ๋ณ€๊ฒฝ ์‹œ๋งˆ๋‹ค ์žฌํ™•์ธ
useEffect(() => {
const checkLoginStatus = async () => {
try {
await api.get('/api/user/details');
setIsLoggedIn(true);
} catch (error) {
setIsLoggedIn(false);
} finally {
setLoading(false);
}
};

checkLoginStatus();
}, [location.pathname]);
Comment thread
Kosw6 marked this conversation as resolved.

const logout = async () => {
try {
await api.post('/api/auth/logout');
} catch (error) {
console.log('๋กœ๊ทธ์•„์›ƒ API ํ˜ธ์ถœ ์‹คํŒจ:', error.message);
} finally {
// localStorage ์œ ์ € ์ •๋ณด ์‚ญ์ œ
localStorage.removeItem('user');

setIsLoggedIn(false);
nav('/');
toast.success('๋กœ๊ทธ์•„์›ƒ ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.');
}
Comment thread
Kosw6 marked this conversation as resolved.
};

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

<div className={styles.authLinks}>
<Link to="/login">๋กœ๊ทธ์ธ</Link>
<span>|</span>
<Link to="/signup">ํšŒ์›๊ฐ€์ž…</Link>
{isLoggedIn ? (
<button onClick={logout} className={styles.logoutButton}>
๋กœ๊ทธ์•„์›ƒ
</button>
) : (
<>
<Link to="/login">๋กœ๊ทธ์ธ</Link>
<span>|</span>
<Link to="/signup">ํšŒ์›๊ฐ€์ž…</Link>
</>
)}
</div>
</header>
);
Expand Down
20 changes: 20 additions & 0 deletions frontend/src/components/Header.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,26 @@
opacity: 0.5;
}

.logoutButton {
background: none;
border: none;
color: #ffffff;
font-size: 14px;
cursor: pointer;
padding: 0;
transition: opacity 0.2s ease;
white-space: nowrap;
font-family: inherit;
}

.logoutButton:hover {
opacity: 0.7;
}

.logoutButton:active {
opacity: 0.5;
}

/* ํƒœ๋ธ”๋ฆฟ ์ด์ƒ */
@media (min-width: 768px) {
.header {
Expand Down
Loading