기존에는 소셜 로그인 후 JWT토큰은 URL에 전달했다.
기존에 구현했던 소셜 로그인 방식은 다음과 같은 문제점이 있었다:
@GetMapping("/kakao/callback")
public void kakaoLogin(@RequestParam("code") String code, HttpServletResponse response) throws IOException {
TokenResponse tokens = userService.kakaoLogin(code);
String redirectUrl = String.format(
"https://cheongyakplanet.vercel.app?accessToken=%s&refreshToken=%s",
tokens.getAccessToken(),
tokens.getRefreshToken()
);
response.sendRedirect(redirectUrl);
}
이 방식의 주요 문제점:
오늘 학습한 안전한 소셜 로그인 방법 중 가장 적합한 것은 임시 상태 코드를 사용하는 방식이다.
먼저 토큰을 임시 저장할 서비스 클래스를 구현한다:
@Service
public class TokenCacheService {
// 토큰 정보와 만료 시간을 함께 저장
private static class TokenInfo {
private final TokenResponse tokens;
private final LocalDateTime expiryTime;
public TokenInfo(TokenResponse tokens, int expirySeconds) {
this.tokens = tokens;
this.expiryTime = LocalDateTime.now().plusSeconds(expirySeconds);
}
public boolean isExpired() {
return LocalDateTime.now().isAfter(expiryTime);
}
public TokenResponse getTokens() {
return tokens;
}
}
// 상태 코드를 키로 사용하는 토큰 저장소
private final Map<String, TokenInfo> tokenStore = new ConcurrentHashMap<>();
// 토큰 저장
public void storeTokens(String stateCode, TokenResponse tokens, int expirySeconds) {
tokenStore.put(stateCode, new TokenInfo(tokens, expirySeconds));
}
// 토큰 조회 및 삭제
public TokenResponse getAndRemoveTokens(String stateCode) {
TokenInfo tokenInfo = tokenStore.remove(stateCode);
if (tokenInfo == null || tokenInfo.isExpired()) {
return null;
}
return tokenInfo.getTokens();
}
// 주기적으로 만료된 토큰 정리 (옵션)
@Scheduled(fixedRate = 600000) // 10분마다
public void cleanupExpiredTokens() {
tokenStore.entrySet().removeIf(entry -> entry.getValue().isExpired());
}
}
다음으로 컨트롤러에서 이 서비스를 활용한다:
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/member")
public class UserController {
private final UserService userService;
private final TokenCacheService tokenCacheService;
@GetMapping("/kakao/callback")
public void kakaoLogin(@RequestParam("code") String code, HttpServletResponse response) throws IOException {
// 1. 인증 코드로 카카오 API 호출하여 토큰 획득
TokenResponse tokens = userService.kakaoLogin(code);
// 2. 짧은 임시 코드 생성
String stateCode = UUID.randomUUID().toString().substring(0, 8);
// 3. 인메모리 캐시에 임시 저장 (5분 만료)
tokenCacheService.storeTokens(stateCode, tokens, 300);
// 4. 상태 코드만 리다이렉트에 포함
response.sendRedirect("https://cheongyakplanet.vercel.app/auth?state=" + stateCode);
}
@GetMapping("/auth/exchange")
public ResponseEntity<TokenResponse> exchangeStateForTokens(@RequestParam("state") String stateCode) {
// 5. 상태 코드로 실제 토큰 교환
TokenResponse tokens = tokenCacheService.getAndRemoveTokens(stateCode);
if (tokens == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
return ResponseEntity.ok(tokens);
}
}
프론트엔드에서는 /auth 경로에 다음과 같은 컴포넌트를 구현한다:
// Auth.js
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
function Auth() {
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
// URL에서 상태 코드 가져오기
const urlParams = new URLSearchParams(window.location.search);
const stateCode = urlParams.get('state');
if (!stateCode) {
setError('인증 코드가 없습니다');
setLoading(false);
return;
}
// 백엔드에 상태 코드를 제출하여 실제 토큰 받기
fetch(`https://api.cheongyakplanet.com/api/member/auth/exchange?state=${stateCode}`)
.then(response => {
if (!response.ok) throw new Error('인증 실패');
return response.json();
})
.then(data => {
// 토큰을 안전하게 저장
localStorage.setItem('accessToken', data.accessToken);
localStorage.setItem('refreshToken', data.refreshToken);
// 홈 페이지로 이동
navigate('/');
})
.catch(error => {
console.error('Authentication error:', error);
setError(error.message);
})
.finally(() => {
setLoading(false);
});
}, [navigate]);
if (loading) {
return <div className="loading">인증 처리 중입니다...</div>;
}
if (error) {
return <div className="error">오류: {error}</div>;
}
return null;
}
export default Auth;
백엔드에서 토큰 응답을 위한 DTO 클래스:
@Getter
@AllArgsConstructor
public class TokenResponse {
private String accessToken;
private String refreshToken;
}
또 다른 안전한 방법으로 HTTP-only 쿠키를 사용하는 방식도 배웠다. 이 방식은 XSS 공격에 더 안전하지만, 프론트엔드와 백엔드가 같은 도메인 또는 적절한 CORS 설정이 있어야 한다.
@GetMapping("/kakao/callback")
public void kakaoLogin(@RequestParam("code") String code, HttpServletResponse response) throws IOException {
TokenResponse tokens = userService.kakaoLogin(code);
// HTTP-only 쿠키에 토큰 저장
Cookie accessTokenCookie = new Cookie("accessToken", tokens.getAccessToken());
accessTokenCookie.setHttpOnly(true);
accessTokenCookie.setSecure(true); // HTTPS에서만 전송
accessTokenCookie.setPath("/");
accessTokenCookie.setMaxAge(60 * 60); // 1시간
Cookie refreshTokenCookie = new Cookie("refreshToken", tokens.getRefreshToken());
refreshTokenCookie.setHttpOnly(true);
refreshTokenCookie.setSecure(true);
refreshTokenCookie.setPath("/");
refreshTokenCookie.setMaxAge(24 * 60 * 60); // 1일
response.addCookie(accessTokenCookie);
response.addCookie(refreshTokenCookie);
response.sendRedirect("https://cheongyakplanet.vercel.app");
}
우리 서비스에는 상태 코드를 사용한 토큰 교환 방식이 가장 적합하다고 판단하여 이 방식으로 구현하기로 결정했다. 이 방식은 구현이 비교적 간단하고, 토큰을 URL에 노출시키지 않으면서도 프론트엔드에서 토큰 관리가 용이하다는 장점이 있다.
향후 사용자가 많아지면 토큰 저장소를 Redis로 마이그레이션하는 것을 고려할 수 있다.