V-logue

[Node.js] Jwt access token과 refresh token 구현 본문

발자취/Node.js

[Node.js] Jwt access token과 refresh token 구현

보그 2022. 7. 20. 18:11

실전 프로젝트를 진행하면서, 로그인 기능을 구현하게 됐다.

 

jwt를 통해 로그인을 구현하는 건 항해를 진행하던 중 늘 하던 일이라, 별로 어렵지 않았는데

refreshToken을 사용하자는 말이 나왔다.

 

jwt는 기본적으로 Stateless한 방식이라서 토큰을 발급해주는 서버측에서는

토큰을 가지고 있는 유저가 과연 진짜 유저인지 확인할 수 없다는 에로사항이 발생한다.

요약 무상태(Stateless) - 
클라이언트와 서버 관계에서 서버가 클라이언트의 상태를 보존하지 않음
을 의미한다. 장점 : 서버의 확장성이 높기 때문에 대량의 트래픽 발생 시에도 대처를 수월하게 할 수 있다.

이에 대한 보안적인 측면을 강화하기 위해서 등장한게 refresh token인데 이 refresh token이라는걸

사용함으로서, 사용자에게 토큰을 재발급하고 그 재발급 주기를 짧게 함으로 사용자가

진실된 유저임을 인증한다고 간단하게 설명할 수 있다.

 

(편의상 access = a , refresh = rf라고 설명하겠습니다.)

a token과 그 보다 길게 설정된 rf token을 발급한 후 클라이언트로부터 발급한 토큰값을

다시 request하여 받아온 후 a token이 만료됐다면, rf token으로 새롭게 a token을 발급하게 된다.

 

짤은 주기설정으로 주기적으로 토큰을 재발급하고, 그 재발급 과정을 통해 토큰이 공격당해서

토큰에 들어있는 값들이 외부로 유출된다 하더라도 그 피해를 짧은 주기 설정으로

최소화 한다는 목적으로 rf token을 사용하게 된 것이다.

 

보안상의 이유로 rf token은 cookie에 저장하고, a token은 localstorage에 저장하기로 했다.

일단 이렇게 ,사용한 이유는 xss 공격과 csrf공격 때문이다.

 

기본적으로 storage에 저장하게 되면 xss공격에 취약하게 되고,

cookie에 저장하게 되면 csrf공격에 취약하다는 단점이 있다.

 

그리고 이 보안상의 이유라는 것은 Node의 helmet이라는 미들웨어가 storage에 담긴

a token값에대한 XSS공격을 막아주고,

쿠키에 refreshToken만 저장하고 새로운 accessToken을 받아와 인증에 이용하는 구조에서는

CSRF 취약점 공격을 방어할 수 있다. refreshToken으로 accessToken을 받아도

accessToken을 스크립트에 삽입할 수 없다면 accessToken을 사용해 유저 정보를 가져올 수 없기 때문이다.

 

rf token은 기본적으로 로그인할 때 발급하게 되는데,

const refreshToken = jwt.sign(payload, process.env.JWT_SECRET_REFRESH, {
          expiresIn: "2d",
        });

        
        await User.update({ refreshToken }, { where: { userId } });
        
        return res.
        status(200).
        cookie("refreshToken", refreshToken, { httpOnly: true, sameSite: 'none' }).
        send({ message: "로그인 하셨습니다.", token });

 return하고 a token을 내보내는 동시에 rf token도 response에 담아서 같이 내보낸다.

여기서 httpOnly옵션은 쿠키를 클라이언트에서 스크립트로 조회할 수 있다는 점을 악용하는

CSS(Cross Site Scripting) 공격을 막고, httpOnly 쿠키 방식으로 저장된 정보는 XSS 취약점 공격으로 담긴 값을 불러올 수 없다.  또, httpOnly 쿠키 역시 refreshToken만 저장하고 accessToken을 받아와 인증에 이용하는 구조로 CSRF 공격 방어가 가능하기 때문에 이를 위해서 사용했다.

 

