JWT

김동현·2023년 6월 25일
0

nodeJS

목록 보기
13/14

JWT는 Json Web Token의 준말로, Authentication(인증) 및 Authorization(권한부여) 에서 session을 대체할 수 있다.

Authentication(인증) 및 Authorization(권한부여)를 살펴보기 전에 회원가입 기능부터 구현해보자.

사용자 DB 설정

사용자 DB는 json파일로 간단하게 구현했다.

import { writeFile } from "node:fs/promises";
import path from "node:path";
import users from "./users.json" assert { type: "json" };

const usersDB = {
  users,
  async setUsers(users) {
    this.users = users;
    const data = JSON.stringify(users);
    return new Promise(async (resolve, reject) => {
      try {
        await writeFile(path.join("db", "users.json"), data);
        resolve("save success");
      } catch (err) {
        reject(err);
      }
    });
  },
};

export default usersDB;

ESM 모듈에서는 json파일을 로드하려면 from 뒤에
assert {type: "json"} 을 붙여야 한다.
표준은 아니고 아직 실험적으로 사용되는 기능이다.

회원가입

export async function registerController(req, res) {
  const { username, password, email } = req.body;
  // 데이터 존재 확인 검증
  if (!(username && password && email)) return res.status(400).json({ error: { message: "data is required" } });
  // 아이디 중복 확인
  if (usersDB.users.find((user) => user.username === username)) return res.status(400).json({ error: { message: "중복된 아이디입니다." } });
  // 패스워드 암호화
  const saltRounds = 10;
  bcrypt.hash(password, saltRounds, function (err, hash) {
    if (err) return res.sendStatus(500); // 서버 오류
    // 사용자 정보 객체 만들기
    const currentUser = {
      username,
      password: hash,
      email,
      userId: uuidv4(),
    };
    // 사용자 정보 DB에 저장하기
    usersDB
      .setUsers([...usersDB.users, currentUser])
      .then((msg) => {
        console.log(msg);
        // 성공 메시지 응답
        return res.status(201).json({ success: { message: "created successfully" } });
      })
      .catch((err) => {
        console.error(err);
        // 서버 오류
        return res.sendStatus(500);
      });
  });
}

API Test



Authentication ( 인증 )

위에서 회원가입을 해서 "ehd8054" 라는 계정을 만들었다.
jwt를 이용한 로그인 기능을 만들어서 로그인해보자.

login

export async function loginController(req, res) {
  const { username, password } = req.body;
  // 데이터 존재 확인 검증
  if (!(username && password)) return res.status(400).json({ error: { message: "data is required" } });
  // 아이디 존재 확인
  const foundUser = usersDB.users.find((user) => user.username === username);
  if (!foundUser) return res.status(404).json({ error: { message: "user not exist" } });
  // 찾은 계정과 비밀번호 일치 확인
  bcrypt.compare(password, foundUser.password, function (err, isEqual) {
    if (err) return res.sendStatus(500); // 서버 에러
    if (!isEqual) return res.status(401).json({ error: { message: "invalid password" } }); // 비밀번호 틀림
    // password를 제외한 jwt payload에 담을 객체 생성
    const { password, ...others } = foundUser;
    // jwt 객체 생성
    const accessToken = jwt.sign(others, "ACCESS_TOKEN_SECRET", { issuer: "DH" });
    return res.status(200).json({ accessToken });
  });
}

로그인에 성공하면 서버는 jwt토큰을 클라이언트로 보낸다.

클라이언트는 서버에 요청할때마다 jwt 토큰을 같이 보내고, 서버는 이 토큰의 유무(검증에 통과한다고 가정)로 로그인 상태를 판단한다.

또한 서버는 jwt를 decode한 뒤, payload 부분에 담겨있는 정보를 토대로 요청자가 누구인지 판단한다.

decode는 서버만 할 수 있는게 아니다.
클라이언트에서도 atob() 메서드를 통해 payload부분을 decoded 할 수 있다.
payload부분은 암호화한 것이 아니라 단순히 base64기반의 문자열로 인코딩한 것 뿐이다.
jwt의 SIGNATURE부분만 암호화된다.
서버는 바로 그부분만 검증한다.
따라서 payload부분에는 프라이버시한 내용을 담으면 안된다.

