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

[React Client, Spring Boot Server] Spring Security와 OAuth2 Client를 활용한 SPA 웹 애플리케이션 로그인 시스템 구축: 일반 회원 및 소셜 로그인(Naver, Google, Kakao)과 JJWT 활용 (1) 구현 절차와 인증 방식에 대한 설명

by 우지uz 2024. 6. 21.

쇼핑몰을 만드는 팀 프로젝트를 진행하면서, 일반 회원 및 소셜로그인 을 구현 했으며

JWT 토큰을 활용한 로그인에 대해 포스팅 하려고 합니다.

특히나 보안을 신경 쓰기 위해서, 소셜로그인과 같은 경우 Rest API 방식을 사용하지 않고
클라이언트에서 redirect uri 를 요청하는 방식이 아닌
서버에서 직접 redirect uri 를 요청하고 그에 대한 응답을 클라이언트에게 반환 하는 
OAuth2 Client 의 커스텀 소셜 로그인을 진행했습니다.

방식 : JWT 를 활용한 로그인에는 여러가지 방식이 존재 하지만,
Authorization Header 방식과 HttpOnly Cookie 방식을 사용한 "혼합 방식"을 채택했습니다.

 

 


프론트엔드 기술

Vite 와 같은 프론트엔드 빌드 도구를 사용하지 않았고, 순수히 리액트 라이브러리만을 사용했습니다.
다음은, 현재 프로젝트 클라이언트 단에서 사용한 dependencies 전체입니다.
(JWT 토큰 기반의 로그인에 대한 구현을 중점적으로 포스팅 할 것이기 때문에, 각 dependency 에 대한 설명은 검색을 통해서 해결해주십시오.)

node version : v20.11.1

"dependencies": {
    "@popperjs/core": "^2.11.8",
    "@reduxjs/toolkit": "^2.2.5",
    "@testing-library/dom": "^10.1.0",
    "@testing-library/jest-dom": "^5.17.0",
    "@testing-library/react": "^13.4.0",
    "@testing-library/user-event": "^13.5.0",
    "axios": "^1.6.7",
    "bootstrap": "^5.3.3",
    "concurrently": "^8.2.2",
    "json-server": "^0.17.4",
    "json-server-auth": "^2.1.0",
    "react": "^18.2.0",
    "react-bootstrap": "^2.10.1",
    "react-daum-postcode": "^3.1.3",
    "react-dom": "^18.2.0",
    "react-icons": "^5.0.1",
    "react-image-crop": "^11.0.5",
    "react-redux": "^9.1.2",
    "react-router-dom": "^6.22.2",
    "react-scripts": "5.0.1",
    "redux": "^5.0.1",
    "sweetalert": "^2.1.2",
    "swiper": "^11.1.3",
    "web-vitals": "^2.1.4"
  },

백엔드 기술

implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'org.springframework.boot:spring-boot-starter-security'

// jsonwebtoken jjwt - 0.12.3
implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.12.3'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.12.3'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.12.3'

스프링 부트 DEPENDENCY 는 OAuth2 Client, Security, jjwt(Java Json Web Token) 를 사용했으며
특히 Security를 사용한 이유는 "다른 DEPENDENCY 에 대한 통합 및 연동"이 잘 이루어져 있다는 점입니다.
특히나 스프링 부트와 잘 어우러져 사용되도록 개발되었다고 하여 선택하게 되었습니다.


앞서 설명 드릴 내용에 대한 순서 입니다. 

  1. React Client, Spring Boot Server 에서 구현 절차와 구현 영상
  2. 일반 회원, 소셜 로그인 인증에 대한 핵심 내용

1. React Client, Spring Boot Server 에서 구현 절차


1-1) 일반 회원에 대한 로그인 절차

1. 사용자(Client)가 Email, Password 를 입력하여 로그인 합니다.
2. Server에서 로그인에 대한 요청을 확인하고, Server의 secret key 를 통해 엑세스토큰과 리프레쉬 토큰을 발급합니다.
3. 엑세스토큰을 response data 에, 리프레쉬토큰은 HttpOnly Secure Cookie 방식으로 쿠키에 담아줍니다.
4. Client 는 전달받은 엑세스토큰을 LocalStorage 에 Authorization 이름으로 저장합니다. 
5. 로그인에 성공하여, 성공적으로 사이트에 접속한 멤버(Client)는 웹 애플리케이션에서 서버로의 요청을 보낼 때마다, HttpOnly Secure Cookie 방식의 리프레쉬토큰을 검증 받습니다. 검증을 통해 로그인 여부와, 유효 날짜가 지나진 않았는지 검사받습니다. 
6. 리프레쉬 토큰과 액세스토큰에 대한 검증이 통과하면, 클라이언트에 요청에 대한 정상적인 응답을 전달합니다. 

