[React/Node.js] 쿠키를 이용해 refresh-token 구현

·

5 min read

[React/Node.js] 쿠키를 이용해 refresh-token 구현

JSON 로그인 프론트엔드 + 백엔드 구현

프로젝트를 진행하면서 JWT (Json Web Token) 방식으로 로그인을 구현하기로 하였다. (JWT에 대한 설명은 생략, 이 글을 읽는 사람들은 에러를 해결하기 위해 온거겟지?)

백엔드에서 access-tokenheaderAuthorization에 담아서 보내주고, refresh-tokencookie에 담아서 보내기로 하였다. (보안 이슈 고려 body에 넣지 않음)

프론트에서는 access-tokenlocalStorage에 저장하고, refresh-tokencookie에 저장하기로 하였다. (아래 표와 같음)

프론트엔드(저장 위치)백엔드(response 방식)
access-tokenlocalStorageheader.authorization
refresh-tokencookiecookie

프로젝트 과정에서 해당 방식으로 JWT를 구현하기 위해 삽질했던 기록을 작성한다.

똑똑한 사람들을 위해 최종 코드 먼저 제공

백엔드

return res
    .status(403)
    .cookie('refreshToken', refreshToken, {
        expires: new Date(Date.now() + 259200),
        httpOnly: true,
    })
    .header('Authorization', newAccessToken)
    .json({ message: 'Renewed expired access token' });

프론트엔드

import axios from "axios";
export const API = axios.create({
  baseURL: import.meta.env.VITE_BASE_URL,
  headers: {
    Authorization: `Bearer ${localStorage.getItem("accessToken")}`,
  },
  withCredentials: true,
});


처음에 설계했던 방법은 다음과 같다.

프론트엔드

import axios from "axios";
export const API = axios.create({
  baseURL: import.meta.env.VITE_BASE_URL,
  headers: {
    Authorization: `Bearer ${localStorage.getItem("accessToken")}`,
  },
  withCredentials: true,
});

백엔드

return res
  .status(403)
  .cookie("refreshToken", refreshToken, {
    httpOnly: true,
  })
  .header("Authorization", newAccessToken)
  .json({ message: "Renewed expired access token" });

request는 간단했다. 우리는 login post를 요청하였고 headers에서 Authorization 항목을 받아오고 싶었다.

하지만, CORS ERROR와 마주했다.

[삽질 No.1] CORS 설정

// login request code
try {
      const res = await API.post("/auth/login", userInput);
      // access token 저장
      localStorage.setItem("accessToken", res.headers.get("Authorization"));      
    } 
catch (error) {
  // status에 따른 Error Handling
  const errorResponse = error.response;
  const statusCode = errorResponse.status;
  console.log(statusCode);
  switch (statusCode) {
    case 40X:
      ...
      break;
  }
}

❓CORS란?

CORS를 이해하기 위해서는 SOP를 이해해야 합니다.

브라우저에서는 보안적인 이유로 cross-origin HTTP Request들을 제한합니다. 말 그대로, 서로 다른 origin (간단하게 url) 끼리의 HTTP Request를 제한하는 것을 의미한다.

이를 SOP (Same - Origin Policy)라고 합니다.

즉, SOP는 동일한 출처 사이에서만 리소스를 공유할 수 있다는 규칙을 지니고 있습니다.

→ CSRF(Cross Site Request Frogery, 크로스 사이트 요청 위조 공격)을 대비해 만들어짐 (공격자의 요청이 사용자의 요청인 것처럼 속이는 공격 방식)

⭐예전에는 프론트엔드 / 백엔드가 분리되지 않아서, 처리를 같은 도메인 내에서 했기 때문에 해당 정책이 효과적으로 공격들을 막아낼 수 있었다(CSRF, XSS). 하지만, 이제는 프론트엔드 / 백엔드가 분리되고, 다양한 API들을 사용하기 위해서 동일하지 않은 출처 사이에서도 리소스를 공유해야 했다.

이때, SOP를 지키면서 몇가지 예외사항을 적용하는 것이 CORS이다.