API Test

Verify

클라이언트가 jwt를 서버로 보내면 서버는 이를 검증해야 한다.

const jwtVerify = (req, res, next) => {
  // jwt 유무 확인
  const token = req.headers?.authorization?.split(" ")[1];
  if (!token) return res.status(403).json({ error: { message: "user not logged in" } });
  // jwt 검증
  jwt.verify(token, "ACCESS_TOKEN_SECRET", function (err, decoded) {
    if (err) return res.status(403).json({ error: { message: "invalid token" } }); // 검증 에러
    // req 객체에 현재 유저의 정보를 추가
    const { username, email, userId } = decoded;
    req.user = { username, email, userId };
    // 검증 통과
    next();
  });
};

API Test

api/auth/ 리소스는 로그인을 한 유저에게만 제공된다고 가정하자.
jwtVerify 미들웨어를 컨트롤러 앞에 세팅했다.

const secretController = (req, res) => {
  return res.status(200).json({ content: "secret contents" });
};
// 라우터
authRouter.get("/", jwtVerify, secretController);

jwt을 헤더에 담아 보내보자.

로그인을 하지 않은 경우(jwt이 없는 경우)를 보자.

임의로 토큰을 수정하여 잘못된 토큰을 보내보자.
맨 앞 문자를 소문자에서 대문자로 딱 1개 바꿨다.

logout

서버는 유저의 로그인 상태를 저장하지 않는다.
즉, 로그인 및 로그아웃 상태는 클라이언트에 저장되어 있다.
jwt가 클라이언트에 존재하면 로그인, 존재하지 않으면 로그아웃이다.

Refresh token

위의 jwt 사용법은 보안에 취약하다.
만약 해커가 이 토큰을 탈취하여 본래의 사용자인것처럼 서버에 요청을 보낸다면, 서버는 이를 알 방법이 없다.
되도록이면 탈취하기 어렵게 jwt을 관리해야한다.
위의 예시처럼 서버는 jwt을 json객체에 담아 클라이언트에게 보내고 클라이언트는 이 토큰을 메모리단에서만 사용해야한다.
메모리에 저장하는 것이 상대적으로 훔치기 어렵기 때문이다.
local storage나 캐시 및 쿠키와 같은 로컬 공간 및 브라우저 저장소에 저장하는 것은 훔치기 쉽다.
따라서 서버는 response객체의 헤더에 캐시 및 쿠키를 설정해서는 안된다.
하지만 탈취가 어려워졌을 뿐이지 탈취되었을 경우의 해결책은 여전히 없다.

해결 방법 중 하나는 토큰의 유효기간을 짧게 설정하는 것이다.
즉, 탈취되더라도 몇초뒤면 못쓰게 되도록 만드는 것이다.
하지만, 이렇게 하면 토큰의 유효기간이 너무 빨리 끝나서 다시 로그인을 해야 하는 상황이 발생한다.
그래서 refresh token이라고 하는 개념을 사용한다.
refresh token은 유효기간이 긴 토큰이고, access token은 유효기간이 짧은 토큰이다.

동작 방식은 다음과 같다.
1. 로그인 성공시 access token과 refresh token 2개를 만들어서 클라이언트에 응답한다.
2. access token으로 인증하다가 토큰의 유효기간이 끝나면 refresh token을 서버에 보낸다.
3. 서버는 refresh token을 decode하고, 문제가 없을 시 access token을 새로 만들어서 클라이언트에게 응답한다.
4. 이렇게 access token이 만료되더라도 refresh token의 유효기간만큼 자동으로 로그인 상태를 유지할 수 있다.

이러면 access token이 유효기간이 짧더라도 로그인을 유지할 수 있다.

하지만 이 refresh token 마저 탈취될 수 있다.
사실 토큰의 탈취는 어디까지나 사용자 잘못이라서 서버쪽에선 나몰라라 할 수도 있다.
하지만 사용자가 탈취당한 토큰을 신고하면 서버쪽에서 막아줄 필요는 있다.
따라서 특정 refresh token을 블랙리스트로 등록 또는 삭제하기 위해서는 서버에서 refresh token를 관리할 수 밖에 없다.
이 말인즉, 서버에 refresh token을 저장해야 한다는 말이다.

