Express에서 jwt를 이용하여 access token으로만 인증을 하는 글은 많이 있는데, refresh token까지 구현한 자료는 그렇게 많지않아 이 글을 쓰게 되었습니다.
이 글은 구현에 치중되어 있어, JWT의 자세한 특징을 소개하지는 않습니다.자세한 정보가 필요하다면, Refrence의 글을 읽는 것을 추천합니다.
JWT는 클라이언트와 서버 사이 통신시 권한을 인가하기 위해 사용하는 토큰입니다.
헤더 - 페이로드 - 서명
세 부분으로 이루어져 있는 것이 특징입니다.
Header
JWT를 검증하는데 필요한 정보를 가진 JSON을 Base64 알고리즘 기반으로 인코딩한 문자열입니다.
검증을 하기위한 내용을 가지고 있습니다.
Payload
JWT에 저장 된 값입니다.
(name,value)의 쌍으로 이루어져 있고, JWT에는 이 값들을 여러 개 할당 할 수 있습니다.
Payload의 값은 암호화되지 않기에, 비밀번호와 같은 민감한 값을 넣으면 안됩니다.
Signature
JWT를 인코딩하거나 유효성 검증을 할 때 사용하는 암호화 된 코드입니다.
1. Acess Token만을 이용한 서버 인증 방식
2. Refresh Token
Access Token만을 이용한 인증 방식의 문제는 제 3자에게 토큰을 탈취당하게 되면, 토큰의 유효기간이 만료되기 전까지는 막을 방법이 없다는 점입니다. 그렇기에 대부분 토큰의 유효기간을 짧게 설정합니다. 하지만, 짧게 설정하면 로그인을 자주해야하는 단점이 있어 사용자가 불편을 겪게 됩니다. 이를 어떻게 해결할까요?
Refresh Token으로 해결을하게 됩니다. Refresh Token이란 Access Token과 같은 JWT입니다. 하지만 차이점이 있다면, Refresh Token은 아주 긴 유효기간을 가지며 Access Token이 만료되었을 때 새로 발급을 해주기 위한 토큰이라는 점입니다.
그래서 보통 Acess Token의 유효기간을 1시간, Refresh Token의 유효기간을 2주정도로 정합니다. 그후 Access Token이 만료되었을 때, Refresh Token이 만료되지 않았다면 Access Token을 재발급하는 형태로 인증을 하게 됩니다.
3. Access Token과 Refresh Token을 모두 이용한 서버 인증 방식
1번의 방식보다는 꽤 복잡하지만, 확실히 Access Token만을 사용했을 때보다 안전하다는 장점이 있습니다.
이미 Express로 구성 된 프로젝트에 추가한다고 가정을하고 시작하겠습니다. 기초적인 Express 세팅은 하지 않겠습니다.
jsonwebtoken 모듈은 Node.js에서 JWT를 쉽게 발급하고, 검증 할 수 있게 도와주는 모듈입니다.
npm install --save jsonwebtoken
Refresh Token을 Redis에 저장 할 것이기 때문에 redis 모듈을 설치하겠습니다.
npm install --save redis
Redis에 Refresh Token을 저장 할 것이기 때문에, 미리 Redis를 세팅하겠습니다.
// redis.js
const redis = require('redis');
const redisClient = redis.createClient(process.env.REDIS_PORT);
module.exports = redisClient
// jwt-util.js
const { promisify } = require('util');
const jwt = require('jsonwebtoken');
const redisClient = require('./redis');
const secret = process.env.SECRET;
module.exports = {
sign: (user) => { // access token 발급
const payload = { // access token에 들어갈 payload
id: user.id,
role: user.role,
};
return jwt.sign(payload, secret, { // secret으로 sign하여 발급하고 return
algorithm: 'HS256', // 암호화 알고리즘
expiresIn: '1h', // 유효기간
});
},
verify: (token) => { // access token 검증
let decoded = null;
try {
decoded = jwt.verify(token, secret);
return {
ok: true,
id: decoded.id,
role: decoded.role,
};
} catch (err) {
return {
ok: false,
message: err.message,
};
}
},
refresh: () => { // refresh token 발급
return jwt.sign({}, secret, { // refresh token은 payload 없이 발급
algorithm: 'HS256',
expiresIn: '14d',
});
},
refreshVerify: async (token, userId) => { // refresh token 검증
/* redis 모듈은 기본적으로 promise를 반환하지 않으므로,
promisify를 이용하여 promise를 반환하게 해줍니다.*/
const getAsync = promisify(redisClient.get).bind(redisClient);
try {
const data = await getAsync(userId); // refresh token 가져오기
if (token === data) {
try {
jwt.verify(token, secret);
return true;
} catch (err) {
return false;
}
} else {
return false;
}
} catch (err) {
return false;
}
},
};
const jwt = require('./utils/jwt-util');
const redisClient = require('./utils/redis');
const login = async (req, res) => {
//... user 로그인 로직
if (success) { // id, pw가 맞다면..
// access token과 refresh token을 발급합니다.
const accessToken = jwt.sign(user);
const refreshToken = jwt.refresh();
// 발급한 refresh token을 redis에 key를 user의 id로 하여 저장합니다.
redisClient.set(user.id, refreshToken);
res.status(200).send({ // client에게 토큰 모두를 반환합니다.
ok: true,
data: {
accessToken,
refreshToken,
},
});
} else {
res.status(401).send({
ok: false,
message: 'password is incorrect',
});
}
};
인증이 필요한 Express router에 사용 할 middleware를 작성합니다.
보통 JWT는 헤더에 다음과 같은 형태로 담겨옵니다.
{
"Authorizaiton":"Bearer jwt-token"
}
// authJWT.js
const { verify } = require('./util/jwt-util');
const authJWT = (req, res, next) => {
if (req.headers.authorization) {
const token = req.headers.authorization.split('Bearer ') [1]; // header에서 access token을 가져옵니다.
const result = verify(token); // token을 검증합니다.
if (result.ok) { // token이 검증되었으면 req에 값을 세팅하고, 다음 콜백함수로 갑니다.
req.id = result.id;
req.role = result.role;
next();
} else { // 검증에 실패하거나 토큰이 만료되었다면 클라이언트에게 메세지를 담아서 응답합니다.
res.status(401).send({
ok: false,
message: result.message, // jwt가 만료되었다면 메세지는 'jwt expired'입니다.
});
}
}
};
module.exports = authJWT;
// user.js
const express = require('express');
const { editProfile } = require('./profile');
const authJwt = require('../midlewares/authJWT');
const router = express.Router();
//... 다른 router 설정들
/* user 프로필을 변경하려면 권한이 필요하기 때문에 authJWT 미들웨어를 적용합니다.
이제 이 router는 access token을 헤더에 담아서 요청해야합니다. */
router.get('/profile', authJWT, editProfile);
module.exports = router;
재발급을 위해서 클라이언트는 access token과 refresh token 모두를 헤더에 담아 보내야합니다. 저는 클라이언트가 다음과 같이 헤더에 토큰들을 담아서 보낸다고 가정하겠습니다.
{
"Authorizaiton":"Bearer access-token",
"Refresh":"refresh-token"
}
토큰을 재발급 할 때는 다음과 같은 시나리오들이 존재합니다.
위 시나리오를 그대로 구현하면 됩니다.
// refresh.js
const { sign, verify, refreshVerify } = require('../util/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) {
res.status(401).send({
ok: false,
message: 'No authorized!',
});
}
/* access token의 decoding 된 값에서
유저의 id를 가져와 refresh token을 검증합니다. */
const refreshResult = refreshVerify(refreshToken, decoded.id);
// 재발급을 위해서는 access token이 만료되어 있어야합니다.
if (authResult.ok === false && authResult.message === 'jwt expired') {
// 1. access token이 만료되고, refresh token도 만료 된 경우 => 새로 로그인해야합니다.
if (refreshResult.ok === false) {
res.status(401).send({
ok: false,
message: 'No authorized!',
});
} else {
// 2. access token이 만료되고, refresh token은 만료되지 않은 경우 => 새로운 access token을 발급
const newAccessToken = sign(user);
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!',
});
}
};
module.exports = refresh;
// user.js
const express = require('express');
const { editProfile } = require('./profile');
const refresh = require('./refresh');
const authJwt = require('../midlewares/authJWT');
const router = express.Router();
//... 다른 router 설정들
/* access token을 재발급 하기 위한 router.
클라이언트는 access token과 refresh token을 둘 다 헤더에 담아서 요청해야합니다. */
router.get('/refresh', refresh);
module.exports = router;
이제 모두 적용되었으며, 이것을 응용하여 여러분의 프로젝트에 적용하면 될 것입니다!
위 방식을 적용한 제 사이드 프로젝트도 github에 존재하니 한 번 들려주시면 감사하겠습니다!
https://github.com/kshired/ShoppingCart
안녕하세요, 좋은 글 정말 잘 읽었습니다. 궁금한 게 있어서 질문 드리는데요,
refresh부분에서 access token을 decode해서 유저 정보를 가져와 refresh token을 검증하시는데, access token이 만료되면 유저정보를 가져오는 게 불가능하지 않나요?? TokenExpiredError만 나오고, 안의 정보는 안나오지 않나요?