롤링페이퍼 사이트를 개발하는 프로젝트를 진행하는 도중 문제가 발생하였다.
소셜로그인과 JWT토큰 생성하는 순서까지 기본 CRUD을 지원하는 게시판을 만드는 것은 별다른 어려움 없이 진행하였다.
But... 이제 프론트 즉 클라이언트와 API 통신을 하는 첫 과정인 로그인 이후 유저 정보를 받아오는 것이
클라이언트 측에서 진행 되지 않았다. 해결기를 작성해보자 드가자!!!!!!!
OAuth2 소셜 로그인 개발 일지: JWT 토큰 전달 문제 해결기
💡 구현하고자 했던 것
목표
- 카카오 소셜 로그인 구현
- 로그인 성공 시 JWT 토큰 발급
- 발급된 토큰을 프론트엔드에 전달
초기 구현 계획
Client->>Backend: 1. 카카오 로그인 버튼 클릭
Backend->>Kakao: 2. 인증 요청
Kakao->>Client: 3. 로그인 페이지
Client->>Kakao: 4. 로그인
Kakao->>Backend: 5. 인증 코드 전달
Backend->>Kakao: 6. 액세스 토큰 요청
Kakao->>Backend: 7. 액세스 토큰 전달
Backend->>Backend: 8. JWT 토큰 생성
Backend->>Client: 9. JWT 토큰 전달 (여기서 문제 발생!)
🔨 문제 해결 과정
첫 번째 시도: 헤더에 담아 리다이렉트
@Override
public void onAuthenticationSuccess(...) {
String accessToken = jwtUtil.createAccessToken(...);
String refreshToken = jwtUtil.createRefreshToken(...);
response.setHeader("Authorization", "Bearer " + accessToken);
response.setHeader("RefreshToken", refreshToken);
response.sendRedirect("<http://localhost:3000/callback>");
}
결과: 실패
- 프론트엔드에서 토큰을 받지 못함
- 리다이렉트는 성공했지만 헤더가 비어있음
이때 배운 것:
- Same-Origin Policy
- 브라우저의 보안 정책으로, 다른 출처로의 요청 시 제한이 있음
- 프로토콜, 도메인, 포트 중 하나라도 다르면 '다른 출처'
백엔드: <http://localhost:8080> 프론트: <http://localhost:3000> => 포트가 달라서 다른 출처! - 리다이렉트의 특성
- 새로운 HTTP 요청을 발생시킴
- 이전 요청의 헤더는 유지되지 않음
두 번째 시도: Response Body에 담기
public void onAuthenticationSuccess(...) {
Map<String, String> tokens = new HashMap<>();
tokens.put("accessToken", accessToken);
tokens.put("refreshToken", refreshToken);
response.getWriter().write(new ObjectMapper().writeValueAsString(tokens));
}
결과: 실패
- 리다이렉트가 필요한 상황에서 body 데이터 전달 불가
- 프론트엔드에서 데이터를 받을 수 없음
이때 배운 것:
- 리다이렉트와 response body는 함께 사용할 수 없음
- 리다이렉트는 3xx 상태 코드를 사용하며, body 내용은 무시됨
세 번째 시도: Query Parameter 사용 (성공!!🙆)
String redirectUrl = String.format(
"<http://localhost:3000/callback?token=%s&refreshToken=%s>",
accessToken, refreshToken
);
response.sendRedirect(redirectUrl);
장점:
- 구현이 간단함
- 프론트엔드에서 쉽게 접근 가능
단점:
- URL에 토큰이 노출됨
- 서버 로그에 토큰이 남을 수 있음
- 보안상 취약
이때 배운 것:
- JWT 토큰은 민감한 정보를 포함할 수 있음
- URL 파라미터는 서버 로그, 브라우저 히스토리에 남음
- 보안적으로 안전한 전달 방식의 중요성
네 번째 시도: URL Fragment 사용(성공!!🙆)
String targetUrl = UriComponentsBuilder
.fromUriString("<http://localhost:3000/oauth/callback>")
.fragment("token=" + accessToken + "&refreshToken=" + refreshToken)
.build().toUriString();
장점:
- 서버 로그에 토큰이 남지 않음
- URL 히스토리에도 남지 않음
- Query Parameter보다 안전
이때 배운 것:
- URL Fragment(#)는 서버로 전송되지 않음
- 클라이언트 측에서만 처리되는 데이터
- 보안과 기능의 균형 맞추기

다섯 번째 시도: Callback URl 경로에 직접 매핑
@GetMapping("/login/oauth2/code/kakao")// 동작하지 않음!
public ResponseEntity<?> handleCallback() {
return ResponseEntity.ok()
.header("Authorization", "Bearer " + accessToken)
.build();
}
결과: 실패
- 컨트롤러가 호출되지 않음
이때 배운 것:
- Spring Security의 OAuth2 필터 동작 방식
public class OAuth2LoginAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
// 이 필터가 해당 URL 패턴을 모두 처리함
ㅈ public static final String DEFAULT_FILTER_PROCESSES_URI = "/login/oauth2/code/*";
}
- /login/oauth2/code/* 경로는 OAuth2 필터가 먼저 가로챔
- 인증 코드 없는 일반 GET 요청은 인증 실패 처리
향후 개선 계획: 별도 API 엔드포인트
// OAuth2 성공 핸들러
@Component
public class CustomSuccessHandler implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(...) {
// OAuth2 인증 완료 후 우리의 API로 리다이렉트
response.sendRedirect("/api/oauth/kakao/token");
}
}
// 토큰 발급용 별도 API
@RestController
public class AuthController {
@GetMapping("/api/oauth/kakao/token")// OAuth2 필터와 충돌하지 않는 경로
public ResponseEntity<?> handleToken(Authentication authentication) {
return ResponseEntity.ok()
.header("Authorization", "Bearer " + accessToken)
.header("RefreshToken", refreshToken)
.build();
}
}
장점:
- OAuth2 필터와 완전히 분리된 경로 사용
- REST API 표준에 맞는 구현
- 헤더를 통한 안전한 토큰 전달
- 필터 체인의 순서를 고려한 설계
📍 현재 구현 상태와 향후 계획
현재 구현
- URL Fragment 방식으로 구현
- 토큰이 URL에 노출되지 않으면서도 안전하게 전달
향후 개선 계획
- 별도 API 엔드포인트 방식으로 전환 검토
- 토큰 관리 로직 강화
- 보안성 강화
배운 점
- OAuth2와 JWT 토큰의 전달 방식은 여러 가지가 있음
- 각 방식의 장단점을 이해하고 상황에 맞게 선택해야 함
- 브라우저의 보안 정책(Same-Origin Policy)를 이해하는 것이 중요
- Spring Security의 필터 동작 방식 이해가 필요
- 보안과 사용성의 균형을 고려해야 함
🙇♂️ 마무리
Security와 관련된 문제는 각 구현 방식의 장단점을 이해하고 상황에 맞는 최적의 방법을 선택하는 것이 중요
Same-Origin Policy, 리다이렉트의 특성, OAuth2 필터의 동작 방식 등을 이해하고 나니, 문제 해결의 실마리를 찾을 수 있었다.
현재는 URL Fragment 방식으로 구현했지만, 향후 별도 API 엔드포인트 방식으로의 개선을 고려하고 있는 중이다.
'Project > Sparkle-Note' 카테고리의 다른 글
| [Project] Spakrle Note / JWT 토큰 내 사용자 정보 개선하기 (2) | 2024.11.12 |
|---|---|
| [Project] Sparkle Note / 사용자 인증 정보 구분하기 (1) | 2024.11.12 |
| [Project] Sparkle-Note / 데일리로그 (0) | 2024.10.25 |
| [Project] Sparkle-Note / 데일리로그 (1) | 2024.10.14 |
| [Project] Sparkle-Note / 데일리 로그 (1) | 2024.10.08 |