그런데 이러면 jwt의 장점 중 하나인 "stateless"가 사실상 무용지물된다.
서버에서 로그인 상태정보를 관리하는 세션 방식과는 달리 jwt는 로그인 상태정보를 서버에서 관리하지 않는 것(stateless)이 장점인데, 결국 이렇게 되면 jwt의 장점이 사라진다.

물론 세션 방식과 동일하진 않다.
세션은 매번 요청마다 session DB 테이블을 검사해서 로그인 상태여부를 체크하지만 jwt는 refresh token을 이용해서 access token을 재발급 받을 때만 refresh token DB를 참조하기 때문에 서버의 부하가 좀 더 줄어든다.

따라서 웹을 설계하는 사람은 이러한 조건들을 다 따져보고 세션 방식과 jwt 방식 중에서 하나를 선택해야 한다.

Refresh token을 이용한 인증

login

export async function loginController(req, res) {
  const { username, password } = req.body;
  // 데이터 존재 확인 검증
  if (!(username && password)) return res.status(400).json({ error: { message: "data is required" } });
  // 아이디 존재 확인
  const foundUser = usersDB.users.find((user) => user.username === username);
  if (!foundUser) return res.status(404).json({ error: { message: "user not exist" } });
  // 찾은 계정과 비밀번호 일치 확인
  bcrypt.compare(password, foundUser.password, async function (err, isEqual) {
    if (err) return res.sendStatus(500); // 서버 에러
    if (!isEqual) return res.status(401).json({ error: { message: "invalid password" } }); // 비밀번호 틀림
    // jwt payload에 담을 객체 생성
    const { password, ...others } = foundUser;
    // jwt 객체 생성 - access token
    const accessToken = jwt.sign(others, "ACCESS_TOKEN_SECRET", { issuer: "DH", expiresIn: 30 });
    // jwt 객체 생성 - refresh token
    const refreshToken = jwt.sign(others, "REFRESH_TOKEN_SECRET", { issuer: "DH", expiresIn: "1d" });
    // 생성한 refresh token을 서버에서 관리하기 위해 유저별로 저장.
    // 해당 계정에 연결된 refresh token이 이미 DB에 존재한다면 refresh token 교체
    const exist = refreshTokensDB.refreshTokens.find((item) => item.userId === others.userId);
    let otherTokens = null;
    let currentToken = null;
    if (exist) {
      otherTokens = refreshTokensDB.refreshTokens.filter((item) => item.userId !== others.userId);
      currentToken = { ...exist, refreshToken };
    } else {
      // 해당 계정에 연결된 refresh token이 DB에 없다면 refresh token 생성
      otherTokens = refreshTokensDB.refreshTokens;
      currentToken = { userId: others.userId, refreshToken };
    }
    // refresh tokon DB에 해당 유저의 refresh token 저장
    refreshTokensDB
      .setRefreshTokens([...otherTokens, currentToken])
      .then(() => {
        // refresh token 쿠키에 서명하기, 실제 서비스땐 https 프로토콜만 사용하도록 하고 secure 속성 추가
        res.cookie("jwt", refreshToken, { httpOnly: true, maxAge: 1000 * 60 * 60 * 24, signed: true });
        return res.status(200).json({ accessToken });
      })
      .catch((err) => {
        // 서버 에러
        return res.sendStatus(500);
      });
  });
}

access token의 유효기간은 30초, refresh token의 유효기간은 하루로 설정했다.
access token은 클라이언트의 메모리에 저장되도록 json객체로 전달하는 반면, refresh token은 클라이언트의 쿠키에 저장되도록 설정했다.
refresh token도 메모리에 저장되도록 해도 상관없으나, 브라우저가 종료될 때마다 refresh token을 잃어버리게 되므로 쿠키에 저장했다.
차라리 잃어버리는 것이 더욱 보안에 적합하지만 개발자의 선호도에 따르도록 한다.
여기서는 쿠키를 사용하도록 하자.
다만, 자바스크립트로 쿠키를 사용하지 못하도록 httpOnly 속성을 true 로 설정했고 쿠키의 유효기간을 refresh token의 유효기간과 같도록 설정했다.
또한 변조된 쿠키를 선별하기 위해 cookie-parser 서드파티 모듈이 제공하는 쿠키서명 기능도 추가했다.

