본문 바로가기
Library&Framework/시큐리티

[OAuth2 Client] Handling HttpOnly Refresh Tokens on the Client-Side: How to Mana

by 우지uz 2024. 7. 12.

https://ksw4060.tistory.com/210

[React Client, Spring Boot Server] Spring Security와 OAuth2 Client를 활용한 SPA 웹 애플리케이션 로그인 시스템

쇼핑몰을 만드는 팀 프로젝트를 진행하면서, 일반 회원 및 소셜로그인 을 구현 했으며JWT 토큰을 활용한 로그인에 대해 포스팅 하려고 합니다.특히나 보안을 신경 쓰기 위해서, 소셜로그인과 같

ksw4060.tistory.com

이전 포스팅에 이어서, HttpOnly 리프레시 토큰으로 엑세스토큰을 발행하는 구현방법에 대해 따로 다루려고 합니다. 
관련 참고 문헌과 자료 및 후기는 노션에 기록하였습니다. 
저는 "ODDShop 쇼핑몰 사이드 프로젝트"에서 Spring Security 를 통한 인증, OAuth2 Client 를 통한 소셜 로그인 인가 작업을 구현하기 위해서 Spring Security 의 내부 구조와 JWT, Session 동작 방식을 먼저 학습하고,
WAS 안에 있는 수많은 Filter 와, 그 안에 시큐리티 필터를 먼저 거치고 
스프링 컨테이너에 등록된 서블릿 컨테이너와 컨트롤러 내 API 등을 들리게 된다는 것을 이해했습니다.

인가 작업은, 이메일과 비밀번호를 통한 일반 로그인과 
OAuth2 Client 소셜로그인을 통해서 구현 했지만
이번 포스팅에서는, OAuth2 Client 소셜로그인 중에서
백엔드 권한의 CustomLoginSuccessHandlerV1 등록을 통해서

public class CustomLoginSuccessHandlerV1 extends SimpleUrlAuthenticationSuccessHandler

클라이언트에서는 Href 하이퍼링크를 통한 요청밖에 할 수 없는 상황일 때,
어떻게 엑세스 토큰과, 리프레쉬 토큰에 대한 Response 를 받아
클라이언트에서 유저의 로그인(인가) 작업을 진행할 수 있는 지에 대해 다뤄 보겠습니다.
이전 포스팅에서, 구현 절차와 인증 방식에 대해서 이미 다뤘기 때문에
이번 포스팅에서는, 서버로 부터 클라이언트에게 전달한 HttpOnly 리프레쉬 토큰을 통해서
어떻게 클라이언트에서는 HttpOnly 리프레쉬 토큰을 전달받았음을 인지하고
로그인에 성공했을 때, HttpOnly 리프레쉬 토큰을 통해서 엑세스토큰을 발급 받을 것인지 알아보겠습니다.


클라이언트에서는 axios 나 fetch api 로의 request, response 가 불가능한, Href 링크를 통해 소셜 로그인을 진행하고 있으며


다음과 같은 하이퍼링크를 통해서, 소셜로그인에 대한 요청을 서버에게 전달하고 있습니다.
https://백엔드도메인/oauth2/authorization/kakao
https://백엔드도메인/oauth2/authorization/naver
https://백엔드도메인/oauth2/authorization/google
서버에서는 클라이언트의 하이퍼링크를 통한 엑세스 요청을 먼저 받고
서버는 카카오, 네이버, 구글 리소스 서버에 Sign In 을 요청합니다.
이후 서버에 등록된 Redirect Uri 를 통해서 , redirected 됩니다. 

spring:
  # oauth2 관련 설정
  security:
    oauth2:
      client:
        registration:
          naver:
            client-name: naver
            client-id: ${NAVER_CLIENT_ID}
            client-secret: ${NAVER_CLIENT_SECRET}
            redirect-uri: ${LOCAL_NAVER_REDIRECT_URI}
            authorization-grant-type: authorization_code
            scope: name,email
          kakao:
            client-name: kakao
            client-id: ${KAKAO_CLIENT_ID}
            client-secret: ${KAKAO_SECRET}
            client-authentication-method: ${KAKAO_AUTHENTICATION_METHOD}
            redirect-uri: ${LOCAL_KAKAO_REDIRECT_URI}
            authorization-grant-type: authorization_code
            scope: profile_nickname, account_email
          google:
            client-name: google
            client-id: ${GOOGLE_CLIENT_ID}
            client-secret: ${GOOGLE_CLIENT_SECRET}
            redirect-uri: ${LOCAL_GOOGLE_REDIRECT_URI}
            authorization-grant-type: authorization_code
            scope: profile,email