WAS 는 Web Application Server 입니다. 말 그대로 서버를 의미합니다. 
클라이언트가 서버로 API 요청을 보내면, API 에 대한 컨트롤러를 찾기 전에, Filter Chain 에 등록된 필터들을 거치게 됩니다.

그 중 하나가 리프레쉬 토큰과 엑세스토큰에 대한 검사를 진행하는 OncePerRequestFilter 를 먼저 거치고, API Controller 를 요청하게 됩니다. 


필터에는 인증과 인가, 보안과 관련된 기능들을 담당하는 다양한 필터가 존재합니다. 다음은 대표적인 필터에 대한 간략한 설명입니다.

현재 프로젝트에 등록된 Spring Security 필터들

@Configuration
@EnableWebSecurity(debug = true)
@RequiredArgsConstructor
public class SecurityConfig {
    위와같이, debug = true 로 설정하시면 , 시큐리티 관련 디버그 모드를 사용할 수 있고
Security filter chain: [
  DisableEncodeUrlFilter
  WebAsyncManagerIntegrationFilter
  SecurityContextHolderFilter
  HeaderWriterFilter
  CorsFilter
  OAuth2AuthorizationRequestRedirectFilter
  OAuth2LoginAuthenticationFilter
  LoginFilter
  JWTFilterV3
  DefaultLoginPageGeneratingFilter
  RequestCacheAwareFilter
  SecurityContextHolderAwareRequestFilter
  AnonymousAuthenticationFilter
  SessionManagementFilter
  ExceptionTranslationFilter
  AuthorizationFilter
]

위와같이 다양한 필터들이 존재함을 확인할 수 있습니다.


각 필터들에 대해서, ChatGPT 4o 에게 간단한 설명을 부탁했습니다.

Spring Security에서 사용되는 각 필터는 다양한 보안 작업을 처리합니다. 아래는 나열된 필터들의 역할을 간단하게 설명한 것입니다.

1. DisableEncodeUrlFilter

  • 역할: URL 인코딩을 비활성화하는 필터입니다. URL 인코딩은 URL에 민감한 데이터를 포함할 때 사용되지만, 이 필터는 이를 비활성화합니다.

2. WebAsyncManagerIntegrationFilter

  • 역할: 비동기 요청의 보안 컨텍스트를 관리하는 필터입니다. Spring의 비동기 요청 처리와 통합하여 보안 컨텍스트를 유지합니다.

3. SecurityContextHolderFilter

  • 역할: SecurityContextHolder에 보안 컨텍스트를 설정하는 필터입니다. 요청의 시작 시 보안 컨텍스트를 설정하고 요청이 완료되면 이를 정리합니다.

4. HeaderWriterFilter

  • 역할: HTTP 응답 헤더를 작성하는 필터입니다. 보안 관련 헤더(예: X-Content-Type-Options, X-Frame-Options)를 추가하여 응답의 보안을 강화합니다.

5. CorsFilter

  • 역할: CORS(Cross-Origin Resource Sharing) 요청을 처리하는 필터입니다. 다른 도메인에서 오는 요청을 허용하거나 차단하는 역할을 합니다.

6. OAuth2AuthorizationRequestRedirectFilter

  • 역할: OAuth2 인증 요청을 처리하고 사용자를 인증 서버로 리디렉션하는 필터입니다. 사용자가 소셜 로그인 버튼을 클릭했을 때 동작합니다.

7. OAuth2LoginAuthenticationFilter

  • 역할: OAuth2 로그인 인증을 처리하는 필터입니다. 인증 서버에서 받은 인가 코드를 사용하여 액세스 토큰을 요청하고 사용자 정보를 가져옵니다.

8. LoginFilter

  • 역할: 사용자 로그인 요청을 처리하는 필터입니다. 일반적으로 폼 기반 로그인을 처리하며, 사용자가 자격 증명을 제출했을 때 동작합니다.

9. JWTFilterV3

  • 역할: JWT 토큰을 검증하고 인증을 처리하는 필터입니다. 요청 헤더에 포함된 JWT 토큰을 파싱하여 사용자 정보를 검증합니다.

10. DefaultLoginPageGeneratingFilter

