⚙️ Backend/🍀 Node Express

Node Express에서 JWT 인증 방식 직접 구현하기 ( Access Token & Refresh Token)

코너(Corner) 2022. 7. 10.
반응형

Node Express에서 JWT 인증 방식 직접 구현하기 ( Access Token & Refresh Token)

jwt

JWT (Json Web Token)이 무엇인가요?

이전 포스팅에서는 JWT 토큰에 대한 내용이 허술 했는데, 이 글에서 보완해서 보충 설명을 하도록 하겠습니다.

이전 포스팅 내용에서도 언급했듯이 JWT는 클라이언트와 서버 사이 통신시 권한을 인가하기 위해 사용하는 토큰입니다.

header-payload-signature세 부분으로 이루어져 있는 것이 특징입니다.

Header

JWT를 검증하는데 필요한 정보를 가진 JSON을 Base64 알고리즘 기반으로 인코딩한 문자열입니다.

검증을 하기 위한 내용을 가지고 있습니다.

Payload

JWT에 저장된 값입니다.

(name, value)의 쌍으로 이루어져 있고, JWT에는 이 값들을 여러 개 할당할 수 있습니다.

Payload의 값은 암호화되지 않기에 비밀번호와 같은 민감한 값을 넣으시면 안됩니다.

SIgnature

JWT를 인코딩하거나 유효성 검증을 할 때 사용하는 암호화된 코드입니다.

Access token과 Refresh Token

images%2Fkshired%2Fpost%2Fcb1f6f10-4979-4de6-bb09-92a513b5832b%2F995EC2345B53368912

  1. Access Token만을 이용한 서버 인증방식
    • 사용자가 로그인 -> 서버에서 사용자 확인 -> Access Token(JWT)에 권한 인증을 위한 정보를 Payload에 넣고 생성 ->
    • 생성한 토큰을 클라이언트에게 반환 -> 클라이언트는 발급받은 토큰을 저장 ->
    • 클라이언트는 권한 인증이 필요한 요청을 할 때마다 이 토큰을 헤더에 저장하여 보냅니다. ->
    • 서버는 헤더의 토큰을 검증하고, Payload의 값을 디코딩하여 사용자의 권한을 확인하고 데이터를 반환합니다. ->
    • 만약, 토큰이 valid(유효)하지 않거나 만료되었다면 다시 로그인하여 토큰을 발급 받아야 합니다.
  2. Refresh Token
    • Access Token만을 이용한 인증 방식의 문제는 제 3자에게 토큰을 탈취당하게 되면, 토큰의 유효기간이 만료되기 전까지는 막을 방법이 없다는 점입니다. 그렇기에 대부분 토큰의 유효기간을 짧게 설정합니다. 하지만, 짧게 설정하면 로그인을 자주해야하는 단점이 있어 사용자가 불편을 겪게 됩니다. 이를 해결하기 위해 Refresh Token방식을 사용합니다.
    • Refresh Token이란, Access Token과 같은 JWT입니다. 하지만 차이점이 있다면, Refresh Token은 아주 긴 유효기간을 가지며 Access Token이 만료되었을 때 새로 발급을 해주기 위한 토큰이라는 점입니다.
    • 그래서 보통 Access Token의 유효기간을 1시간, Refresh Token의 유효기간을 최대 2주 정도로 정합니다. 이 후 Access Token이 만료되었을 때, Refresh Token이 만료되지 않았다면 Access Token을 재발급하는 형태로 인증하게 되는 것입니다.
  3. Access Token과 Refresh Token을 모두 이용한 서버 인증 방식

images%2Fkshired%2Fpost%2Ffa1ca964-9203-4f84-8284-a7fd1593186b%2F99DB8C475B5CA1C936

    • 사용자가 로그인합니다. -> 서버에서 사용자 확인 후, Access Token(JWT)에 권한 인증을 위한 정보를 Payload에 넣고 생성합니다. ->
    • Refresh Token도 생성하여 서버에 저장한 후 두 토큰 모두를 클라이언트에게 반환합니다. ->
    • 클라이언트는 두 토큰을 저장합니다. -> 클라이언트가 권한 인증이 필요한 요청을 할 때마다 Access Token을 헤더에 실어 보냅니다. ->
    • 서버는 헤더의 토큰을 검증하고, Payload의 값을 디코딩하여 사용자의 권한을 확인하고 데이터를 반환합니다. ->
    • 만약, 토큰이 만료 되었다면 서버는 클라이언트에게 만료되었다는 응답을 보냅니다. ->
    • 클라이언트는 만료 된 토큰을 재발급 하기위해 만료된 Access Token과 Refresh Token을 헤더에 실어 서버에게 새로운 토큰 발급을 요청합니다.
    • 서버는 Access Token과 Refresh Token을 모두 검증 한 후, Refresh Token이 만료되어있지 않을 때 새로운 Access Token을 발급하여 클라이언트에게 반환합니다.

