본문 바로가기
프로젝트 개발 기록/[개발] node.js | nest, express

[Node.js+Express] Refresh Token 구현

by HelloJudy 2022. 4. 25.

01. 개요

포트폴리오 공유 웹서비스 프로젝트

✔️ GitHub Repository 

 

 

1차 프로젝트로 포트폴리오 공유 웹서비스를 개발했다.

당시 개발 일정이 2주로 한정되어 있었고 시간이 부족해서 Access Token만을 이용한 인증 방식으로 개발을 했다.

 

개발 과정에서도 팀원끼리 한번 발급받은 Token으로 유효기간이 만료되기 전까지팀원 전체가 돌아가면서 사용했다.이때 들었던 생각은 만약에 제 3자가 유효기간이 만료되기 전 Token탈취해서 사용하게 된다면우리 서비스는 보안이 좋다고 할 수 있을까? 우리 서비스의 사용자가 안심하고 서비스를 이용할 수 있을까? 라는 고민이 들었다. 그래서 현재 진행하고 있는 2차 프로젝트에는 Refresh Token을 구현하려고 한다.

 

+

JWT 토큰을 사용할까?

Stateful 해야하는 세션의 단점을 보완하고 서버의 Stateless(무상태성)을 유지하고자 JWT를 사용했다!

 


 

02. Access token과 Refresh Token

 

해당 포스팅은 개념을 설명하는 것을 목적으로 하고 있지 않기 때문에 개념에 대한 정리는 차후에 깃에 정리해서 링크를  추가하도록 하겠다.

 

그러면 일단 간단하게 개념을 이야기하고 넘어가자.

 

출처: 웹표준(RFC 7519)

 

OAuth 2.0 ( RFC 6749)를 검색하면 다음과 같은 Access Token과 Refresh Token의 인증 과정을 도식화 되어 있는 것을 볼 수 있다.

 

개요에서 언급했다시피 Access Token만을 이용한다면 토큰의 유효기간이 만료 전 제 3자에게 탈취될 가능성이 높으므로 보안 이슈가 생길 수 있다. 

 

이것을 해결하기 위해 보통 토큰의 유효기간을 30분~2시간으로 짧게 설정한다. 하지만 유효기간이 짧으면 다시 로그인을 해서 토큰을 발급받아야 하기 때문에 사용자가 서비스 이용에 불편을 겪을 수 있다.

 

 

현재 우리가 개발하고 있는 서비스는 유효기간을 1시간으로 두고 있다. 지금부터 앞선 문제를 해결하기 위해 Refresh Token을 구현해보자.

 

 

🌀 Refresh Token

 

Access Token이 만료되었을 때 새로 발급을 해주기 위한 토큰이다. 그래서 보통 Refresh token은 보통 2주로 둔다. 하지만 Refresh Token도 탈취 될 가능성이 있기 때문에 적절한 유효기간 설정이 필요하다.

 

Access Token이 만료되었을 때, Refresh Token이 만료되지 않았다면 Access Token을 재발급하는 형태로 인증을 하게 된다. (  Access Token의 유효기간이 만료되었을 때, Refresh Token이 새로 발급해주는 열쇠 )

 

 


03. 구현

 

이제 본격적으로 Node.js+Express 에서 구현해보자. 

 

1) Refresh Token 저장소

 

내가 찾아본 예시에는 Redis에 저장하는 것도 보았지만 우리 프로젝트는 기본적으로 MongoDB를 사용하고 있기 때문에 MongoDB에 저장했다.

 

 

💁‍♂️ token schema

 

const TokenSchema = new Schema(
  {
    user_id: {
      type: String,
      required: true,
    },
    refreshToken: {
      type: String,
      required: true,
    },
  },
  {
    timestamps: true,
  }
);

 

💁‍♂️ Token Model

 

class TokenModel {
  static async findToken(userId) {
    const userToken = await Token.findOne({ _id: userId });
    return userToken;
  }

  static updateRefresh = async ({ _id, refreshToken }) => {
    const update = await Token.updateOne(
      { _id },
      { _id, refreshToken },
      { upsert: true }
    );
    return update;
  };
}

 

 

 

2) Access token을 생성하는 함수

 

utils 폴더 안에 토큰을 만드는 함수를 정의했다.

 

import jwt from "jsonwebtoken";
import dotenv from "dotenv";
dotenv.config();

const JWT_KEY = process.env.JWT_KEY;
const makeToken = (Object) => {
  const token = jwt.sign(Object, JWT_KEY, { expiresIn: "1h" });
  return token;
};

const makeRefreshToken = () => {
  // refresh token 발급
  const refreshToken = jwt.sign({}, JWT_KEY, {
    // refresh token은 payload 없이 발급
    algorithm: "HS256",
    expiresIn: "14d",
  });
  return refreshToken;
};

export { makeToken, makeRefreshToken };

 

 

3) 로그인 로직에 Access Token, Refresh Token 발급

 

Access Token, Refresh Token을 발급해서 client에게 모두 반환한다.

이때 Refresh Token은 유저 id와 함께 mongoDB에 함께 저장한다.

 