또한 생성한 refresh token을 서버에서 관리하기 위해 refresh token DB에 저장하는 코드도 추가하였다.

다만, 로그인을 여러번 시도할 경우 (정상적인 사용이 아닐 때) refresh token이 계속 추가되는 것을 방지하고자 한 명의 유저에게 하나의 refresh token만이 발급되도록 userId와 refresh token을 함께 묶어서 refresh token DB에 저장하도록 작성했다.

API Test

response body부분은 달라진게 없다.

쿠키부분을 보자.

jwt 쿠키의 value를 보면 앞에 "s%"가 붙어있다.
이 쿠키는 서명된 쿠키라는 의미이다.
refresh token이 refresh token DB에 잘 저장되었는지도 확인해보자.

Verify

verify 부분은 access token만을 이용하므로 이전과 달라진게 없다.
다만, access token의 유효기간이 짧아서 수 초만 지나도 검증이 실패가 된다는 것만 다르다.

Update Access Token

refresh token의 유효기간이 남아있다는 가정하에 access token을 재발급 받을 수 있다.

export async function refreshtokenController(req, res) {
  // request 쿠키에 refresh token 유무 검사
  const refreshToken = req?.signedCookies?.jwt;
  if (!refreshToken) return res.status(403).json({ error: { message: "refresh token not exist" } });
  // refresh token DB에 저장되어있는 token인지 확인
  // 만약 없다면 관리자가 임의로 삭제시킨 블랙리스트 토큰이거나 잘못된 토큰이라는 뜻이다.
  const exist = refreshTokensDB.refreshTokens.find((token) => token.refreshToken === refreshToken);
  if (!exist) return res.status(403).json({ error: { message: "Forbidden" } });
  // 검증
  jwt.verify(refreshToken, "REFRESH_TOKEN_SECRET", function (err, decoded) {
    if (err) return res.status(403).json({ error: { message: "Invalid refresh token" } });
    const { username, email, userId } = decoded;
    const accessToken = jwt.sign({ username, email, userId }, "ACCESS_TOKEN_SECRET", { issuer: "DH", expiresIn: 30 });
    return res.status(200).json({ accessToken });
  });
}

API Test

유효기간이 끝난 access token으로 private한 컨텐츠에 접근해보자.

refresh token을 이용해서 access token을 재발급 받아보자.

재발급 받은 access token을 이용해서 다시 private한 컨텐츠에 접근해보자.

logout

refresh token을 사용한 인증은 서버에 refresh token 정보가 저장되어 있기 때문에 클라이언트단 뿐만 아니라 서버단에서도 해줘야 할 것들이 있다.

export async function logoutController(req, res) {
  // verify 미들웨어를 거쳐서 사용자 인증을 마쳤다고 가정한다.
  const { userId } = req.user;
  // refresh token DB에서 refresh token을 제거한다.
  const DBtokens = refreshTokensDB.refreshTokens.filter((item) => item.userId !== userId);
  await refreshTokensDB.setRefreshTokens([...DBtokens]);
  // 클라이언트의 쿠키에서도 refresh token을 제거한다.
  res.clearCookie("jwt", { httpOnly: true, signed: true });
  return res.status(200).json({ success: { message: "logged out successfully" } });
}

API Test

verify 미들웨어를 거쳐야 하기 때문에 헤더에 access token 정보를 넣어줘서 테스트해야 한다.


쿠키는 서명정보만 남아있고 token 알맹이는 사라졌다.

DB에도 토큰정보가 사라졌다.

Authorization ( 권한부여 )

각 사용자들의 권한이 다를 수 있다.

  • 관리자 권한
  • 편집자 권한
  • 유저 권한