로컬환경에서의 환경변수와, 배포된 서버에서 깃허브 시크릿을 통한 환경변수를 등록하여
서버와 로컬환경에서의 환경변수를 분리했습니다.

package PU.pushop.global.authentication.oauth2.handler;


import PU.pushop.global.authentication.jwts.utils.JWTUtil;
import PU.pushop.global.authentication.oauth2.custom.entity.CustomOAuth2User;
import PU.pushop.members.entity.Member;
import PU.pushop.members.repository.MemberRepositoryV1;
import PU.pushop.members.service.RefreshService;
import com.google.gson.JsonObject;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import org.springframework.beans.factory.annotation.Value;

import java.io.IOException;
import java.util.Collection;
import java.util.Iterator;

import static PU.pushop.global.authentication.jwts.utils.CookieUtil.createCookie;

@Component
@RequiredArgsConstructor
@Slf4j
public class CustomLoginSuccessHandlerV1 extends SimpleUrlAuthenticationSuccessHandler {

    private final JWTUtil jwtUtil;
    private final MemberRepositoryV1 memberRepositoryV1;
    private final RefreshService refreshService;

    private Long accessTokenExpirationPeriod = 60L * 30; // 30 분
    private Long refreshTokenExpirationPeriod = 3600L * 24 * 7; // 7일

    @Value("${frontend.url}")
    private String frontendUrl;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {

        CustomOAuth2User oAuth2User = (CustomOAuth2User) authentication.getPrincipal();
        String email = oAuth2User.getName();
        String role = extractOAuthRole(authentication);
        String socialId = oAuth2User.getSocialId();

        log.info("소셜로그인 유저 = " + email);
        // ============= RefreshToken 생성 시, memberId 가 필요 ==============
        Member requestMember = memberRepositoryV1.findBySocialId(socialId)
                .orElseThrow(() -> new UsernameNotFoundException("해당 socialId 을 가진 멤버가 존재하지 않습니다."));
        // 토큰을 생성하는 부분 .
        String refreshToken = jwtUtil.createRefreshToken("refresh", String.valueOf(requestMember.getId()), role);

        // 리프레쉬 토큰 - DB 에 자징합니다.
        refreshService.saveOrUpdateRefreshEntity(requestMember, refreshToken);

        // 리프레시 토큰을 쿠키에 저장합니다.
        response.addCookie(createCookie("refreshAuthorization", "Bearer+" +refreshToken));
        response.setStatus(HttpStatus.OK.value());

        // frontendUrl을 사용하여 리디렉션 URL을 구성
        response.sendRedirect(frontendUrl + "?redirectedFromSocialLogin=true");
    }

    private static String extractOAuthRole(Authentication authentication) {
        Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
        Iterator<? extends GrantedAuthority> iterator = authorities.iterator();
        GrantedAuthority auth = iterator.next();

        String role = auth.getAuthority();
        return role;
    }
    
    public static Cookie createCookie(String key, String value) {

        Cookie cookie = new Cookie(key, value);
        // 만료 기간을 설정하지 않음으로써 세션 쿠키를 사용. 퍼시스턴트 쿠키를 사용하지 않음.
        cookie.setMaxAge(60*60*60);
        cookie.setSecure(true);
        cookie.setPath("/");
        cookie.setHttpOnly(true);

        return cookie;
    }

}

이후 redirect uri 까지 모든 과정이 성공적으로 진행된다면

CustomLoginSuccessHandlerV1

를 통해서, 클라이언트에게 HttpOnly 리프레쉬 토큰 쿠키를 전달합니다. 
이후

frontendUrl + "?redirectedFromSocialLogin=true"

로 Redirect 됩니다. 이 부분이 핵심입니다.
 
클라이언트는 HttpOnly 리프레쉬 토큰 쿠키를 전달 받으면서, 메인페이지로 리다이렉트 될때 
redirectedFromSocialLogin 라는 Query Parameter 를 전달받습니다.
그러면, 클라이언트에서는 Query Parameter 를 전달받았다는 것을 
어떻게 눈치채면 될까요 ?
말 그대로, Query Parameter 에 redirectedFromSocialLogin 가 존재하는지, 존재하지 않는지를
리액트 훅 useEffect 를 통해서 체크해주면 됩니다.
 