좀 복잡한 과정이 추가되었지만, 더 안전하다는 장점을 가지고 있는 Refresh Token 인증 방식입니다.


Express에 적용하기

jsonwebtoken module 설치

jsonwebtoken 모듈은 node.js에서 JWT를 쉽게 발급하고, 검증할 수 있도록 도와주는 모듈입니다.

npm install --save jsonwebtoken

Redis module 설치

Refresh Token을 Redis에 저장할 것이기 때문에 redis 모듈을 설치하도록 합니다.

npm install --save redis

시스템에서도 Redis를 설치해야합니다.

mac os

brew install redis 
// after
redis-server

# brew services restart redis

linux

sudo apt-get install redis-server

sudo service redis-server status

Redis를 위한 유틸 함수 작성

Redis에 Refresh Token을 저장할 것이기 때문에, 미리 Redis를 세팅합니다.

utils/redis.util.js

// redis.util.js
const redisUtil = require('redis');
const redis = require("redis");
const redisClient = redis.createClient({
    port: process.env.REDIS_PORT,
});
redisClient.on('connect', () => console.log('Connected to Redis!'));
redisClient.on('error', (err) => console.log('Redis Client Error', err));
redisClient.connect();

module.exports = redisClient;

JWT 유틸 코드 작성

utils/jwt.util.js

// jwtUtil-util.js
const { promisify } = require('util');
const jwtUtil = require('jsonwebtoken');
const redisClient = require('./redis.util');
const secret = process.env.JWT_KEY;


module.exports = {
    sign: (email) => { // access token 발급
        const payload = { // access token에 들어갈 payload
            email: email
        };
        return jwtUtil.sign(payload, secret, { // secret으로 sign하여 발급하고 return
            expiresIn: '1h',       // 유효기간
            algorithm: 'HS256', // 암호화 알고리즘
        });
    },
    verify: (token) => { // access token 검증
        let decoded = null;
        try {
            decoded = jwtUtil.verify(token, secret);
            return {
                type: true,
                email: decoded.email,
            };
        } catch (err) {
            return {
                type: false,
                message: err.message,
            };
        }
    },
    refresh: () => { // refresh token 발급
        return jwtUtil.sign({}, secret, { // refresh token은 payload 없이 발급
            algorithm: 'HS256',
            expiresIn: '14d',
        });
    },
    refreshVerify: async (token, email) => { // refresh token 검증
        /* redis 모듈은 기본적으로 promise를 반환하지 않으므로,
           promisify를 이용하여 promise를 반환하게 해줍니다.*/
        const getAsync = promisify(redisClient.get).bind(redisClient);

        try {
            const data = await getAsync(email); // refresh token 가져오기
            if (token === data) {
                try {
                    jwtUtil.verify(token, secret);
                    return true;
                } catch (err) {
                    return false;
                }
            } else {
                return false;
            }
        } catch (err) {
            return false;
        }
    },
};

Auth Controller 로그인 함수 적용하기

const db = require("../models");
const Member = db.Member;

const crypto = require('crypto');

const jwt = require('../utils/jwt.util');
const redisClient = require("../utils/redis.util");

exports.login = async (req, res) => {
    if (req.body.constructor === Object && Object.keys(req.body).length === 0) {
        return res.status(200).json({
            status: 400,
            message: "Error: Body(JSON)값이 비어있습니다."
        });
    }
    if (req.body.hasOwnProperty('email') === false || req.body.hasOwnProperty('password') === false) {
        return res.status(200).json({
            status: 400,
            message: "Error: 이메일 또는 비밀번호가 없습니다."
        });
    }

    const {email, password} = req.body

    let info = {type: false, message: ''};

    crypto.createHash('sha512').update(password).digest('base64');
    let hex_password = crypto.createHash('sha512').update(password).digest('hex');

    let org_password = '';

    await Member.findOne({
        where: {email: email}
    }).then(respond => {

        if (!respond) {

            info.message = '존재하지 않는 유저입니다.'
            return res.status(200).json({
                status: 403,
                info: info,
            });

        } else {

            org_password = respond.password;

            if (hex_password === org_password) {

                const accessToken = jwt.sign(respond.email);
                const refreshToken = jwt.refresh();

                redisClient.set(respond.email, refreshToken);

                info.message = 'success';
                res.setHeader('Content-Type','application/json; charset=utf-8');
                res.setHeader('Authorization', 'Bearer ' + accessToken);
                res.setHeader('Refresh', 'Bearer ' + refreshToken);
                return res.status(200).json({
                    status: 200,
                    info: info,
                    token: {
                        accessToken: accessToken,
                        refreshToken: refreshToken
                    }
                });

            } else {

                info.message = '비밀번호가 일치하지 않습니다.'
                return res.status(200).json({
                    status: 403,
                    info: info,
                });

            }

        }

    }).catch(err => {
        info.message = '로그인 실패 : ' + err;
        return res.status(200).json({
            status: 500,
            info: info,
        });
    });

}

 