httpOnly옵션을 사용하게 되면, 클라이언트에서 자바스크립트로 쿠키를 조회할 수 없게 된다.

문제는, httpOnly옵션 덕분에 클라이언트에서 

자바스크립트로 쿠키를 읽을 수 없게 되기 때문에 별도의 설정이 필요하다.

 

이 별도의 설정이 없다면 브라우저의 application tab의 쿠키항목에서 쿠키값을

읽어올 수 없게 된다.

 

그리고 위 설정을 하기위해 리액트를 사용하는 프론트와 협력하는 과정에서 엄청난 삽질을 하게 된다.

엄청난 삽질을 하고난 후 정리된 코드는 다음과 같다.

 

exports.refresh = async (req, res) => {
  console.log("refreshToken:", req.cookies.refreshToken)

  const refreshToken = req.cookies.refreshToken;

  console.log("refresh 입니다: ", refreshToken);

  if (!refreshToken) {
    return res.status(401).send({ errorMessage: "Token is expired" });
  }

  const users = await User.findAll({ where: { refreshToken } });

  try {
    if (!users) {
      return res.status(401).json({ errorMessage: "refreshToken is unvalidate" });
    } else {
      jwt.verify(refreshToken, process.env.JWT_SECRET_REFRESH, (err, data) => {
        if (err) {
          return res.status(403).send({ errorMessage: "refreshToken is unvalidate" });
        } else {
          const payload = {
            userId: refreshToken.userId,
            nickname: refreshToken.nickname,
          };
          const token = jwt.sign(payload, process.env.JWT_SECRET_KEY, {
            expiresIn: "1h",
          });
          return res.status(200).send({ message: "토큰이 재발급 됐습니다.", token });
        }
      });
    }
  } catch (err) {
    if (err) {
      console.log(err);
      return res.status(401).send({ errorMessage: "refreshToken is unvalidate" });
    }
  }
};

Node.js - user.controller.js

const express = require("express");
const app = express();
const helmet = require("helmet");
const morganMiddleware = require("./middlewares/morganMiddleware");
const cookieParser = require("cookie-parser");
const cors = require("cors");
const { sequelize } = require("./models");
// const db = require("./models/index.js");
const logger = require("./config/logger");

const Router = require("./routes");

app.use(
  cors({
    credentials: true
  })
);

sequelize
  .sync({ force: false })
  .then(() => {
    console.log("SEQUELIZE -=CONNECTED=-");
  })
  .catch((error) => {
    console.log(error);
  });

app.use(cookieParser());
app.use(morganMiddleware);
app.use(helmet({ contentSecurityPolicy: false, crossOriginEmbedderPolicy: false, crossOriginResourcePolicy: false }));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use("/api", Router);

module.exports = app;

Node.js - app.js

 

const api = axios.create({
  baseURL: "http://localhost:3000/",
  headers: {
    "content-type": "application/json;charset=UTF-8",
    accept: "application/json,",
    withCredentials: true,
  },
});

React - api.js

 

엄청난 삽질 끝에 일단 정리된 코드는 위와 같았다. cors 설정은 서버쪽에선 credentials : true

클라이언트에서는 withCredentials : true로 맞춰놓고 어딘가 미묘한 설정을 계속해서 맞춰가면서 하나씩 실행해 나갔다.

그러던 중 비슷한 사례를 계속해서 반복하는 실수를 거듭하고 있다는 생각이 들어서 Case를 정리하기로 했고,

각 Case마다 가져갈 것과 가져가지 않을 것을 구분했다.

 

일단 먼저 확인해 볼 것은 res.cookie로 내보내는 쿠키가 과연 application tab에 잘 들어가냐는 것이어서, 그것부터

확인하기로 했다.

 

res.cookie라는 값을 