  • 역할: 기본 로그인 페이지를 생성하는 필터입니다. 사용자 정의 로그인 페이지가 없을 때 기본 로그인 페이지를 제공합니다.

11. RequestCacheAwareFilter

  • 역할: 요청 캐시를 처리하는 필터입니다. 인증되지 않은 사용자가 접근하려던 URL을 저장하고, 인증 후에 해당 URL로 리디렉션합니다.

12. SecurityContextHolderAwareRequestFilter

  • 역할: 보안 컨텍스트를 HTTP 요청 객체에 통합하는 필터입니다. 보안 컨텍스트에 기반한 보안 관련 요청 메서드를 제공합니다.

13. AnonymousAuthenticationFilter

  • 역할: 인증되지 않은 사용자를 익명 사용자로 처리하는 필터입니다. 인증되지 않은 요청에 대해 익명 사용자 인증을 제공합니다.

14. SessionManagementFilter

  • 역할: 세션 관리와 관련된 보안을 처리하는 필터입니다. 세션 고정 보호, 세션 만료 처리 등을 담당합니다.

15. ExceptionTranslationFilter

  • 역할: 보안 예외를 처리하는 필터입니다. 인증 실패나 권한 부족 등의 예외가 발생했을 때 적절한 응답을 제공합니다.

16. AuthorizationFilter

  • 역할: 요청에 대한 접근을 결정하는 필터입니다. 사용자의 권한을 검사하여 보호된 리소스에 대한 접근을 허용하거나 차단합니다.

 

 


1-2) 소셜 로그인 절차

1. 소셜로그인 버튼을 클릭합니다.
2. 하이퍼 링크로, 백엔드 서버로 소셜로그인을 요청합니다. 

window.location.href = "http://localhost:8080/oauth2/authorization/kakao"

3. 카카오(구글, 네이버)로 회원가입을 진행합니다.

https://kauth.kakao.com/oauth/authorize?response_type={response_type}&client_id={client_id}&scope=???%???&state={state}&redirect_uri={redirect_uri}

4. 카카오 개발자 홈과 서버에 등록한 redirect_uri 로 보내집니다.
5. 서버로 요청된 redirect_uri 를, 서버가 응답 합니다. 

http://localhost:8080/login/oauth2/code/kakao?code={code}&state={state}

6. 서버에서는 카카오(구글, 네이버) 에 맞는 OAuth2UserResponse 가 각기 다르기 때문에 , OAuth2UserResponse 를 만들어 주고 
7. HttpOnly, Https 등 보안이 추가된 Cookie 에 리프레쉬 토큰을 담아줍니다. 
8. 소셜로그인에 대한 서버의 응답이 성공적으로 완료되고나서, http://localhost:3000/?redirectedFromSocialLogin=true 클라이언트의 메인 페이지로 redirect 시켜줍니다.

@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 에 자징합니다.
    saveOrUpdateRefreshEntity(requestMember, refreshToken);

    // 리프레시 토큰을 쿠키에 저장합니다.
    response.addCookie(createCookie("refreshAuthorization", "Bearer+" +refreshToken));
    response.setStatus(HttpStatus.OK.value());
    response.sendRedirect("http://localhost:3000?redirectedFromSocialLogin=true");
}

9. 클라이언트에서는, 쿼리 파라미터로 redirectedFromSocialLogin 가 담겨지는 시점에, 리프레쉬 토큰이 담긴 HttpOnly Cookie 을 통해서 엑세스토큰을 발급 받습니다. 

