도찐개찐

[React] JWT 프론트 로그인 처리 하기 본문

React

[React] JWT 프론트 로그인 처리 하기

도개진 2022. 5. 8. 11:07
  • JWT를 쓴다.
  • refreshToken은 secure httpOnly 쿠키로, accessToken은 JSON payload로 전달받는다.
  • 웹 어플리케이션이 mount 될 때 마다 refreshToken을 이용해 새로운 accessToken을 받아와 웹 어플리케이션 내 지역 변수에 저장하고 사용한다.
  • 이 방식으로 CSRF 취약점 공격과 (다른 선택지보다) XSS 공격에서 안전할 수 있다. 하지만 웹 어플리케이션이 XSS 공격에 취약하다면 어떤 방식을 선택하던 보안이 위험하기에 꼭 XSS 처리를 해야 한다.

클라이언트에서 안전한 로그인 / 유저 인증 (Authentication) 방식에 대해 얘기하려면 다음 세 가지를 먼저 이해해야 한다.

  1. 로그인은 어떻게 이루어지나
  2. 보안은 어떻게 뚫리나
  3. 브라우저 저장소 종류와 보안 이슈 (localStorage, 쿠키, httpOnly 쿠키)

먼저 유저 인증 처리 프로세스부터 이야기해보자. 두 가지 방식이 있다.

🔑 로그인은 어떻게 이루어지나

  1. 세션 id를 이용하는 방식
  2. 이 방식에서 서버는 특정 유저의 정보를 담은 세션을 생성한다. (1) 유저가 로그인할 때 (2) 서버는 세션을 생성한 후 (3) 그 세션의 id를 클라이언트에 보내주고 (4) 클라이언트는 이 id를 클라이언트에 저장해두었다가 (5) 인증이 필요한 데이터를 가져올 때 서버에 id 값을 보내면 (6) 서버는 그 id를 통해 세션을 불러와 유효한지 확인하는 방식으로 인증한다.
  3. JWT를 이용하는 방식 (ft. refreshToken, accessToken)좀 더 자세히 설명하면 실질적인 인증 정보는 accessToken인데 일정 시간이 지나면 만료하는 구조를 가지고있다. (로그인 후 며칠 뒤 로그인이 만료돼서 다시 로그인해야 했던 경험이 있는가?) refreshToken을 이용해 로그인을 지속적으로 유지할 수도 있다. refreshToken을 서버에 보내면 그때마다 새로운 accessToken을 발급해 돌려주는 거다. refreshToken 사용은 옵션이다.
  4. 그럼 다시 돌아와 (5) 이 accessToken을 유저에게만 보여줄 수 있는 정보에 접근할 때 서버에 보내면 (6) 서버는 그 토큰이 유효한지 확인하는 방식으로 인증한다.
  5. 비슷한 방식으로 (1) 유저가 로그인할 때 (2) 서버가 인증 정보를 보내주는데, 암호화나 시그니처 추가가 가능한 데이터 패키지안에 인증 정보를 담아 보내준다. (이 패키지가 JSON Web Token 즉 JWT다.) (3) 담기는 정보 중 accessToken과 refreshToken이 이후 유저 인증에 사용되는데 (4) 이 정보를 클라이언트에 저장해둔다.

다음으로 보안 공격의 종류와 특징에 대해 살펴보자. 웹 어플리케이션 보안 취약점 중 유저 인증에서 보편적으로 이용되는 취약점은 두 가지다: XSS와 CSRF

