redis를 활용해 access token과 refresh token을 jwt로 만들어보고 토큰 검사 기능을 탑재한 모듈을 구성해 보자.
access 토큰과 refresh 토큰의 기능 및 역할에 대한 설명은 생략한다.
redis는 docker 위에 설치한다.
아래 블로그를 참조하여 docker 위에 redis를 설치했다.
이후 npm으로 redis 설치해 주고
npm i redis
아래와 같이 redis 서버를 열어줬다.
//redis.js
const redis = require("redis");
const dotenv = require("dotenv");
dotenv.config();
const redisClient = redis.createClient(
{ legacyMode: true, port: process.env.REDIS_PORT }
);
redisClient.connect();
module.exports = redisClient;
아래 코드는 jwt를 활용해 access 토큰과 refresh 토큰을 만드는 과정이다.
access 토큰은 userId(db의 pk 값), email을 기반으로 생성하고
refresh 토큰에는 아무런 데이터도 넣지 않는다.(이유는 뒤에서 설명)
//jwtUtils.js
const jwt = require("jsonwebtoken");
const redisClient = require("./redis");
const dotenv = require("dotenv");
const { promisify } = require("util");
dotenv.config();
exports.signIn = ({ id, email }) => {
// jwt 토큰을 만들 때 사용자 id(데이터 베이스 priamary key)와 email로 만들기
const token = jwt.sign({ id: id, email: email }, process.env.PRIVATE_KEY, { expiresIn: "30m", issuer: "kimchi" });
return token;
};
exports.verify = (req) => {
const token = req.headers["authorization"];
try {
const decoded = jwt.verify(token, process.env.PRIVATE_KEY);
return {
...decoded,
ok: true,
};
} catch (err) {
return {
ok: false,
message: err.message,
};
}
};
exports.refresh = () => {
const token = jwt.sign({}, process.env.PRIVATE_KEY, { expiresIn: "14d", issuer: "kimchi" });
return token;
};
exports.refreshVerify = async (token, userId) => {
const getAsync = promisify(redisClient.get).bind(redisClient);
try {
const data = await getAsync(userId); // refresh token 가져오기
if (token === data) {
try {
jwt.verify(token, process.env.PRIVATE_KEY);
return true;
} catch (err) {
return false;
}
} else {
return false;
}
} catch (err) {
return false;
}
};
로그인 컨트롤러에서 access 토큰과 refresh 토큰을 발급받고
redis에 refresh 토큰을 value로, 유저의 db pk를 key로 저장한다.
이후 access 토큰과 refresh 토큰을 쿠키에 담아 보낸다.
// 로그인 컨트롤러
const jwtUtils = require("/somewhere");
exports.login = [
...
const accessToken = jwtUtils.signIn({ id: user.id, email: user.email });
const refreshToken = jwtUtils.refresh();
redisClient.set(user.id, refreshToken);
res.cookie("accessToken", accessToken, {
httpOnly: true,
});
res.cookie("refreshToken", refreshToken, {
httpOnly: true,
});
...
},
];
아래는 토큰 검증 모듈이다.
주요 기능으로는
request header에 access 토큰과 refresh 토큰의 존재 확인
-> 없으면 다음 미들웨어로 넘어가지 않음
access 토큰이 유효하면 request에 userId 담에서 다음 미들웨어로 이동
access 토큰이 만료되었거나 유효하지 않으면, refresh 토큰을 확인
-> jwt의 decode를 활용하면 토큰이 만료됐어도 해독이 가능하기 때문에 이를 기반으로 redis 서버에 저장한 refresh 토큰과 request header의 refresh 토큰을 비교한다.
refresh 토큰도 만료됐으면 다시 로그인해야 함.
refresh 토큰이 만료되지 않았으면 새로운 access 토큰을 발급
// 토큰 검증 모듈
const authJWT = async (req, res, next) => {
if (!req.headers.authorization || !req.headers.refresh) return res.status(400).json({ message: "no token" });
const accessToken = req.headers["authorization"];
const result = jwtUtills.verify(req); // token 검증
if (result.ok) {
req.id = result.id;
return next();
} else {
// 검증 실패 시 1. refresh token 확인
const refreshToken = req.headers.refresh;
const decodedAccessToken = jwt.decode(accessToken, process.env.PRIVATE_KEY);
const isValidRefreshToken = await jwtUtills.refreshVerify(refreshToken, decodedAccessToken.id);
// refresh token 만료? -> 다시 로그인
if (!isValidRefreshToken) return res.status(400).json({ message: "다시 로그인하세요" });
const newAccessToken = jwtUtills.signIn({ id: decodedAccessToken.id, email: decodedAccessToken.email });
res.cookie("accessToken", newAccessToken, {
httpOnly: true,
});
res.cookie("refreshToken", refreshToken, {
httpOnly: true,
});
req.id = decodedAccessToken.id;
return next();
}
};
아래와 같이 어떤 모듈 이전에 토큰 검증 과정을 추가해 사용자를 파악할 수 있다.
app.use("/orders", authJWT, ordersRoutes.router);
로그인하지 않아도 되는 페이지의 경우 authJWT를 그냥 제거하면 된다.
하지만, 페이지에 따라
로그인 상태일 때와 비로그인 상태일 때
웹 페이지의 전체적인 구성은 동일하지만
일부분에서 사용자 정보가 필요할 수도 있다.(로그인 상태일 때)
유튜브 동영상 좋아요 버튼을 예로 들면,
로그인 상태일 때 좋아요 표시한 영상 재생 페이지로 이동 시 좋아요 이력이 반영된다.

하지만, 비로그인 상태일 때는 좋아요 표시가 반영되지 않는다.

위의 authJWT는 access 토큰이나 refresh 토큰이 없으면 response를 반환하여 데이터를 받지 못하였다.
이런 상황을 대비하기 위해 토큰 확인하는 모듈을 추가로 구현할 수 있다.
// 토큰의 존재 여부를 기반으로 다음 모듈 실행 여부를 상태 값으로 저장
const checkTokens = (req, res, next) => {
if (!req.headers.authorization || !req.headers.refresh)
req.skipAuthJWT = true;
else req.skipAuthJWT = false;
next();
};
module.exports = checkTokens;
이후 authJWT 상단에 아래 조건을 추가하면, 토큰이 없는 사용자도 웹페이지에서 로그인 여부와 관련 없는 데이터를 받을 수 있다.
if (req.skipAuthJWT) return next();
아래와 같이 모듈로 사용할 수 있다.
router.get("/:videoId", checkTokens, authJWT, controller.getVideo);
(refresh 토큰의 유효기한이 얼마 남지 않았을 때 refresh 토큰을 새로 발급하는 기능도 구현해보자~!)