Case 1 : cors 설정 origin : true, credentials : true , 실패
Case 2 : cors 설정 credentials : true , 실패
Case 3 : cors 설정 methods: 'GET, HEAD, PUT, PATCH, POST, DELETE' 추가 , 실패
Case 4 : api.js에 const api의 withCredential설정이 블록 밖으로 나오면 서버-클라이언트 연결이 끊어진다. , 실패

일단 기본적으로 httpOnly일때 뿐만아니라, 프론트와의 협력을 위해서는 cors설정은 필수다.

 

// Case 5

credentials: "include",
      headers: {
        "Content-Type": "application/json",
        "Access-Control-Allow-Credentials": true,
      }, // 실패
      
      //headers에  withCredentials: true 대신,
          //    "Access-Control-Allow-Credentials": true,
// Case 6

const api = axios.create({
  baseURL: "http://localhost:3000/",
  credentials: "include",
  headers: {
    "content-type": "application/json;charset=UTF-8",
    accept: "application/json,",
    withCredentials: true
  },
});

// 기존 api 설정에 credentials : "include"추가, 실패
Case 7 : api요청 POST에서 GET요청으로 변경, 실패 ( POST로 다시 변경)
// Case 8

// res.cookie("refreshToken", refreshToken, { httpOnly: true, SameSite : "None" });
        return res.
        status(200).
        cookie("refreshToken", refreshToken, { httpOnly: true, SameSite : "None" }).
        send({ message: "로그인 하셨습니다.", token });
        
        // res.cookie단일말고, token과 같이 내보냄
        // 정체모를 cookie가 application에 담기지만, 실패
Case 9 : SameSite "None"에서 "lax"로 변경, 실패
// Case 10

const api = axios.create({
  baseURL: "http://localhost:3000/",
  headers: {
    "content-type": "application/json;charset=UTF-8",
    accept: "application/json,",
    withCredentials: true,
    credentials: 'include'
  },
});

// credentials : 'include' 옵션을 headers 블록안에 넣음, 실패
// Case 11

const api = axios.create({
  baseURL: "http://localhost:3000/",
  headers: {
    "content-type": "application/json;charset=UTF-8",
    accept: "application/json,",
  },
  withCredentials: true,
  credentials: 'include'
});

// withCredentials, credentials 옵션 headers 블록 밖으로 꺼냄 , 성공!

credential : include옵션은
cross-origin 호출이라 할지라도 언제나 user credentials (cookies, basic http auth 등..)을 전송한다.

 

성공한줄 알았는데, 다시 안된다. error

문제의 원인은 브라우저 application tab에 쿠키가 안담기고, headers에 cookies:하고 박혀있지 않다.

 

그러다 sameSite가 lax일때 다시 성공했다가, 프론트와 연결하니 다시 쿠키가 들어오지 않았다.

이유를 찾다가 생각해보니 lax일때 성공한 것은 같은 네트워크 환경에서 테스트 해봤을 때였고,

프론트와 연결했을 때는 EC2서버에 프론트가 로컬로 연결해서 사용했을 때 였다.

결과적으로 다른 네트워크 환경에서 서로 다른 도메인을 통해서 통신하고 있는 상태였다.

.lax는 같은 네트워크 환경을 공유하고 있을 때, 크로스 도메인이 아니기 때문에 cookie를 서고 주고 받을 수 있었고,

프론트와의 연결은 같은 네트워크 환경을 공유하지 않은 크로스 도메인 상태이기 때문에 주고 받을 수 없었다라고

추측했다.

 

그렇다면, 크로스 도메인 상태일 때 서버와 클라이언트가 서로 쿠키를 주고 받기 위해서는 어떤 작업이 필요한가,

일단 우리서버에서 쿠키는 httpOnly옵션이기 때문에 자바스크립트에서 쿠키를 읽을 수 없다.

Get-cookie, Set-cookie이런 옵션은 여기서 제외하고 나서 고려해 볼 것은 sameSite:"None"이었다.