😈 보안은 어떻게 뚫리나

  1. XSS 공격
  2. 공격자(해커)가 클라이언트 브라우저에 Javascript를 삽입해 실행하는 공격이다. 공격자가 <input>을 통해 Javascript를 서버로 전송해 서버에서 스크립트를 실행하거나, url에 Javascript를 적어 클라이언트에서 스크립트 실행이 가능하다면 공격자가 사이트에 스크립트를 삽입해 XSS 공격을 할 수 있다. 이때 공격자는 Javascript를 통해 사이트의 글로벌 변숫값을 가져오거나 그 값을 이용해 사이트인 척 API 콜을 요청할 수도 있다. 다시 말하면 공격자의 코드가 내 사이트의 로직인 척 행동할 수 있다는 거다.
  3. CSRF 공격
  4. 공격자가 다른 사이트에서 우리 사이트의 API 콜을 요청해 실행하는 공격이다. API 콜을 요청할 수 있는 클라이언트 도메인이 누구인지 서버에서 통제하고 있지 않다면 CSRF가 가능한데, 이때 공격자가 클라이언트에 저장된 유저 인증정보를 서버에 보낼 수 있다면, 제대로 로그인한 것처럼 유저의 정보를 변경하거나 유저만 가능한 액션들을 수행할 수 있다. 예를 들어 CSRF에 취약한 은행 사이트가 있다면 로그인한 척 계좌 비밀번호를 바꾸거나 송금을 보낼 수 있는 것이다.

React에서(= 클라이언트) 위 프로세스들을 따라 세션 id나 accessToken 같은 인증정보를 저장할 때 (인증 방식 4번 단계 해당) 이용하는 저장소는 보통 localStorage나 쿠키다. 페이지를 리프레시 하거나 창을 닫고 다시 접속할 때도 로그인 정보가 이어지도록 둘 다 브라우저에 저장하는 방식이다. 하지만 두 방식은 XSS와 CSRF 공격에 취약할 수 있다.

😱 브라우저 저장소 종류와 보안 이슈

  1. localStorage 저장 방식😈 : localStorage 안에 세션 id, refreshToken 또는 accessToken을 저장해두면 XSS 취약점을 통해 그 안에 담긴 값을 불러오거나, 불러온 값을 이용해 API 콜을 위조할 수 있다.
  2. 브라우저 저장소에 저장하는 방식이다. Javascript 내 글로벌 변수 읽기 / 쓰기 접근이 가능하다.
  3. 쿠키 저장 방식😈 : 쿠키 저장 방식 역시 안에 세션 id, refreshToken, accessToken을 저장해두면 XSS 취약점이 있을 때 담긴 값들을 불러오거나, API 콜을 보내면 쿠키에 담긴 값들이 함께 전송되어 로그인한 척 공격을 수행할 수 있다.
  4. 😇 : 쿠키에 refreshToken만 저장하고 새로운 accessToken을 받아와 인증에 이용하는 구조에서는 CSRF 취약점 공격을 방어할 수 있다. refreshToken으로 accessToken을 받아도 accessToken을 스크립트에 삽입할 수 없다면 accessToken을 사용해 유저 정보를 가져올 수 없기 때문이다.
  5. 😈 : 쿠키에 세션 id나 accessToken을 저장해 인증에 이용하는 구조에 CSRF 취약점이 있다면 인증 정보가 쿠키에 담겨 서버로 보내진다. 공격자는 유저 권한으로 정보를 가져오거나 액션을 수행할 수 있다.
  6. 브라우저에 쿠키로 저장되는데, 클라이언트가 HTTP 요청을 보낼 때마다 자동으로 쿠키가 서버에 전송된다. Javascript 내 글로벌 변수 읽기 / 쓰기 접근이 가능하다.
  7. secure httpOnly 쿠키 저장 방식😇 : httpOnly 쿠키 방식으로 저장된 정보는 XSS 취약점 공격으로 담긴 값을 불러올 수 없다.
  8. 😈 : 쿠키 저장 방식과 같은 이유로 세션 id, accessToken은 저장하면 안 된다.
  9. 😈 : httpOnly 쿠키에 담긴 값에 접근할 수는 없지만 XSS 취약점을 노려 API 콜을 요청하면 httpOnly 쿠키에 담긴 값들도 함께 보내져 유저인 척 정보를 빼오거나 액션을 수행할 수 있다.
  10. 😇 : httpOnly 쿠키 역시 refreshToken만 저장하고 accessToken을 받아와 인증에 이용하는 구조로 CSRF 공격 방어가 가능하다.
  11. 브라우저에 쿠키로 저장되는 건 같지만, Javascript 내에서 접근이 불가능하다. secure을 적용하면 https 접속에서만 동작한다.

