[인증/보안] Token, JWT, im-sprint-auth-token

윤태영 | Taeyoung Yoon·2022년 5월 24일
0

TIL (Today I Learned)

목록 보기
44/53
post-thumbnail

토큰 기반 인증

서버혹은 DB에 유저 정보를 담는 방식의 세션 기반 인증 방식은
매 요청마다 데이터베이스를 살펴봐야하는 부담이 있다.
이 부담을 클러이언트에게 넘겨주는 방식이 고안되었다.

JWT (JSON Web Token)

토큰 기반 인증 중 대표적인 인증방식이다.
클라이언트는 XSS, CSRF 공격에 노출이 될 위험이 있으니 민감한 정보를 담고 있어서는 안되지만
토큰은 유저 정보를 암호화한 상태로 담을 수 있고, 암호화했기 때문에 클라이언트에 담을 수 있다.

JWT의 종류

클라이언트가 처음 인증을 받게 될 때(로그인 시), access, refresh token 두 가지를 받는다.

Access Token

  • 보호된 정보들에 접근할 수 있는 권한부여에 사용한다.
  • 실제로 권한을 얻는 데 사용하는 토큰이다.
  • 권한을 부여받는 데엔 access Token만 가지고 있으면 안된다.
  • access Token은 비교적 짧은 유효 기간을 주어 탈취되더라도 오랫동안 사용할 수 없도록 하는 것이 좋다.

Refresh Token

  • access token의 유효기간이 만료된다면 refresh token을 사용하여 새로운 access token을 발급받는다. 이때, 유저는 다시 로그인할 필요가 없다.
  • 유효기간이 긴 refresh token 마저 탈취당하면 큰 문제가 된다.
  • 유저 편의보다 보안을 더 중요시하는 웹사이트는 refresh token을 사용하지 않는 곳이 많다.

JWT의 구조

어떤 종류의 토큰인지(지금의 경우엔 JWT), 어떤 알고리즘으로 sign할지가 적혀있다.
아래 예시의 JSON 객체가 base64로 인코딩 되어 들어간다.

{
  "alg": "HS256",
  "typ": "JWT"
}

Payload

정보가 담겨 있다. 어떤 정보에 접근 가능한지에 대한 권한이나 사용자의 유저 이름 등 필요한 데이터는 이곳에 담아 Sign 시킨다. 민감한 정보는 담지 않는 것이 좋다.
아래 예시의 JSON 객체가 base64로 인코딩 되어 들어간다.

{
  "sub": "someInformation",
  "name": "phillip",
  "iat": 151623391
}

Signature

원하는 비밀 키(암호화에 추가할 salt)를 사용하여 암호화한다.
base64 인코딩을 한 값은 쉽게 디코딩할 수 있지만, 비밀 키를 보유한게 아니라면 해독하는 데 엄청난 시간이 들어간다.
HMAC SHA256 알고리즘(암호화 방법 중 하나)을 사용한다면 signature는 아래와 같은 방식으로 생성된다.

HMACSHA256(base64UrlEncode(header) + '.' + base64UrlEncode(payload), secret);

JWT 사용예시

JWT는 권한 부여에 굉장이 유용하다.

새로 다운받은 A라는 앱이 Gmail과 연동되어 이메일을 읽어오는 예시

  1. Gmail 인증서버에 로그인 정보(아이디, 비밀번호)를 제공한다.
  2. 성공적으로 인증 시 JWT를 발급받는다.
  3. A 앱은 JWT를 사용해 해당 유저의 Gmail 이메일을 읽거나 사용할 수 있다 .

토큰기반 인증 절차

  1. 클라이언트가 서버에 아이디/비밀번호를 담아 로그인 요청을 보낸다.
  2. 아이디/비밀번호가 일치하는지 확인하고, 클라이언트에게 보낼 Signature 된 토큰을 생성한다.
  • access/refresh 토큰을 모두 생성한다.
  • 토큰에 담길 정보(payload)는 유저를 식별할 정보, 권한이 부여된 카테고리(사진, 연락처, 기타 등등)이 될 수 있다.
  • 두 종류의 토큰이 같은 정보를 담을 필요는 없다 (이 스프린트에서는 같은 정보를 담아줍시다).
  1. 토큰을 클라이언트에게 보내주면, 클라이언트는 토큰을 저장한다.
  • 저장하는 위치는 local storage, cookie, react의 state 등 다양하다.
  1. 클라이언트가 HTTP 헤더(authorization 헤더)에 토큰을 담아 보낸다.
  • bearer authentication을 이용한다. 참고 링크1(요약), 링크2(상세)
  1. 서버는 토큰을 해독하여 "아 우리가 발급해 준 토큰이 맞네!"라는 판단이 될 경우, 클라이언트의 요청을 처리한 후 응답을 보내준다.