static findUser = async ({ email, password }) => {
    const discoveredUser = await UserModel.findByEmail({ email });
    const hashedPassword = hashPassword(password);
    const userId = String(discoveredUser._id);

    if (!discoveredUser) {
      const errorMessage = "해당 이메일로 가입한 내역이 없습니다.";
      return { errorMessage };
    } else if (discoveredUser.password === hashedPassword) {
      const accessToken = makeToken({ userId: userId });
      const refreshToken = makeRefreshToken();

      const setRefreshToken = await TokenModel.updateRefresh({
        _id: userId,
        refreshToken,
      });

      return {
        discoveredUser,
        accessToken,
        refreshToken,
      };
    } else {
      const errorMessage = "비밀번호가 틀립니다 다시 한 번 확인해 주세요";
      return { errorMessage };
    }
  };

 

 

4) Access token 재발급

 

Access token 재발급 하는 미들웨어를 만들자.

 

재발급을 위해서 Client는 header에 Access Token, Refresh Token 모두 보내야 한다.

 

 

 

💁‍♀️ 요청 예시 header

 

{
  "Authorizaiton":"Bearer access-token",
  "Refresh":"refresh-token"
}

 

 

[ 시나리오 1️⃣ : Access Token 만료 + Refresh Token 만료 ]

 

이 경우에는 사용자는 로그인을 다시 해야한다.

 

 

[ 시나리오 2️⃣ : Access Token 만료 + Refresh Token 만료X ]

 

새로운 Access token을 발급한다.

 

 

[ 시나리오 3️⃣ : Access Token 만료X ]

 

토큰을 다시 발급받을 필요가 없다.

 

 

 

💁‍♂️ verifyRefresh 미들웨어

 

const verifyRefresh = async (req, res, next) => {
  if (req.headers["authorization"] && req.headers["refresh"]) {
    const token = req.headers["authorization"].split(" ")[1];
    const refreshToken = req.headers["refresh"];

    // access token 검증 -> expired여야 함.
    const authResult = verify(token);

    // access token 디코딩하여 userId를 가져온다.
    const decoded = jwt.decode(token);

    // 디코딩 결과가 없으면 권한이 없음을 응답.
    if (!decoded) {
      res.status(401).send({
        ok: false,
        message: "No authorized!",
      });
    }

    /* access token의 decoding 된 값에서
        유저의 id를 가져와 refresh token을 검증합니다. */
    const refreshResult = await refreshVerify(refreshToken, decoded.userId);

    // 재발급을 위해서는 access token이 만료되어 있어야합니다.
    if (authResult.ok === false && authResult.message === "jwt expired") {
      // 1. access token이 만료되고, refresh token도 만료 된 경우 => 새로 로그인해야합니다.
      if (refreshResult === false) {
        res.status(401).send({
          ok: false,
          message: "No authorized! 다시 로그인해주세요.",
        });
      } else {
        // 2. access token이 만료되고, refresh token은 만료되지 않은 경우 => 새로운 access token을 발급
        const newAccessToken = makeToken({ userId: decoded.userId });

        res.status(200).send({
          // 새로 발급한 access token과 원래 있던 refresh token 모두 클라이언트에게 반환합니다.
          ok: true,
          data: {
            accessToken: newAccessToken,
            refreshToken,
          },
        });
      }
    } else {
      // 3. access token이 만료되지 않은경우 => refresh 할 필요가 없습니다.
      res.status(400).send({
        ok: false,
        message: "Acess token is not expired!",
      });
    }
  } else {
    // access token 또는 refresh token이 헤더에 없는 경우
    res.status(400).send({
      ok: false,
      message: "Access token and refresh token are need for refresh!",
    });
  }
};

 

이때 미들웨어에서 Access Token과 Refresh Token 의 유효성을 검사하는 함수는 따로 utils에 정의해주었다.

 

 

💁‍♂️ verify 

 

// access token 유효성 검사
const verify = (token) => {
  try {
    const decoded = jwt.verify(token, JWT_KEY);
    return {
      ok: true,
      userId: decoded.userId,
    };
  } catch (error) {
    return {
      ok: false,
      message: error.message,
    };
  }
};

// refresh token 유효성 검사
const refreshVerify = async (token, userId) => {
  try {
    // db에서 refresh token 가져오기
    const { refreshToken } = await TokenModel.findToken(userId);
    if (token === refreshToken) {
      try {
        jwt.verify(token, JWT_KEY);
        return true;
      } catch (err) {
        return false;
      }
    } else {
      return false;
    }
  } catch (err) {
    return false;
  }
};

 

 

 

4) Token을 재발급 받기 위한 Refresh Token 라우터 

 

Access token을 재발급 하기 위한 router를 만들어 준다.

 

클라이언트가 토큰이 필요한 유저 서비스에 접근하기 위해서 Access Token을 Header에 담아 Request를 했을 때 Access Token이 만료되었다는 Response를 받으면 

 

클라이언트는 Header에 Access token과 Refresh token를 담아서 Refresh 요청해야 한다. ( 재발급 요청 )

 

 

loginRouter.get("/refresh", verifyRefresh);

 

 


✔️ 인증과 인가에 대한 개념을 이해하는데 도움을 받은 영상

 

📌 Reference

반응형

댓글