어떤 저장 방식을 택해도 XSS 취약점이 있다면 보안 이슈가 존재한다 (XSS로 API 콜을 보내는 방식으로 다 뚫린다). 그러므로 유저 정보 저장 방식을 바꾸는 것만으로는 방어할 수 없고, 클라이언트와 서버에서 추가적으로 XSS 방어 처리가 필수다.

예: <input>에서 입력된 값이 html / Javascript로 인식되지 않도록 서버에서 escape 처리를 해준다. 또 url을 통해 Javascript를 수행할 수 없도록 라우팅을 꼼꼼하게 관리한다. 다행인 것은 React는 공격자가 string에 html / Javascript를 담아 JSX에 삽입할 경우 자동으로 escape 처리한다. (XSS 방어 처리는 또 다른 주제기에 여기서는 이 정도에서 마무리한다.)

🤔 그래서 로그인을 어떻게 한다는 거야?

본론에 들어가기 위한 준비는 마쳤다.

종합해보면 세션 id를 브라우저에 저장하는 방식은 어떤 방식이던지 보안 위험요소가 있으므로 JWT 이용한 인증 방식을 사용할 것이다. refreshToken만을 secure httpOnly 쿠키에 저장해 CSRF 공격을 방어할 것이다. accessToken은 웹 어플리케이션 내 로컬 변수에 저장해 사용하며, API를 요청할 때 Authorization 헤더에 넣어 보내준다. XSS 취약점을 이용한 API 요청 공격은 클라이언트와 서버에서 추가적으로 방어 해야 한다.

정리

  • JWT로 유저 인증
  • refreshToken을 secure httpOnly 쿠키로, accessToken은 JSON payload로 받아와서 웹 어플리케이션 내 로컬 변수로 이용
  • 이를 통해 CSRF 취약점 공격 방어하고, XSS 취약점 공격으로 저장된 유저 정보 읽기는 막을 수 있음
  • 하지만 XSS 취약점을 통해 API 콜을 보낼 때는 무방비하니 XSS 자체를 막기 위해 서버와 클라이언트 모두 노력해야 함

👩🏻‍💻 React에 적용하기

준비물