저는 먼저, 토큰을 관리하는 유틸클래스를 정의했습니다.
아래의 코드를 참고하시면 됩니다.
HttpOnly Cookie 는 자바스크립트를 통해 get , delete, set 이 불가능합니다. 
무조건 클라이언트 <-> 서버와의 전달을 통해서 사용가능하며
전달하기 위해서는,
withCredentials: true, 를 세번째 인자로 추가해줘야 합니다.

import { useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import axios from "axios";

const ACCESS_TOKEN_EXPIRATION_PERIOD = 10 * 60 * 1000; // 10분 (단위: 밀리초)

// Access token을 주기적으로 갱신하는 컴포넌트
export const PeriodicAccessTokenRefresher = () => {
  const requestCount = useRef(0);
  const navigate = useNavigate();

  useEffect(() => {
    const accessTokenGenerator = async () => {
      try {
        requestCount.current += 1;
        console.log(`Access token request count: ${requestCount.current}`);

        const response = await axios.post(
          `${process.env.REACT_APP_BACKEND_BASE_URL}/reissue/access`,
          {},
          {
            headers: {
              "Content-Type": "application/json",
            },
            withCredentials: true,
          }
        );

        const newAccessToken = response.data["accessToken"];
        extractUserInfoFromAccess(newAccessToken);
      } catch (error) {
        console.error(
          "refresh token이 만료되었거나 존재하지 않습니다. ",
          error
        );

        navigate("/login");
      }
    };

    const interval = setInterval(
      accessTokenGenerator,
      ACCESS_TOKEN_EXPIRATION_PERIOD
    );

    return () => clearInterval(interval);
  }, [navigate]);

  return null;
};

/**
 *  소셜 로그인 후 access token 재발급 함수
 * */
export const socialLoginAccessToken = async (navigate) => {
  try {
    localStorage.removeItem("Authorization");
    localStorage.removeItem("memberId");

    const response = await axios.post(
      `${process.env.REACT_APP_BACKEND_BASE_URL}/reissue/access`,
      {},
      {
        headers: {
          "Content-Type": "application/json",
        },
        withCredentials: true,
      }
    );

    const newAccessToken = response.data["accessToken"];
    localStorage.setItem("Authorization", "Bearer " + newAccessToken);

    const payloadObject = decodeJWT(newAccessToken);

    if (payloadObject) {
      localStorage.setItem("memberId", payloadObject.memberId);
      localStorage.setItem("memberRole", payloadObject.role);
    }
  } catch (error) {
    console.error(
      "refresh token 이 만료되었기 때문에, access token 재발급에 실패했습니다. ",
      error
    );

    navigate("/login");
  }
};

export const decodeJWT = (token) => {
  try {
    const base64Url = token.split(".")[1];
    const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
    const jsonPayload = decodeURIComponent(
      atob(base64)
        .split("")
        .map(function (c) {
          return "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2);
        })
        .join("")
    );

    return JSON.parse(jsonPayload);
  } catch (error) {
    console.error("Invalid token:", error);
    return null;
  }
};

// Function to check token expiration
export const isAccessTokenValid = (accessToken) => {
  try {
    const decodedToken = decodeJWT(accessToken);

    if (decodedToken && decodedToken.exp) {
      const currentTime = Math.floor(Date.now() / 1000); // 현재 시각 (초 단위)
      const tokenExpirationTime = decodedToken.exp; // 토큰 만료 시간 (초 단위)

      return tokenExpirationTime > currentTime;
    } else {
      console.error("Token does not have exp field");
      return false;
    }
  } catch (error) {
    console.error("Error decoding token:", error);
    return false;
  }
};

export const extractUserInfoFromAccessAndRefresh = (
  AccessToken,
  RefreshToken
) => {
  console.log("extract UserInfo From Access And Refresh 호출됨");

  if (AccessToken) {
    // 기존에 저장된 Authorization 토큰과 memberId를 삭제합니다.
    localStorage.removeItem("Authorization");
    localStorage.removeItem("memberId");
    localStorage.removeItem("memberRole");

    // accessToken 디코딩
    const decodedToken = decodeJWT(AccessToken);

    // decodedToken이 존재한다면 실행
    if (decodedToken?.memberId && decodedToken?.role) {
      const memberId = decodedToken.memberId;
      const memberRole = decodedToken.role;

      // 로컬 스토리지에 새로운 값 저장
      localStorage.setItem("memberId", memberId);
      localStorage.setItem("memberRole", memberRole);
      localStorage.setItem("Authorization", "Bearer " + AccessToken);

      // 쿠키에 refreshToken 저장
      const refreshAuthorization = "Bearer+" + RefreshToken;
      document.cookie = `refreshAuthorization=${refreshAuthorization}; path=/; SameSite=Lax`;
    } else {
      console.error("decodedToken에 id 또는 role이 없음");
    }
  } else {
    console.error("AccessToken이 존재하지 않아, extractUserInfo 실패.");
  }
};

export const extractUserInfoFromAccess = (AccessToken) => {
  console.log("extract User Info From Access 호출됨");

  if (AccessToken) {
    // 기존에 저장된 Authorization 토큰과 memberId를 삭제합니다.
    localStorage.removeItem("Authorization");
    localStorage.removeItem("memberId");
    localStorage.removeItem("memberRole");

    // accessToken 디코딩
    const decodedToken = decodeJWT(AccessToken);

    // decodedToken이 존재한다면 실행
    if (decodedToken?.memberId && decodedToken?.role) {
      const memberId = decodedToken.memberId;
      const memberRole = decodedToken.role;

      // 로컬 스토리지에 새로운 값 저장
      localStorage.setItem("memberId", memberId);
      localStorage.setItem("memberRole", memberRole);
      localStorage.setItem("Authorization", "Bearer " + AccessToken);
    } else {
      console.error("decodedToken에 memberId 또는 role이 없음");
    }
  } else {
    console.error("AccessToken이 존재하지 않아, extractUserInfo 실패.");
  }
};

코드는 상대적으로 단순하며, 어렵지 않기에 설명 드리지 않겠습니다. 
 
그러면 클라이언트에서 , 쿼리 파라미터에 redirectedFromSocialLogin 를 어떻게 체크하는가 입니다. 

/**
   *  소셜로그인 성공시, 메인도메인 주소에 redirectedFromSocialLogin 라는 파라미터가 추가되어지며
   * 그 즉시, useEffect 훅이 실행되어 집니다.
   * 1. 서버로부터 받은, HttpOnly Cookie 를 통해, 엑세스토큰을 클라이언트에 발급받습니다.
   * 2. 로그인 상태, 회원Id, 회원Role 를 로컬스토리지에 저장하고, 로그인 여부를 파악합니다.
   * */
  useEffect(() => {
    const urlParams = new URLSearchParams(window.location.search);

    if (urlParams.has("redirectedFromSocialLogin")) {
      socialLoginAccessToken(navigate).then(() => {
        setIsLoggedin(true);
        setMemberId(localStorage.getItem("memberId"));
        setMemberRole(localStorage.getItem("memberRole"));
      });
    }
  }, [navigate]);

URLSearchParams 라고해서, 현재 페이지의 파라미터에 redirectedFromSocialLogin 가 존재하는지 체크하고
존재한다면, 소셜로그인을 통한 엑세스토큰 발행 절차를 진행합니다.
리액트 상태관리 단에서는, 
로그인이 됐으며 setIsLoggedin
엑세스토큰, 회원Id, 회원Role 를 엑세스토큰의 디코딩을 통해
JWT Token PayLoad 에 담긴 회원Id, 회원Role를 로컬스토리지에 저장합니다.
로컬스토리지에 저장해서, 현재 로그인 한 유저가 어떤 회원인지를 
리액트 상태관리 단에서 알아채줍니다.
여기서 이 글을 읽으시는 초보 개발자분들이 조심하셔야 할 사항은
로컬스토리지에 회원의 개인 정보(실명, 휴대폰번호, 민감한 사항)을 담는 것은
보안상 매우 위험하다는 것입니다.
그렇다면, 사실상 JWT 리프레쉬, 엑세스토큰을 발급한
백엔드 권한의 커스텀 소셜로그인에 의미가 없어집니다.
애초에 클라이언트에게 토큰에 대한 권한을 주지 않도록 하는 것이 
백엔드 권한의 소셜로그인의 핵심입니다.
이후 리프레쉬 토큰을 통해서 엑세스토큰을 주기적으로 발급하시면 됩니다.

import axios from "axios";
import "./App.css";
import "react-image-crop/dist/ReactCrop.css";

function App() {
return (
	<div className="Body">
    // 엑세스토큰을 주기적으로 발급하는 컴포넌트 
      <PeriodicAccessTokenRefresher />
	</div>
  );
}

export default App;

 
감사합니다.
[OAuth2 Client] Handling HttpOnly Refresh Tokens on the Client-Side: How to Manage Tokens in a React Application Using Spring Security & OAuth2 Client for Authentication/Authorization. 클라이언트에서 href 소셜 로그인을 통해 받은 HttpOnly 리프레시 토큰으로 엑세스토큰을 발행하는 구현방법