✏️출처는 Protocol + Host + Port (HTTPS://www.domain.com:3000)이다.

즉, 동일하지 않은 출처 사이에서도 HTTP Request를 가능하게끔 하는 것이 CORS이다. (매번 에러가 발생해서 나쁜 놈인줄 알았지만 사실은 좋은 놈이였다)

단, 모든 출처에서 접근할 수 있게 만들면 SOP가 없는것과도 같으니, ⭐가능한 URL들을 지정해주는 방법으로 최소한의 접근만 허용하는 것이다.

해당 프로젝트에서 어떻게 적용해야 할까?

1.CORS설정으로 프론트에서는 백엔드를, 백엔드에서는 프론트엔드를 등록한다.

  1. → 이때, 양쪽에서 등록해야 함을 주의하자.
// 프론트엔드 (package.json)
{
  ...,
  "proxy": "http://localhost:3000" //백엔드 url
}
// 백엔드 (cors_config.js)
const cors = require("cors");
exports.options = cors({
  origin: [
    "http://localhost:5173", // 프론트 url
  ],
});

이렇게 했는데도, CORS Error가 발생했다.

The value of the 'Access-Control-Allow-Credentials' header in the response is '' which must be 'true' when the request's credentials mode is 'include’

2.credentials: true 설정하기

⭐CORS는 기본적으로 보안상의 이유로 쿠키를 request로 보낼 수 없도록 설계되었다.

하지만, 다른 도메인을 가진 API 서버에 자신을 인증해야 하는 정상적인 응답을 받을 수 있는 상황에서는 쿠키를 통한 인증이 필요하므로, 해당 설정을 수정해야 한다.

// 프론트엔드
import axios from "axios";
export const API = axios.create({
  baseURL: import.meta.env.VITE_BASE_URL,
  headers: {
    Authorization: `Bearer ${localStorage.getItem("accessToken")}`,
  },
  withCredentials: true, // 추가하기
});
// 백엔드
const cors = require("cors");
exports.options = cors({
  origin: [
    "http://localhost:5173",
  ],
  credentials: true, // 추가하기
});

1.header의 Authorization에 접근 권한 설정하기

⭐ header는 기본적으로 값들이 접근가능하지 않도록 설정되어 있는 경우가 많다.

백엔드의 CORS 설정에서 exposedHeaders 값으로 노출하는 값을 변경할 수 있다.

// 백엔드
const cors = require("cors");
exports.options = cors({
  origin: [
    "http://localhost:5173",
  ],
  exposedHeaders: ["Authorization"],
  credentials: true,
});

최종 코드

참고로, set-Cookie에 보낸 refreshToken은 브라우저에서 자동으로 쿠키에 저장해준다.

try {
    const res = await API.post("/auth/login", userInput);      
    // access token 저장
    localStorage.setItem("accessToken", res.headers.get("Authorization"));
    // refresh token 저장 : 브라우저 자동
    // ... 유저 정보 저장 / redirect 등
  } 
catch (error) {
    // status에 따른 Error Handling
    const errorResponse = error.response;
    const statusCode = errorResponse.status;
    console.log(statusCode);
    switch (statusCode) {
      case 404:
        alert("해당 이메일로된 아이가 없습니다");
        break;
      case 401:
        alert("비밀번호가 틀렸습니다.");
        break;
      case 400:
        alert("Bad request (요청 형식이 잘못됨)");
        break;
    }
  }

[삽질 No.2] 로그아웃 기능

로그아웃 기능을 구현할때, ✏️access Token과 refresh Token을 삭제하고 싶다.

access Token은 localStorage에 있으므로, 쉽게 삭제 가능하다.

😡문제는 refresh Token이다.

쿠키에 있는 refresh token에 접근해서 삭제하고 싶었다.

암만 찾아봐도 방법이 없었다. 실제로 불가능했기 때문이다.

⭐백엔드에서 refresh Token을 쿠키에 담아서 보낼때 httpOnly:true 를 달아서 보냈는데, 해당 속성을 달아주면 프론트에서 암만 노력해도 접근할 수 없다. 일부러 막아둔 것이다.

잘 생각해보면 저장할 때도 자동으로 저장하는 이유가, 프론트에서 접근하지 못하니까 친절하게 알아서 저장해주는 것이였다.

❓그러면.. 어떻게 삭제해???

쿠키에는 2가지 종류가 있었다.

세션 쿠키사용자가 사이트 탐색 시에 관련한 설정들과 선호사항을 저장하는 임시 쿠키정도라고 생각하면 된다. 브라우저를 닫는 순간 삭제된다.
지속 쿠키지속쿠키는 세션쿠키와 다르게 삭제되지 않고 더 길게 유지가 가능하다. 지속쿠키는 디스크에 저장되며, 브라우저를 닫거나 컴퓨터를 재시작해도 남아닜다. 사용자 로그인 항상 유지와 같은 곳에 사용한다
차이점세션쿠키와 지속쿠키는 파기시점 외에 차이점이 없다. Discard라는 파라미터가 설정되어 있거나, 파기까지 남은시간인 Expires또는 Max-Age라는 파라미터가 없으면 세션쿠키이다.

즉, 우리는 지금까지 세션 쿠키를 사용하였지만, httpOnly 속성으로 인해 직접 쿠키를 제어할 수 없으므로, 지속 쿠키를 생성하기로 하였다.

즉, refresh-token을 삭제하는 대신 ⭐만료기간을 설정하는 방식으로 진행하였다.

백엔드분들한테 refreshToken의 기간을 설정해달라고 요청하였다.

return res
    .status(403)
    .cookie('refreshToken', refreshToken, {
        expires: new Date(Date.now() + 259200), // 기간 설정
        httpOnly: true,
    })
    .header('Authorization', newAccessToken)
    .json({ message: 'Renewed expired access token' });

지금까지, JWT를 구현할 때 삽질했던 기록들을 정리했습니다.

나중에 또 삽질할때 봐야겠다 :)


개인 블로그: https://velog.io/@jihostudy/posts

edited by 김지호