secure 쿠키 전달을 하려면 프론트(React)와 로그인 API를 제공할 백엔드(서버 API)는 같은 도메인을 공유해야한다. (예: 클라이언트: https://shop.abc.com, 서버 API: https://api.abc.com)
백엔드는 HTTP 응답 Set-Cookie 헤더에 refreshToken 값을 설정하고 accessToken 을 JSON payload에 담아 보내줘야 한다.

클라이언트에서 처리하기

먼저, React 최상단 index.js에서 axios에 withCredentials를 true로 설정해줘야 refreshToken cookie를 주고받을 수 있다.

import React from "react";
import ReactDOM from "react-dom";
import axios from "axios";

import App from "./App";

axios.defaults.baseURL = "https://www.abc.com";
axios.defaults.withCredentials = true;

로그인 로직은 간단하다.

onLogin = (email, password) => {
	const data = {
		email,
		password,
	};
	axios.post('/login', data).then(response => {
		const { accessToken } = response.data;

		// API 요청하는 콜마다 헤더에 accessToken 담아 보내도록 설정
		axios.defaults.headers.common['Authorization'] = `Bearer ${accessToken}`;

		// accessToken을 localStorage, cookie 등에 저장하지 않는다!

	}).catch(error => {
		// ... 에러 처리
	});
}

🎁 보너스

로그인 만료, 로그인 연장 처리하기

이 섹션은 zeneo님 질문을 받고 추가되었습니다.

JWT 인증 방식에서 실질적으로 인증되었나를 결정하는 것은 accessToken이다. 하지만 우리가 택한 구조에서 브라우저에 저장된 값은 쿠키에 저장된 refreshToken뿐이기에 로컬에 저장된 accessToken은 브라우저 창이 꺼지거나 페이지가 리프레시 되는 등 페이지가 리로드 되면 사라진다. 또한, accessToken은 일정 시간이 지나면 만료된다. 그래서 (1) 이메일, 비밀번호를 입력해서 accessToken을 받아오는 보통 로그인 처리뿐만 아니라 보다 완성도 있는 사이트를 만들기 위해 (2) accessToken이 만료됐을 때 어떻게 처리할지 (예를 들어 은행 사이트같이 보안이 중요한 서비스라면 아예 다시 로그인하도록 로그인 페이지로 이동시킬 수도 있고, 아니면 유저 모르게 서버에서 새로운 accessToken을 받아와서 로그인이 연장되도록 할 수도 있음), (3) 페이지 리로드 될 때 어떻게 처리할지 (2번과 같이 여러 가지 처리 방식이 있음)도 결정해야 한다.

이 예제에서는 유저가 다시 직접 로그인하도록 유도하지 않고 조용히 자동으로 로그인 연장하는 기능을 구현해보겠다. (영어로 silent refresh라고 부른다.)

처리할 "로그인" 케이스들

  1. 이메일, 비밀번호를 체크하는 보통 로그인
  2. accessToken이 만료됐을 때 로그인 연장 처리
  3. 페이지 리로드 될 때 로그인 연장 처리

준비물

위에서 이야기한 3가지 케이스에 대응하기 위해 API가 2개 필요하다.

  1. POST /login: 이메일, 비밀번호를 보내면 refreshToken과 accessToken을 리턴한다.
  2. POST /silent-refresh: 쿠키에 담긴 refreshToken이 자동으로 보내지면 새로운 refreshToken과 accessToken을 리턴한다.

두 API 모두 HTTP 응답 Set-Cookie 헤더에 refreshToken 값을 설정하고 accessToken 을 JSON payload에 담아 보내줘야 한다.

클라이언트에서 처리하기

1. 로그인 & 2. 로그인 만료되기 전에 연장

const JWT_EXPIRY_TIME = 24 * 3600 * 1000; // 만료 시간 (24시간 밀리 초로 표현)

onLogin = (email, password) => {
    const data = {
        email,
        password,
    };
    axios.post('/login', data)
        .then(onLoginSuccess)
        .catch(error => {
            // ... 에러 처리
        });
}

onSilentRefresh = () => {
    axios.post('/silent-refresh', data)
        .then(onLoginSuccess)
        .catch(error => {
            // ... 로그인 실패 처리
        });
}

onLoginSuccess = response => {
    const { accessToken } = response.data;

    // accessToken 설정
    axios.defaults.headers.common['Authorization'] = `Bearer ${accessToken}`;

    // accessToken 만료하기 1분 전에 로그인 연장
    setTimeout(onSilentRefresh, JWT_EXPIRRY_TIME - 60000);
}

3. 페이지 리로드 될 때 로그인 연장

// 어플리케이션이 실행될 때마다 다시 로그인 시도
class App extends Componet {
    componentDidMount() {
        onSilentRefresh();
    }
    // ...
}

 

출처 :  https://velog.io/@yaytomato/%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%90%EC%84%9C-%EC%95%88%EC%A0%84%ED%95%98%EA%B2%8C-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0

 

🍪 프론트에서 안전하게 로그인 처리하기 (ft. React)

localStorage냐 쿠키냐 그것이 문제로다

velog.io

 

728x90

'React' 카테고리의 다른 글

[React] 실행 포트 변경  (0) 2022.05.03
[React] 중첩 객체(Nested Object)의 수정  (0) 2022.04.29
[React] Fragment 사용하기  (0) 2022.04.28
[React] useState VS useEffect  (0) 2022.04.28
[React] PureComponent VS Component  (0) 2022.04.28
Comments