/**
*  소셜로그인 성공시, 메인도메인 주소에 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]);

10. 엑세스토큰을 로컬 스토리지에 Authorization 이라는 이름으로 담아줍니다. 
11. 이후 로컬스토리지에 담긴 Authorization 와 HttpOnly 쿠키 방식으로 담긴 리프레쉬 토큰을 통해 로그인 인증/인가 절차를 밟습니다. 

이후, 일반 회원에 대한 로그인과 소셜로그인 이후 
리프레쉬 토큰을 통해 엑세스 토큰을 재발급 받습니다.

// 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;
};

엑세스토큰의 유효기간이 10분이하로 짧기 때문에, 로컬스토리지에 담기더라도 안전할 수 있습니다.
그리고 엑세스토큰에 담긴 유저에 대한 정보는 
진짜 필요로 하거나, 탈취되어도 안전하다고 생각될 정도의 데이터만 담아 주시면 됩니다.
회원의 개인 정보는 위험합니다.(실명, 이메일, 휴대폰번호, 주민번호 등등..)

저는 프로젝트에서 회원의 id 와 role 만 페이로드에 담아주었습니다.

2. 일반 회원, 소셜 로그인 인증에 대한 핵심 내용

제가 구현 했던 JWT 토큰 기반 인증 방식에서 핵심 내용은 

유저의 보안을 위해서, 토큰을 어떻게 관리하고 처리 했느냐 입니다. 

1. HttpOnly 방식의 쿠키를 사용 했습니다.

HttpOnly 방식의 쿠키는 웹 브라우저와 서버 간의 HTTP 요청/응답에서만 접근 가능한 쿠키를 의미합니다. 이는 클라이언트 측의 JavaScript 코드에서 쿠키에 접근할 수 없도록 하여 보안을 강화하는 데 사용됩니다.

HttpOnly 쿠키의 주요 특징

  1. JavaScript 접근 불가
    • HttpOnly 속성이 설정된 쿠키는 클라이언트 측의 JavaScript 코드에서 document.cookie를 통해 접근하거나 수정할 수 없습니다. 이를 통해 XSS(교차 사이트 스크립팅) 공격으로부터 쿠키를 보호할 수 있습니다.
  2. 보안 강화
    • HttpOnly 쿠키는 서버와 클라이언트 간의 HTTP 요청과 응답을 통해서만 전달되기 때문에, 민감한 정보(예: 세션 토큰, 인증 토큰 등)를 저장하는 데 유용합니다.
    • 이러한 쿠키는 클라이언트 측 스크립트로 인해 노출될 가능성이 줄어들어 보안이 강화됩니다.
  3. 설정 방법
    • 서버에서 쿠키를 설정할 때 HttpOnly 속성을 추가하여 설정합니다.
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletResponse;

public void setHttpOnlyCookie(HttpServletResponse response) {
    Cookie cookie = new Cookie("refreshToken", "your-refresh-token-value");
    cookie.setHttpOnly(true); // HttpOnly 속성 설정
    cookie.setSecure(true); // HTTPS에서만 전송 (Secure 속성)
    cookie.setPath("/");
    cookie.setMaxAge(7 * 24 * 60 * 60); // 7일간 유효
    response.addCookie(cookie);
}

HttpOnly 방식으로 쿠키를 사용하게 되면, 서버에서만 쿠키를 관리할 수 있기 때문에 XSS 공격으로부터 쿠키를 보호하는데 탁월합니다.
악성 스크립트가 실행되더라도 HttpOnly 쿠키에 접근할 수 없기 때문에 민감한 정보가 유출될 위험이 줄어듭니다.

단점은 클라이언트 단에서 쿠키를 꺼내거나, 수정하거나 삭제할 수 없기 때문에 
쿠키를 추가하거나, 삭제하려면 서버로 Http 요청을 통해서 쿠키를 사용해야 한다는 점입니다.

2. 엑세스 토큰의 기한을 짧게 해서, 리프레쉬 토큰을 통해 주기적으로 엑세스토큰을 재발급 받아. 로컬스토리지에 담긴 엑세스토큰에 대한 보안성을 강화했습니다

로컬 스토리지에 엑세스토큰을 곧바로 담아주기 보다, 흔히 사용되는 Authorization 이름으로 엑세스토큰을 "Bearer {엑세스토큰}" 으로 담아주었고, 엑세스토큰의 페이로드에 유저에 대한 민감한 정보들을 담지 않았습니다.

 

참고 레퍼런스

  • 개발자 유미님의 '스프링 시큐리티, 스프링 시큐리티 JWT, 스프링 OAuth2 클라이언트 세션, 스프링 OAuth2 클라이언트 JWT, 스프링 JWT 심화, 스프링 시큐리티 내부구조' 재생목록 전체.

https://tecoble.techcourse.co.kr/post/2021-07-10-understanding-oauth/

https://youtu.be/36lpDzQzVXs?si=Mn1vGgKyLwm5IHgF

생활코딩 웹 OAuth2 재생목록 전체

https://docs.spring.io/spring-security/reference/index.html

https://suddiyo.tistory.com/entry/Spring-Spring-Security-JWT-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-1

https://docs.spring.io/spring-security/reference/servlet/oauth2/client/index.html

https://green-bin.tistory.com/76