우리 서비스에서는 소셜로그인을 지원한다. 일단 카카오 로그인을 구현했고, 추가로 구글 로그인을 구현 예정이다.
소셜로그인에는 크게 두가지 방법이 있다.
1. 백엔드에서 모든 과정을 처리하고 JWT 토큰을 발급하여 리턴 함
2. 프론트엔드와 적절히 역할을 나누어 로그인을 진행하고 JWT 토큰을 발급하여 리턴 함
나는 백엔드에서 모든 과정을 담당하고 JWT 토큰만을 제대로 리턴해주면 프론트에서 할일이 줄어드니까 좋을 것이라고 판단하고 1번 과정대로 구현을 완성했었다.
전체적인 과정은 다음과 같다.
https://kauth.kakao.com/oauth/authorize 이 주소 뒤에 앱 키와 redirect_uri 등을 추가로 담아서 보낸다.
https://kauth.kakao.com/oauth/token
해당 주소로 인가코드를 보내면 access token과 다른 추가적인 정보들이 response 로 온다.
https://kapi.kakao.com/v2/user/me
해당 주소로 access token 을 담아서 보내면 사용자 정보를 response 로 보내준다.
백엔드에서 발급한 JWT 토큰들은 프론트엔드에게 전달해주어야 한다. 내가 아는 방식으로는 크게 두가지가 있는 것 같다.
우리 서비스에서는 2번 방식을 사용하기로 했다. 따라서 다음의 코드를 사용했다. (access token도 다음의 코드를 통해 전송했다.)
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authResult) throws IOException, ServletException {
log.info("로그인 성공 및 JWT 생성");
PrincipalDetails principalDetails = (PrincipalDetails) authResult.getPrincipal();
String username = principalDetails.getName();
String provider = "kakao";
String providerId = principalDetails.getAttributes().get("id").toString();
String uniqueId = provider + "_" + providerId;
String accessToken = tokenProvider.createAccessToken(username, uniqueId, authResult);
String refreshToken = tokenProvider.createRefreshToken(username, uniqueId);
memberService.saveOrUpdateRefreshToken(uniqueId, refreshToken);
// Access Token 쿠키 설정
ResponseCookie accessTokenCookie = ResponseCookie.from("accessToken", accessToken)
.httpOnly(true)
.secure(true) // HTTPS를 사용할 경우에만 true로 설정
.maxAge(24 * 60 * 60) // 24시간
.sameSite("None")
.build();
// Refresh Token 쿠키 설정
ResponseCookie refreshTokenCookie = ResponseCookie.from("refreshToken", refreshToken)
.httpOnly(true)
.secure(true) // HTTPS를 사용할 경우에만 true로 설정
.maxAge(14 * 24 * 60 * 60) // 2주
.sameSite("None")
.build();
// 쿠키를 응답 헤더에 추가
response.addHeader(HttpHeaders.SET_COOKIE, accessTokenCookie.toString());
response.addHeader(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString());
log.info("Set-Cookie headers: {}", response.getHeaders(HttpHeaders.SET_COOKIE));
log.info("Response headers: {}", response.getHeaderNames());
// CORS 설정을 위해 헤더 추가
response.addHeader("Access-Control-Allow-Origin", "https://main--testtig.netlify.app/");
response.addHeader("Access-Control-Allow-Credentials", "true");
response.addHeader("Access-Control-Expose-Headers", "Set-Cookie");
response.addHeader("Access-Control-Expose-Headers", "Authorization");
response.sendRedirect("https://main--testtig.netlify.app/");
}
하지만 문제가 있었다.
백엔드 주소는 https://api.tigleisure.com 이고 개발을 진행중인 프론트엔드의 주소는 https://main--testtig.netlify.app 이다. 따라서 same-site 정책에 의해 쿠키 전송이 안된다!
쿠키를 생성, 전송 한 뒤 response.sendRedirect(); 를 통해 프론트엔드 주소로 리다이렉트를 시켜주니 모든 쿠키가 초기화 되고 아무것도 없었다.
결론적으로 쿠키에 at와 rt 를 담아서 전송해주려 했지만 실패했다. (거의 2-3일 정도 이걸로 프론트분들과 대토론회를 펼쳤다...)
그래서 우리가 내린 결론은 소셜로그인 방식을 바꾸자! 였다.
기존에는 백엔드에서 Spring security를 사용하여 카카오 로그인의 모든 과정을 담당했었다. 하지만 이렇게 되면 생성된 토큰을 쿠키에 담아서 보내줄 수 없었기 때문에 소셜로그인을 분담해서 진행하기로 했다.
맨 위에서 언급했던 두번째 방식을 진행하였다. 프론트엔드가 진행한 부분은 FE로, 백엔드에서 진행한 부분은 (BE)로 작성하였다.
- (FE) https://kauth.kakao.com/oauth/authorize 이 주소로 필수적인 parameter를 담아서 요청을 보내 인가코드를 얻어온다.
- (FE) 받아온 인가코드를 https://api.tigleisure.com/callback?code={KAKAO_AUTH_CODE} 의 형식으로 백엔드에게 전송한다.
- (BE) /callback 에 대한 api를 뚫어두고 인가코드를 받아와서 카카오에게 access token 발급 요청을 날린다. (여기서 말하는 access token은 JWT at가 아니라 카카오 서버에서 발급해주는 at이다.)
- (BE) 받아온 at를 토대로 다시 카카오 서버에 사용자 정보를 요청한다.
- (BE) 받아온 사용자 정보가 DB에 존재한다면 이미 로그인 했던 사용자 이므로 at와 rt를 생성하여 전달해주고, DB에 존재하지 않는다면 DB에 저장해줌과 동시에 at와 rt를 생성해서 전달해준다.
여기서 중요한 점은 at와 rt를 그래서 어떻게 넘겼냐? 이다.
결론부터 말하자면 at는 response body에, rt는 쿠키에 담아서 전송했다.
이전에는 되지 않던 쿠키 전송이 왜 되는 것일까? 답은 FE에서 BE로의 요청이 있기 때문이다.
FE가 인가코드를 받아오고 해당 인가코드를 BE에 넘기는 요청 에 대한 응답으로 쿠키를 전송해주면 되는 것이다.
코드를 보자.
@RequestMapping("/callback")
public ResponseEntity<ApiResponse<LoginAccessTokenResponseDto>> callback(HttpServletRequest request,
@RequestParam("code") String code,
HttpServletResponse response) throws IOException {
String kakaoAccessToken = kakaoService.getAccessTokenFromKakaoDeploy(code);
KakaoUserInfoResponseDto userInfo = kakaoService.getUserInfo(kakaoAccessToken);
LoginMemberResponseDto member = memberService.createKakaoMember(userInfo);
// Refresh Token 쿠키 설정
ResponseCookie refreshTokenCookie = ResponseCookie.from("refreshToken", member.getRefreshToken())
.httpOnly(true)
.path("/")
.secure(true) // HTTPS를 사용할 경우에만 true로 설정
.maxAge(14 * 24 * 60 * 60) // 2주
.sameSite("None")
.build();
// 쿠키를 응답 헤더에 추가
response.addHeader(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString());
LoginAccessTokenResponseDto loginAccessTokenResponseDto = LoginAccessTokenResponseDto.fromMember(member.getAccessToken());
ApiResponse<LoginAccessTokenResponseDto> result = ApiResponse.of(200, "Login Success", loginAccessTokenResponseDto);
return ResponseEntity.ok(result);
}
코드에서 볼 수 있듯이 인가코드를 요청하는 api 에서 쿠키를 담아서 respones 로 보내주고 있다.
위에서 얘기했듯이 at는 DTO 클래스에 담아서 response body에 보내주었다.
AT는 왜 response body에?
기본적으로 쿠키에 담으면서 httpOnly 설정을 해주면 javascript로 쿠키에 접근할 수 없다. 따라서 XSS 공격에 대비할 수 있다.
하지만 로그아웃시, at를 만료시켜주기 위해 프론트에서 at에 대한 접근을 하고 싶어 했다. 따라서 response body에 담아서 전송해주었다.
좋은 방향으로 리팩토링 한다면 다음의 방법이 있다.
at도 쿠키로 전송을 하고, 로그아웃 시 프론트에서 at를 만료시켜줄 수 없기 때문에 redis blakclist에 해당 at를 담아두고 사용못하게 막는것이다.
이전에 경험했던 로그인과는 달리 새로운 부분에서 conflict를 겪어 좀 힘들었지만 진짜 얻어가는게 많았던 과정이었다. 쿠키에 대한 이해도 + JWT 토큰의 보안 이슈 + redis 사용하는 방식까지 얻어갈 수 있었다!
헤이 니X~