토큰기반 인증의 장점

  1. Statelessness & Scalability (무상태성 & 확장성)
  • 서버는 클라이언트에 대한 정보를 저장할 필요 없다.
  • 같은 토큰으로 여러 서버에서 인증 가능하다.
  1. 안전하다
  • signature을 받은 토큰을 사용하고, 암호화 키를 노출할 필요가 없기 때문에 안전하다.
  1. 어디서나 생성 가능하다
  • 토큰을 확인하는 서버가 토큰을 만들어야 하는 법이 없다.
  • 토큰 생성용 서버를 만들거나, 다른 회사에서 토큰 관련 작업을 맡기는 것 등 다양한 활용이 가능하다.
  1. 권한 부여에 용이하다
  • 토큰의 payload(내용물) 안에 어떤 정보에 접근 가능한지 정할 수 있다.

im-sprint-auth-token

JWT를 이용해 토큰 방식인증을 구현한다. HTTPS 서버를 구축한다.

초기설정

mkcert를 통해 인증서를 발급받고 서버 디렉토리에 넣어준다.
.env 파일을 생성하고 데이터베이스 정보를 적고 엑세스 토큰과 리프레시 토큰 암호는 임의의 값을 적어줬다.

DATABASE_PASSWORD=데이터베이스비밀번호
DATABASE_USERNAME=root
DATABASE_NAME=authentication
ACCESS_SECRET= 암호,복호화 비밀번호
REFRESH_SECRET= 새로운 엑세스 토큰 생성 비밀번호

이전 스프린트와 같은 데이터베이스를 사용하기 때문에 별도로 시퀄라이저 마이그레이션을 해주진 않았다.

서버

/controllers/login.js

  • request로부터 받은 userId, password와 일치하는 유저가 DB에 존재하는지 확인합니다.
  • 일치하는 유저가 없을 경우:
    로그인 요청을 거절합니다.
  • 일치하는 유저가 있을 경우:
    필요한 데이터를 담은 두 종류의 JWT(access, refresh)를 생성합니다.
    생성한 JWT를 적절한 방법으로 반환합니다.
    access token은 클라이언트에서 react state로 다루고 있습니다.
    refresh token은 클라이언트의 쿠키에서 다루고 있습니다.
const { Users } = require('../../models');
const jwt = require('jsonwebtoken');

jsonwebtoken 라이브러리를 사용해 토큰을 생성해야 함으로 모듈을 불러온다.

module.exports = async (req, res) => {
  const userInfo = await Users.findOne({
    where: { userId: req.body.userId, password: req.body.password },
  });

요청받은 정보와 일치하는 유저가 DB에 존재하는지 확인하기 위해
DB에서 조건 { userId: req.body.userId, password: req.body.password } 에 맞는 첫번째 요소를 userInfo에 할당한다.

  if(!userInfo){
    res.status(404).send({ "data": null, "message": "not authorized" })

요청받은 정보와 일치하는 유저가 DB에 존재하지 않으면
404상태코드와 해당내용의 JSON 객체를 반환한다.

  } else {
    const payload = {
      id: userInfo.id,
      userId: userInfo.userId,
      email: userInfo.email,
      createdAt: userInfo.createdAt,
      updatedAt: userInfo.updatedAt
    }
    const accessToken = jwt.sign(payload, process.env.ACCESS_SECRET, { expiresIn: "1d"});
    const refreshToken = jwt.sign(payload, process.env.REFRESH_SECRET, { expiresIn: '2d' });

토큰에 담을 페이로드에 객체형태의 데이터베이스에서 찾은 유저 정보를 할당한다.
jwt.sign(토큰에_담을_값, ACCESS_SECRET, { 옵션1: 값, 옵션2: 값, ... });
각각 하루와 이틀간 유지되는 엑세스 토큰, 리프레시 토큰을 생성했다.

    res.cookie('refreshToken', refreshToken)
    res.status(200).send({"data": { "accessToken": accessToken }, "message": "ok"})
  }
};

로그인 성공시 리프레시 토큰 쿠키를 생성하는 응답.
200상태코드와 토큰을 응답에 포함한다.

/controllers/accesstokenrequest.js

  • authorization header에 담긴 토큰이 서버에서 생성한 JWT인지 확인합니다.
  • 서버에서 생성한 유효한 토큰일 경우, 유효하지 않은 토큰일 경우 각각 다른 응답을 반환합니다.