const ROLES = {
  ADMIN: process.env.ADMIN,
  EDITOR: process.env.EDITOR,
  USER: process.env.USER,
};

ROLES 객체의 key로 역할을 설정한다.
이렇게 하면 외부에서 볼 때, 값만 보이므로 이 값이 어떤값인지 알 수 없어서 보안에 유리하다.

더욱 세부적으로 나눌 수도 있겠지만 여기선 위와 같이 나눈다.
관리자 권한이나 편집자 권한은 일반적으로 회원가입으로 만들어지지 않고 개발자가 직접 만들어야 한다.

회원가입

회원가입으로 생성된 계정은 유저 권한이다.
회원가입을 수정해보자.

export async function registerController(req, res) {
  const { username, password, email } = req.body;
  // 데이터 존재 확인 검증
  if (!(username && password && email)) return res.status(400).json({ error: { message: "data is required" } });
  // 아이디 중복 확인
  if (usersDB.users.find((user) => user.username === username)) return res.status(400).json({ error: { message: "중복된 아이디입니다." } });
  // 패스워드 암호화
  const saltRounds = 10;
  bcrypt.hash(password, saltRounds, function (err, hash) {
    if (err) return res.sendStatus(500); // 서버 오류
    // 사용자 정보 객체 만들기
    const currentUser = {
      username,
      password: hash,
      email,
      userId: uuidv4(),
      role: ROLES.USER, // 역할 추가
    };
    // 사용자 정보 DB에 저장하기
    usersDB
      .setUsers([...usersDB.users, currentUser])
      .then((msg) => {
        console.log(msg);
        // 성공 메시지 응답
        return res.status(201).json({ success: { message: "created successfully" } });
      })
      .catch((err) => {
        console.error(err);
        // 서버 오류
        return res.sendStatus(500);
      });
  });
}

API Test


login

 // jwt payload에 담을 객체 생성
const { password, ...others } = foundUser;
// jwt 객체 생성 - access token
const accessToken = jwt.sign(others, "ACCESS_TOKEN_SECRET", { issuer: "DH", expiresIn: 30 });

위에서 사용한 로그인 코드 중 일부이다.

사용자 DB에서 password 속성을 제외한 나머지 속성들을 access token의 payload에 저장한다.
그에 따라 roles 속성도 payload에 저장된다.
따라서 login 컨트롤러는 수정할 것이 없다.

Verify

검증 단계에서는 역할이 맞지 않으면 접근을 차단하는 기능이 추가되어야 한다.

function jwtVerify(roles = [ROLES.ADMIN, ROLES.EDITOR, ROLES.USER]) {
  return (req, res, next) => {
    // jwt 유무 확인
    const token = req.headers?.authorization?.split(" ")[1];
    if (!token) return res.status(403).json({ error: { message: "user not logged in" } });
    // jwt 검증
    jwt.verify(token, "ACCESS_TOKEN_SECRET", function (err, decoded) {
      if (err) return res.status(403).json({ error: { message: "invalid token" } }); // 검증 에러
      // req 객체에 현재 유저의 정보를 추가
      const { username, email, userId, role } = decoded;
      // 사용자 role 확인
      if (!roles.includes(role)) return res.status(403).json({ error: { message: "not admin" } }); // 해당 역할 접근 금지
      // req객체에 사용자 정보 추가
      const currentUser = { username, email, userId, role };
      req.user = currentUser;
      // 검증 통과
      next();
    });
  };
}

jwtVerify 미들웨어를 고차함수로 만들었다.
매개변수는 허락할 역할들을 담고있는 배열이다.
라우터에서의 사용법을 보면 아래와 같다.

authRouter.get("/", jwtVerify([ROLES.USER, ROLES.EDITOR, ROLES.ADMIN]), secretController);
authRouter.get("/admin", jwtVerify([ROLES.ADMIN]), adminController);

secretController 컨트롤러는 user, editor, admin 전부 접근 가능하지만 adminControlloer 컨트롤러는 admin만 접근 가능하다.

API Test

"USER" role을 가지고 있는 "ehd8054" 계정으로 위의 2개의 url에 접근해보자.

profile
프론트에_가까운_풀스택_개발자

0개의 댓글