strict는 고려해볼 필요도 없었다. 크로스 사이트 요청에는 항상 쿠키를 전송하지 않기 때문이다.

sameSite가 None이라는 것은 크로스 사이트인 상태에서도 쿠키를 전송한다는 것이다.

 

여기서 문제는 크롬 80버전의 정책 변경에 따라, sameSite기본값이 "None"에서 "Lax"로 변했고

또 None을 사용한다면 secure도 true로 바꿔줘야 했다. 

secure : true라는 것은 서버와 클라이언트가 서로 https로 통신을 한다는 것을 의미하고,

그런 의미에서 서로 다른 분리배포를 https를 적용해서 배포해야 했다.

 

결국 최종적으로 남은 선택지는,

cookie("refreshToken", refreshToken, { httpOnly : true, samesite: 'None', secure: true })

라는 옵션이 남아버렸다.

 

하지만, 서로 도메인에 https를 연결하고 통신했을 때도 쿠키가 들어오지 않았다.

그러던 중 프로젝트 팀장님이 옵션을 바꿔가면서 테스트 하시다가, 문제점을 발견하셨다.

 

cookie("refreshToken", refreshToken, { httpOnly : true, sameSite: 'None', secure: true })

이 코드는 맞는 코드다. 위에 적힌 코드와 차이점이 느껴지는 사람이 있는지 모르겠다.

바로, sameSite가 소문자에서 대문자로 바뀐 어마어마한 변화가 있었다.

코드 한줄로 될 것같고, 될 것 같지 않던 그런 순간들이 존재했다. 그결과, 우리 사이트의 헤더에

위와 같이 쿠키가 들어오기 시작했다. 너무 감격스러운 순간, 팀장님께 무한한 감사를..^^

 

+++++

 

아니었다. 그냥 sameSite랑 상관없이 위에 걸로 잘되고 있었는데, application tab만보고 쿠키가 안들어온다고

생각했는데 이미 헤더에는 값이 들어오고 있었다. samesite 대문자 소문자는 영향이 없었다.

 

아무튼 , 여기까지 온 시점에서 종결이 난 코드들은

cookie("refreshToken", refreshToken, { httpOnly : true, sameSite: 'None', secure: true }) // cookie
const api = axios.create({
  baseURL: "https://example.com/",
  headers: {
  "Access-Control-Allow-Origin": "https://exampleServer.com/"
    "content-type": "application/json;charset=UTF-8",
    accept: "application/json,",
  },
  withCredentials: true,
  includes: true
}); //////////////// react
app.use(
  cors({
    origin: true, //["http://localhost:3001"],
    credentials: true,
    methods: 'GET, HEAD, PUT, PATCH, POST, DELETE'
  })
);              ///////// app.js

++++

 

Helmet의 xss를 예방하는 기능을 사용하기 위해서, csp설정을 해야한다.

현재 우리 사이트에서 이미지 리소스를 긁어오는 포인트는 S3에서 유저의 프로필 이미지를 긁어오는 것이 전부

이기 때문에 이미지 리소스는 S3만 허용하면 되고, 우리서버와 관련된 모든 페이지를 허용한다.

const cspOption = {
  directives : {
    ...helmet.contentSecurityPolicy.getDefaultDirectives(), // 기본옵션
    
    //  우리서버 API 도메인과 인라인된 스크립트를 허용합니다.
    "script-src" : ["'self'", "*.example.com", "'unsafe-inline'"],
    
    // 아마존 S3 의 이미지 소스를 허용합니다.
    "img-src": ["'self'", "s3.amazonaws.com"]
  }
}

app.use(helmet({ contentSecurityPolicy: cspOption, crossOriginEmbedderPolicy: false, crossOriginResourcePolicy: false }));

이로서 XSS와 관련된 문제는 일정부분 해결 됐다고 할 수 있다.(2022.08.06)

Comments