const { Users } = require('../../models');
const jwt = require('jsonwebtoken');

module.exports = async (req, res) => {
  const authorization = req.headers.authorization;
  if(!authorization){
    res.status(400).send({ "data": null, "message": "invalid access token" })

요청 헤더에 authorization이 존재하지 않으면 해당 JSON 객체를 반환

  } else {
    const token = authorization.split(' ')[1];
    const data = jwt.verify(token, process.env.ACCESS_SECRET);

jsonwebtoken 라이브러리를 사용해 토큰을 verify(해독, 검증)한다.
token변수에 요청헤더 authorization를 split하여 첫번째 요소를 담았는데
authorization값이 Bearer 토큰... 형태이기 때문에 타입을 제거하고 토큰을 담아준 것이다.
jwt의 verify함수를 이용해 토큰을 해독하여 data변수에 할당한다.

    const userInfo = await Users.findOne({
      where: { userId: data.userId}
    });
    if(!userInfo){
      res.status(400).send({ "data": null, "message": "access token has been tempered" })
    } else {
      res.status(200).send({
        'data': { userInfo: {
        'id' : userInfo.dataValues.id,
        'userId' : userInfo.dataValues.userId,
        'email' : userInfo.dataValues.email,
        'createdAt' : userInfo.dataValues.createdAt,
        'updatedAt' : userInfo.dataValues.updatedAt,
        }},
        'message' :'ok'
      })
    }
  }
};

async await를 이용해 비동기적으로 데이터베이스를 조회해 유저정보를 가져오고
유저정보가 없을 경우 상태코드400과 해당 JSON 객체를 응답,
유저정보가 있을 경우 상태코드 200과 양식대로 데이터를 담아 응답한다.

/controllers/refreshtokenrequest

요청에 담긴 refresh token이 유효하다면 새로운 access token을 발급해 줌과 동시에 유저가 요청한 정보를 반환합니다.
요청에 담긴 refresh token이 유효하지 않거나, 조작된 토큰일 경우 각각 다른 응답을 반환합니다.

const { Users } = require('../../models');
const jwt = require('jsonwebtoken');

module.exports = async (req, res) => {
  const refreshToken = req.cookies.refreshToken
  if(!refreshToken){
    res.status(400).send({ "data": null, "message": "refresh token not provided" })

쿠키에 리프레시 토큰이 담겨있는지 확인하고
그렇지 않을 경우 위와 같이 응답한다.

  } else {
    if(refreshToken === 'invalidtoken'){
      res.status(400).send({"data": null, "message": "invalid refresh token, please log in again" })

유효하지 않은 리프레쉬 토큰을 전달받은 경우 위와 같이 응답한다.

    } else {
      const data = jwt.verify(refreshToken, process.env.REFRESH_SECRET);
      const userInfo = await Users.findOne({ where: { userId: data.userId} });
      if(!userInfo){
        res.status(400).send({ "data": null, "message": "refresh token has been tempered" })

jwt의 verify함수를 이용해 토큰을 해독하여 data변수에 할당한다.
요청받은 유저정보가 데이터베이스에 없을 경우 위와 같이 응답한다.

      } else {
        const payload = {
          'id' : data.id,
          'userId' : data.userId,
          'email' : data.email,
          'createdAt' : data.createdAt,
          'updatedAt' : data.updatedAt
        }
        const accessToken = jwt.sign(payload, process.env.ACCESS_SECRET, { expiresIn : '1d' });
        res.status(200).send({ 'data':{ accessToken:accessToken, userInfo:payload }, message : 'ok' })
      }
    }
  }
};

새로운 생성한 엑세스 토큰과 민감한 정보가 없는 페이로드를 담아 응답한다.

메타인지

🎯 오늘의 학습목표

  • 토큰을 통해 인증 구현을 할 수 있다.
  • 클라이언트, 서버, 데이터베이스의 전체 동작을 이해할 수 있다.
  • 회원가입 및 로그인 등의 유저 인증에 대해 구현하고 이해한다

😎 학습할 내용 중에 알고 있는 것

HTTPS, 세션, 쿠키

✏️ 오늘 새롭게 학습한 것

토큰 기반 인증방식, JWT

🧷 오늘 학습한 내용 중 아직 이해되지 않은 부분

클라이언트 동작에 대한 이해가 부족했다.

💡 이해되지 않은 내용을 보완하기 위해 무엇을 할까

token 스프린트 클라이언트를 다시 작성해본다.

0개의 댓글