반응형

Dotenv, config.env 세팅

npm install --save dotenv

 

서버 초기 실행 JS server.js || index.js  

const dotenv = require('dotenv');
dotenv.config({ path: 'app/config/config.env' });
JWT_KEY=secret
REDIS_PORT=6379

JWT 인증 middleware 작성

middleware/auth.js

const jwt = require('jsonwebtoken');

exports.verifyToken = async (req, res, next) => {
    const headers = req.headers;
    if (!headers.hasOwnProperty('authorization')) {
        return res.status(200).json({
            status: 403,
            success: false,
            message: '로그인이 필요합니다.'
        });
    }
    const token = req.headers.authorization.split('Bearer ')[1] || req.headers['x-access-token']
    if (!token || token === 'null') {
        return res.status(200).json({
            status: 403,
            success: false,
            message: '로그인이 필요합니다.'
        })
    }
    // 토큰이 유효한지 검증

    let info = {
        type: false,
        message: ''
    }

    const p = new Promise((resolve, reject) => {
        jwt.verify(token, 'jwt-secret-key', (err, decoded) => {
            if (err) { // 토큰이 일치하지 않음.
                console.error(err)
                info.type = false;
                info.message = '토큰이 일치하지 않습니다.';
                return res.status(200).json({
                    status: 403,
                    success: false,
                    info: info,
                })
            }
            resolve(decoded);
        })
    });

    p.then((decoded) => {
        req.decoded = decoded;
        next();
    })

}

JWT Middleware를 Expres Router에 적용

const express = require('express');
const router = express.Router();
const controller = require('../controller/member.controller.js');

const auth = require("../middleware/auth");
/*********************
 * Developer : corner
 * Description : 멤버 관련 라우터
 * *******************/

// url, 유저 토큰 체크, 함수
router.get("/member/:id", auth.verifyToken, controller.findOne);

module.exports = router;

Access Token을 재발급 받기 위한 함수 작성

재발급을 위해 클라이언트는 access token과 refresh token 모두를 헤더에 담아 보냅니다.

클라이언트가 다음과 같이 헤더에 토큰을 보냈을 경우,

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

토큰을 재발급 할 때는 아래 시나리오가 존재합니다.

  1. access token이 만료되고, refresh token도 만료된 경우 => 재 로그인
  2. access token이 만료되고, refresh token은 만료되지 않았을 경우 => 새로운 access token을 발급.
  3. access token이 만료되지 않은 경우 => refresh 할 필요 없습니다.

위 시나리오를 그대로 구현합니다.

middleware/refresh.js

const { sign, verify, refreshVerify } = require('../utils/jwt.util');
const jwt = require('jsonwebtoken');

const refresh = async (req, res) => {
    // access token과 refresh token의 존재 유무를 체크합니다.
    if (req.headers.authorization && req.headers.refresh) {
        const authToken = req.headers.authorization.split('Bearer ')[1];
        const refreshToken = req.headers.refresh;

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

        // access token 디코딩하여 user의 정보를 가져옵니다.
        const decoded = jwt.decode(authToken);

        // 디코딩 결과가 없으면 권한이 없음을 응답.
        if (decoded === null) {
            return res.status(401).send({
                type: false,
                message: '권한이 없습니다!',
            });
        }

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

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

                return res.status(200).send({ // 새로 발급한 access token과 원래 있던 refresh token 모두 클라이언트에게 반환합니다.
                    type: true,
                    data: {
                        accessToken: newAccessToken,
                        refreshToken,
                    },
                });
            }
        } else {
            // 3. access token이 만료되지 않은경우 => refresh 할 필요가 없습니다.
            return res.status(400).send({
                type: false,
                message: 'Access Token이 만료되지 않았습니다.',
            });
        }
    } else { // access token 또는 refresh token이 헤더에 없는 경우
        return res.status(400).send({
            type: false,
            message: '재발급 받기 위해 Access Token과 Refresh Token이 필요합니다.',
        });
    }
};

module.exports = refresh;

Refresh를 위해 라우터에 Refresh를 추가합니다.

const refresh = require("../middleware/refresh");

/* access token을 재발급 하기 위한 router.
  클라이언트는 access token과 refresh token을 둘 다 헤더에 담아서 요청해야합니다. */
router.get('/refresh', refresh);

이제 나머지는 프론트에서 구현하면 됩니다!

반응형

댓글