오늘 뭐했냐/함께했던 작업들

23.08.23 리프레시 토큰 (Refresh Token)

스스로에게 2023. 9. 2. 00:53

레디스 클라우드로 연결은 끝났다 그럼 이제 리프레시 토큰을 발급하고 이것을 레디스에 저장해야 한다. 

토큰을 그럼 어떤 경우에 사용할까?

  1.  당연히 로그인했을 때 처음 발급 된다.
  2.  로그아웃을 한다면 토큰을 지워줘야 한다.
  3.  엑세스 토큰이 만료된 경우에 리프레시 토큰을 통해서 다시 재발급한다.
    • 이때 액세스 토큰을 발급하는 API를 만들 수도 있지만 매번 새로운 엑세스 토큰을 발급받기 위한 과정이 불필요해 보인다. 한 번에 처리하는 방법이 없을까?
      그래서 기존 토큰을 확인하던 미들웨어에서 토큰을 재발급하게 바꿨다.
  4.  회원 탈퇴를 할 경우에 탈퇴한 회원이기에 접근을 막기 위해 토큰을 지울 필요가 있다.
  5.  회원 가입을 하고 따로 로그인을 하는 것보다 바로 로그인이 되는 게 더 편할 것 같다. 
    • 회원가입 완료 시 로그인처럼 토큰 발급해서 로그인 한 유저로 만든다.
  6. 리프레시 토큰이 탈취되었을 때 액세스 토큰을 계속 찍어낼 수 있는 문제가 생긴다.
    • 리프레시 토큰도 일회용으로 만드는 리프레시 토큰 로테이션 전략을 사용했다. 
    • 앞에 로직들을 모두 그에 맞게 사용한 리프레시 토큰 사용  시 엑세스 토큰과 함께 새로운 리프레시 토큰을 생성 및 레디스 클라우드에 저장

 

// 리프레시 토큰 저장, 삭제
const redisCli = require('./redisClient');

async function saveRefreshToken(userId, refreshToken) {
    try {
        const result = await redisCli.set(`refreshToken:${userId}`, refreshToken, 'EX', 7 * 24 * 60 * 60);
        if (result !== 'OK') {
            throw new Error('리프레시 토큰 저장에 실패했습니다.');
        }
        return true;
    } catch (err) {
        console.error('리프레시 토큰 저장 오류:', err);
        throw err;
    }
}

async function deleteRefreshToken(userId) {
    try {
        const result = await redisCli.del(`refreshToken:${userId}`);
        if (result === 0) {
            console.warn(`${userId} 사용자 아이디에 대한 리프레시 토큰이 없습니다. 삭제할 것이 없습니다.`);
            return false;
        }
        return true;
    } catch (err) {
        console.error('리프레시 토큰 삭제 오류:', err);
        throw err;
    }
}

module.exports = {
    saveRefreshToken,
    deleteRefreshToken,
};
// 토큰 확인및 새로운 토큰 발급
const asyncHandler = require("./asyncHandler");
const CustomError = require("./error");
const jwt = require("jsonwebtoken");
const { Users } = require("../models");
const regenerateToken = require("../utils/regenerateToken"); // 새로운 토큰을 발급하는 로직

module.exports = asyncHandler(async (req, res, next) => {
    const { MM } = req.cookies;
    const [type, token] = (MM ?? "").split(" ");

    if (!type || !token || type !== "Bearer") {
        throw new CustomError("로그인이 필요한 기능입니다.", 403);
    }
    try {
        const decodeToken = jwt.verify(token, process.env.JWT_SECRET);
        const userId = decodeToken.userId;
        res.locals.user = userId;
        next();
    } catch (err) {
        // 만약 토큰 검증이 실패했다면, 새로운 토큰을 발급
        if (err.name === 'TokenExpiredError' || err.name === 'JsonWebTokenError') {
            try {
                const newTokens = await regenerateToken(req, res, next);
                // 새 토큰을 클라이언트에게 전달하는 로직. 예를 들면 쿠키에 저장.
                res.cookie('MM', `Bearer ${newTokens.accessToken}`, {
                    secure: true,
                    httpOnly: true,
                    sameSite: "none",
                });
                res.cookie('refreshToken', newTokens.refreshToken, {
                    secure: true,
                    httpOnly: true,
                    sameSite: "none",
                });
                res.locals.user = jwt.verify(newTokens.accessToken, process.env.JWT_SECRET);
                next();
            } catch (newTokenErr) {
                next(newTokenErr);
            }
        } else {
            next(err);
        }
    }
});
// 토큰 만료 시 새로운 토큰 발급
const jwt = require('jsonwebtoken');
const { saveRefreshToken, deleteRefreshToken } = require('./tokenManager.redis');
const redisCli = require('./redisClient.js');
const CustomError = require("./error");
const util = require('util');
const verifyAsync = util.promisify(jwt.verify); // 비동기적으로 변경

const regenerateToken = async (req, res, next) => {
    const clientRefreshToken = req.cookies.refreshToken;

    try {
        const user = await verifyAsync(clientRefreshToken, process.env.JWT_REFRESH_SECRET);

        const storedRefreshToken = await redisCli.get(`refreshToken:${user.userId}`);

        if (storedRefreshToken !== clientRefreshToken) {
            throw new CustomError("유효하지 않은 리프레시 토큰", 403);
        }

        const accessToken = jwt.sign(
            { userId: user.userId },
            process.env.JWT_SECRET,
            { expiresIn: "15m" });

        const refreshToken = jwt.sign(
            { userId: user.userId },
            process.env.JWT_REFRESH_SECRET,
            { expiresIn: "7d" });

        await deleteRefreshToken(user.userId);
        await saveRefreshToken(user.userId, refreshToken);

        return { accessToken, refreshToken };

    } catch (err) {
        if (err.name === 'TokenExpiredError') {
            throw new CustomError("리프레시 토큰이 만료되었습니다.", 403);
        }
        next(err);
    }
};

module.exports = regenerateToken;

 

const verifyAsync = util.promisify(jwt.verify); 이렇게 비동기적으로 바꿔준 이유는 에러 처리를 쉽게 하기 위함이다. 기존에 jwt.verify를 사용하면 콜백 기반이기에 에러 처리에 try-catch문을 사용할 수 없다. 그래서 불필요하게 코드가 길어지거나 혹시나 내가 예상하지 못한 문제가 생길 수도 있었다. 그래서 서버에 문제가 생길 위험을 조금이라도 줄이기 위해서 비동기적으로 변경하고  try-catch문을 사용해 에러 처리를 하였다. 그리고 최대한 모듈화를 해서 파일의 수가 많아지더라도 프로젝트 진행 중 변경 사항에 유동적으로 대처할 수 있게 하였다.