ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 팀과제 : refreshToken의 저장장소와 임시방편으로서의 session memory store
    이제 막 슬픔 없이 십오 초 정도가 지났다 2022. 10. 30. 19:37

    조건1. 리프레시 토큰에 아무런 유저 정보도 들어있지 않다.

    기존에 해오던 방식은 이러하다.

    리프레시토큰을 DB의 유저 테이블에 넣는다.

    acess token이 만료된 유저가 refresh token을 제시하면 db를 조회하여 새 acess token을 발급한다.

     

    굳이 그렇게 하는 이유는 다음 코드에 있다.

    const jwt = require('jsonwebtoken');
    const env = require('../config.env');
    
    
    class Jwt {
        sign = function(payload) {
            return jwt.sign(payload, env.JWT_KEY, {
                algorithm: 'HS256',
                expiresIn: 60*60*2
            });
        }
        verify = function(token) {
            try{
                const result = jwt.verify(token, env.JWT_KEY);
                return result;
            }catch(error){
                if(error.name === 'TokenExpiredError'){
                    return null;
                }
            }
        }
        refresh = function() {
            return jwt.sign({}, env.JWT_KEY, {
                algorithm: 'HS256',
                expiresIn: 60*60*24
            });
        }
        decode = function(token) {
            return jwt.decode(token);
        }
    }
    
    
    module.exports = new Jwt();

     

    현재 진행중인 프로젝트의 jwt 토큰 모듈이다.

    sign 이라는 함수가 acess token을, reresh 함수가 refresh 토큰을 각각 발급한다.

    이 코드에서 중요한 점은, refresh 함수 안에 아무런 유저 정보도 넣지 않았다는 것이다. 

    refreshtoken은 서버측에서 오래 보관하는 토큰이니, 보안 등등의 이유로 역시 저렇게 빈값으로 만들어지는 것이 옳을 터이다.

    (반면 소멸시효가 짧은 acesstoken을 발급하는 sign 함수는 인자로 받는 payload를 받는다.

    그리고 저 payload에는 email, nickname 등 유저 정보가 들어가 있다.

    이것을 verify 하여 유저의 동일성을 인지하는 것이다. 뭐 적어도 표면적으로는.)

     

    어쨌든 refresh 토큰 안에 아무런 유저정보도 들어있지 않다는 것이 제약조건 첫번째.

    하지만 이것은 refresh 토큰으로 DB를 조회하면 간단히 해결되는 문제였었다.

     

    조건2. DB에 리프레시 토큰을 넣지 않는다. 로그인기능을 위한 서버와 DB의 대화를 최소화한다.

    이것은 서버의 메모리를 다른 작업에 더욱 할당하기 위함이다.

    그런데, 이 두가지 조건이 더해지자 간편하게 (사실상 복붙으로) 구사해왔던 acess token 과 refresh token의 발급-재발급 루틴이 돌아가지 않게 되었다.

     

    const jwt = require("../utils/jwt");
    const UserRepo = require("../repositories/user");
    const env = require("../config.env");
    // const { findUserByToken } = require('../db/cache');
    const { InvalidAccessError } = require("../utils/exception");
    const cookieConfig = require("../utils/cookieConfig");
    
    // module.exports = async (req, res, next) => {
    //   const { authorization, refreshtoken } = req.headers;
    //   const [authType, authToken] = (authorization || "").split(" ");
    
    //   if (!authToken || authType !== "Bearer") {
    //       next(InvaliadAccessError("로그인 후 이용 가능한 기능입니다.", 401));
    //   }
    
    //   try {
    //     const payload = jwt.verify(authToken);
    
    //     if (payload) {
    //       req.app.locals.user = payload;
    //       next();
    
    //     } else {
    //       const verifyRefresh = jwt.verify(refreshtoken);
    
    //       if (verifyRefresh) {
    //         const userId = await findUserByToken(refreshtoken);
    //         const user = await UserRepo.findOne(userId);
    
    //         const newPayload = {
    //           userId,
    //           username: user.username,
    //           nickname: user.nickname
    //         }
    //         const newAccessToken = jwt.sign(newPayload);
    
    //         res.cookie("accessToken", newAccessToken, cookieConfig);
    //         next();
    //       } else {
    //         throw new InvaliadAccessError("유효하지 않은 refreshToken", 401);
    //       }
    //     }
    //   } catch (error) {
    //     next(error);
    //   }
    // };
    
    // temporary authMiddleware
    module.exports = async (req, res, next) => {
      console.log("TEMP AUTH MIDDLEWARE");
    
      const authorization = JSON.parse(req.headers.authorization);
      console.log("헤더 authorization :", authorization);
      const { accessToken, refreshToken } = authorization;
      const [accType, accToken] = (accessToken || "").split(" ");
      const [refType, refToken] = (refreshToken || "").split(" ");
    
      if (accType !== "Bearer" || refType !== "Bearer") {
        next(new InvalidAccessError("로그인 후 이용 가능한 기능입니다.", 401));
      }
    
      try {
        const payload = jwt.verify(accToken);
        console.log("accessToken이 뱉은 payload :", payload);
    
        if (payload) {
          req.app.locals.user = payload;
          const { userId } = payload;
          req.session.num = userId;
          next();
        }
    
        /**AccessToken만 만료시 AccessToken재발급 */
        if (!payload) {
          const verifyRefresh = jwt.verify(refToken);
          console.log("refreshToken이 뱉은 verifyRefresh 값 :", verifyRefresh);
    
          /**refreshToken만료시 재로그인 요청 */
          if (!verifyRefresh) {
            next(new InvalidAccessError("로그인 후 이용 가능한 기능입니다.", 401));
          }
          if (verifyRefresh) {
            //전달안됨 //const {userId} = req.app.locals.user;
            //전달안됨 //const {userId} = res.locals.user;
            //전달안됨 //const userInfo = tokenObject[refreshToken];
            const userId = req.session.num;
            console.log("access만료, refresh생존, userId:", userId);
    
            /**refreshToken은 정상이지만 acessToken이 만료되도록 2시간동안 한번도 authMiddleware를 거쳐간 적이 없는 경우 **/
            if (!userId) {
              next(new InvalidAccessError("로그인 시간이 만료되었습니다.", 401));
            }
    
            /**유저정보 DB에서 찾아오기*/
            const userInfo = await UserRepo.findOne(userId);
            const user = {
              userId: userInfo.userId,
              email: userInfo.email,
              nickname: userInfo.nickname,
            };
    
            /**AccessToken 재발급 */
            const newAccessToken = jwt.sign(user);
    
            /**로그인 유저정보 다시 저장 */
            req.app.locals.user = user;
    
            /**새로 발급받은 토큰전송 */
            res.cookie("accessToken", newAccessToken, cookieConfig);
            res.json({
              message: "acessToken 재발급",
              accessToken: `Bearer ${accessToken}`,
            });
            console.log("accessToken 재발급");
          }
        }
      } catch (error) {
        next(error);
      }
    };

     

    길고 장황하지만 문제 포인트는 아주 간단하다.

    acesstoken이 만료된 유저가 refreshtoken으로 접근해 왔을 때,

    이 유저의 정보를 확인할 방법이 없다!!!

     

    1.refreshtoken 자체에도 유저 정보를 그냥 넣어버리거나

    2.DB의 유저 테이블에 refreshtoken을 넣거나

     

    이 두가지 방식이 막히니 중간 다리가 사라져 버린 것이다.

     


    가장 먼저 시도한 것은 임의의 토큰 객체 : 실패

    tokenObject[refreshToken] = payload

    배열 구조분해 할당으로 리프레시 토큰의 값이 들어가고, 거기에 payload의 값이 value로 들어간다.

    즉, 빈 객체 tokenObject에 {refreshToken의 값 : payload의 값} 이 key : value  형태로 들어가는 것이다.

    const payload = { userId : 1 }
    const accessToken = jwt.sign(payload);
    const refreshToken = jwt.refresh();
    
    let tokenObject = {};
    
    tokenObject[refreshToken] = payload
    
    console.log([payload]) // [ { userId: 1 } ]
    console.log(payload) // { userId: 1 }
    console.log(tokenObject) 
    /**
    {'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE2NjcxMjQ2NDMsImV4cCI6MTY2NzIxMTA0M
    30.TTfvzWcVFqJa7rqsQ6SgA3zXIvih5CrgdScWqm55AN8': { userId: 1 } }
    */

    그리고 이후 accesstoken이 만료된 유저가 접근했을 때, 저장하고 있던 이 tokenObject 에 refreshToken을 키로 넣어주면...

    뿅! 유저정보가 튀어나오길 기대했다.

     

    그런 일은 물론 일어나지 않았다. 

    accessToken을 확인할 때야 저렇게 잘 저장되겠지만, 그것을 refreshToken 단계에서 빼오기가 녹록치 않았다.

     

    일단 스코프의 한계 때문이기도 하고, 정 하려면 전역에서 사용하는 객체로서 app.js 에서 떡하니 가공을 해야 할 것이다.

    그것도 다른 모든 라우터들보다 상위에서 말이다.

    차마 쓰지 못할 방법이었다.

     

     

    두번째로 시도한 것은 express가 제공하는 로컬 저장소 : 실패

    res.locals 나  req.app.locals 같은 다용도실들.

    res.locals.user 는 거의 모든 프로젝트에서 윤활유 역할을 해온 기특한 녀석이지만.

    이 친구들도 유저 정보를 전달해주지 못했다.

     

    사실 당연한 것이, 한 request와 response의 주기가 끝나면 휘발되어야 하는 메모리이다.

    휘발되지 않고 남아있어서, refreshtoken 을 확인하고 accesstoken을 재발급하는 단계까지 사용할 수 있었다면 그것이 오히려 더 문제적이다.

     

     

    세번째로 시도한 것은 express session 의 memory store : 성공은 했지만

    const express = require('express');
    const session = require("express-session");
    const MemoryStore = require("memorystore")(session);
    const cookieParser = require('cookie-parser');
    const logger = require('morgan');
    const sequelize = require('./db/config/connection');
    const env = require('./config.env');
    
    const indexRouter = require('./routes/index');
    const { errorLogger, errorHandler } = require('./middlewares/errorHandler');
    
    const app = express();
    const PORT = env.PORT || 3333;
    
    // middlewares
    app.use(logger('dev'));
    
    app.use(express.json());
    app.use(cookieParser());
    app.use(
        session({
          secret: env.SESSION_KEY,
          resave: false,
          saveUninitialized: true,
          store: new MemoryStore({
            checkPeriod: 86400000,
          }),
          cookie: {maxAge: 86400000}
        })
      );
    
    app.use('/', indexRouter);
    
    app.use(errorLogger);
    app.use(errorHandler);
    
    
    app.listen(PORT, async() => {
        console.log(`SERVER RUNNING ON PORT ${PORT}`);
    
        try {
            await sequelize.authenticate();
    
            console.log('DB CONNECTED');
        } catch (error) {
            console.error(error);
            console.log('DB CONNECTION FAILED');
        }
    });

    기존의 방식, MySql의 유저 테이블에 넣어놓고 조회하는 것보다 나아진 것은 속도 뿐이다.

    여러 제약들이 있으며, 임시방편에 불과하다.

     

    더보기

    A session store implementation for Express using lru-cache.

    Because the default MemoryStore for express-session will lead to a memory leak due to it haven't a suitable way to make them expire.

    The sessions are still stored in memory, so they're not shared with other processes or services.

     

    -npm memorystore  문서

    설명만 보면 딱 알맞는 기능이긴 하다. LRU 캐시, 휘발되는 메모리. 

    다른 프로세스와 서비스에 공유되지 않음.

    오로지 서버 내부의 잠깐의 다리 연결을 위한 용도.

     

    더보기

    Warning The default server-side session storage, MemoryStore, is purposely not designed for a production environment. It will leak memory under most conditions, does not scale past a single process, and is meant for debugging and developing.

    하지만 또한 동시에 이런 멋진 경고문구가 적혀있기도 하다.

     

    물론 express session  에는 file session store 도 있고 MySQL session store도 있고...

    등등의 생각을 해볼 수 있지만, 처음의 제약조건을 잊어서는 안된다.

    잡다한(이라기에는 보안상으로는 중요한) 로그인 기능들의 DB로부터의 분산관리.

     

    너무 멀리 가는 것 같지만, redis를 대안으로 알아보기로 한다.

    사실 그냥 redis가 써보고 싶다.

Designed by Tistory.