Backend/Node.js

[Node.js] web token(jwt) 인증 + Redis(In memory) + 최소한의 보안

P.Venti 2023. 4. 1. 16:09

 

 

 

GitHub - dotredbee/SampleWebTokenSign: Sample jwt authentication server with csrf security

Sample jwt authentication server with csrf security - GitHub - dotredbee/SampleWebTokenSign: Sample jwt authentication server with csrf security

github.com

 

1.0 사용자 식별

 

 서버에서 제공하는 서비스를 이용하기 위해서는 권한이 필요합니다. 서버 내의 데이터 접근을 누구한테나 허용하면 안 되기 때문입니다. 그래서 우리는 특정 사이트의 서비스를 이용하기 위해 회원가입을 하여 권한을 받고 서비스를 이용합니다. 

 회원가입 후 로그인 하시면 일부 또는 전체 서비스에 권한이 생겨 서비스를 이용하실 수 있습니다. 권한이 있는 사용자에게만 서비스를 하기 위해서는 서버에서는 요청하는 유저의 신원을 식별할 수 있어야 합니다. 식별하는 방법으로는 사용자의 세션 ID로 이용하거나 web token을 사용해 식별하는 방법이 있습니다. 

 오늘은 web token으로 구별하는 방법을 Nodejs로 만들어보면서 추가로 생각해야 할 문제들을 간단하게 작성해 보겠습니다. 

 

 

2.0 Web Token 인증 

 

 web token의 동작방식은 간단합니다. 사용자가 로그인 시 서버는 해당 사용자에게 web token을 제공해 주고 사용자는 특정 서비스를 요청할 때마다제공받은 web token을 포함해서 서버로 요청합니다.

 서버는 web token의 유효성을 검사해 유효할 경우 서비스를 제공하고 유효하지 않을 경우 로그아웃 시키거나 재발급을 합니다.

 

3.0 구현 로직, 부연 설명

.

 

 3.1. Access Token, Refresh Token

 위 그림에서 보시면  web token으로 access token과 refresh token 두 가지 토큰을 제작해 클라이언트에 제공하는 것을 볼 수 있습니다. 

 왜 굳이 두 가지의 토큰을 사용할까? 이는 보안성 문제입니다. 앞서 말했듯이 토큰의 유효성만 검증이 된다면 서버에서는 서비스를 제공해 줍니다. 즉, 해당 토큰만 가로챈다면 타인의 계정으로 손쉽게 서비스를 요청할 수 있다는 뜻입니다. 그렇기에 Access Token은 비교적 유효기간을 짧게 설정을 하고 Refresh Token은 보다 길게 설정하여 제공합니다. 

 만약 서버로 요청온 Access Token이 만료되었다면 같이 온 Refresh Token을 서버 내에 보관하고 있는 것과 일치하는지 확인하고 유효성 검사까지 마친 후 새로운 Access Token을 발급해서 사용자에게 제공합니다.

 

 3.2 Database를 놔두고 왜 Redis에 저장하는가?

 실제 서비스를 제공하다 보면 데이터 베이스에 저장되는 데이터는 상당히 많습니다. 또한 데이터베이스에서 읽고, 쓰는 작업은 생각보다 비용이 많이 드는 작업입니다. 만약 데이터베이스에 Refresh Token을 저장하게 된다면 로그인 시 Refresh Token을 데이터베이스에 저장하고 로그아웃이 삭제하는 동작을 매번 취해야 합니다. 이는 사용자가 적을 경우에는 크게 문제 되지 않지만 사용자가 많아질수록 비용이 많이 듭니다. 또한 Redis는 저장 시에 만료기간을 설정할 수 있어 Refresh Token이나 세션등 일정 시간이 지나면 지워야 하는 데이터를 손쉽게 관리할 수 있습니다. 

 

 위 로직에서 보시면 실제로 서비스를 요청을 할 때 사용자를 확인하는 것이 아닌 Access Token의 유효성만 검증합니다, 또한 만료될 경우 redis에 저장된 refresh token과 일치하는지와 유효한지 확인 후 서비스를 제공합니다.(13~16번)

 

4.0 코드 구현

 

코드가 길기 때문에 편의상 코드 작성은 express, dotenv, mongoose에 대한 지식이 어느 정도 있다는 가정하에 작성하겠습니다.

 

 4.1 라이브러리 설치

npm install express dotenv redis express-session cookie-parser

 

npm install joi csurf bcrypt jsonwebtoken mongoose passport passport-jet

 

 * csurf : CSRF 공격을 방지하기 위한 라이브러리 

 

 4.2 express 설정

경로 : src/app.js

const express = require('express')
const cookieParser = require('cookie-parser')
const session = require('express-session')
const redis = require('redis')
const passport = require('passport')

const app = express()
const cookieSecret = process.env.COOKIE_SECRET

... express 세부 설정 생략 ...

app.use(cookieParser(cookieSecret))
app.ues(session({
  resave : false,
  saveUninitialized : false,
  secret : cookieSecret
  cookie : {
    httpOnly : true,
    sameSite : 'strict',
    maxAge : 60 * 60 * 1000
  }
})

app.use(passport.initialize())
app.use(passport.session())

require('./passport/jwt')

... express 나머지 설정 생략 ...

 

 4.3 passport 설계

경로 :./src/passport/jwt.js 

const JwtStrategy = require('passport-jwt').Strategy;
const ExtractJwt = require('passport-jwt').ExtractJwt;


/**
 *
 * cookie를 사용해 access token과 refresh token을 가져오기때문에
 * cookie에서 access token을 가져오는 함수입니다.
 *
 * @param {express.Request} req 
 */
function cookieExtractor(req) {
  let token = null;
  if(req && req.cookies)
    token = req.cookies.accessToken;
  return null;
}



/**
 * 
 * access token 유효성을 검사한 값을 반환합니다..
 * 따로 생성한 토큰 추출기를 사용할것이기때문에 fromExtractors에 커스텀 함수 명시
 * 
 * @param {Object} passport 초기화 설정된 passport 객체
 */
module.exports = (passport) => {
  passport.user(new JwtStrategy({
    jwtFromRequest : ExtractJwt.fromExtractors([cookieExtractor]),
    secretOrKey : process.env.ACCESS_TOKEN_KEY
  }, (payload, done) => {
    if(payload) return done(null, payload)
    done(null, false)
  })
}

 

* 추가로 쿠키를 사용해 중요한 정보를 교환할 경우 script를 통해 탈취당할 가능성이 매우 높습니다. 그렇기 때문에 쿠키에 옵션을 주어서 저장을 해주셔야 합니다. 

 

res.cookie('accessToken', accessToken, {
  httpOnly : true,
  secure : false,
  sameSite : 'strict',
});

 - httpOnly : 오직 http로만 접근이 가능하도록 합니다. 이를 설정하면 script로 접근이 안됩니다.

 - secure : https를 위한 설정이며 https는 암호화하여 통신하기에 하이재킹 공격을 방어해 줍니다.

   프로덕션 환경에서는 true를 설정해 줍니다.

 - sameSite :  쿠키에 접근하는 도메인 규칙에 대한 설정이며 'strict'으로 설정할 경우 같은 도메인 외에는 해당 쿠키에 접근이 되지 않습니다.

 

 해당 옵션들은 운영하는 서버가 많을수록 바뀔 수 있으며 그에 따라 보안을 해주셔야 합니다. 

 

 4.4 미들웨어 작성

경로 :./src/middlewares/secure.js

const csurf = require('csurf')
const jwt = require('jsonwebtoken')
const redis = require('redis')

const redisClient = redis.createClient({
  password : process.env.REDIS_PASSWORD,
  socket : {
    host : process.env.REDIS_HOST,
    port : process.env.REDIS_PORT
  }
})

// CSRF 공격 방지용 middleware
module.exports.csurfProtection = csurf({
  cookie : {
    httpOnly : true,
    sameSite : 'strict'
  }
})

// access token 유효한지 검증, 유효하지 않으면 refresh token 검증후 재발급
// 검증 후 passport-jwt 로 넘기기때문에 access token 설정을 해주어야합니다.
module.exports.verifyInvalidToken = async (req, res, next) => {
  let token = null;
  if(!req.cookies.hasOwnProperty('accessToken') || !req.cookies.hasOwnProperty('refreshToken'))
    return res.status(200).json({
      status : 403,
      success : false,
      message : "인증 토큰이 없습니다."
    })
    
  try{
    let token = req.cookies.accessToken;
    
    await jwt.verify(accessToken, process.env.ACCESS_TOKEN_KEY)
    
    next()
  catch(err){
    // accessToken이 유효하지 않다면 쿠키 초기화
    res.clearCookie('AccessToken')
    
    try{
      const refreshToken = req.cookies.refreshToken;
      const decode = await jwt.verify(refreshToken, process.env.REFRESH_TOKEN_KEY)
      const { userId }  = decode;
      
      // redis 에 refresh token이 있는지 확인 
      const _refreshToken = await redisClient.get(userId)
      if(!_refreshToken || refreshToken !== _refreshToken) 
        throw new Error('Invalid Refresh token')
      
      // access token 재발급
      token = await jwt.sign(decode, process.env.ACCESS_TOKEN_KEY)
      
      res.cookie('accessToken', accessToken, {
        httpOnly : true,
        sameSite : 'strict'
      })
      
      req.cookies.accessToken = token
      next()
    catch(err){
      res.status(403).json({
        status : 403,
        success : true,
        message : "로그인 후 이용해주세요."
      })
    }
  }
}

 

 4.5 회원가입 & 로그인

경로 :./src/routes/auth.js

const User = require('./models/user')
const { Router } = require('express')
const secure = require('../middlewares/secure')
const joi = require('joi')
const jwt = require('jsonwebtoken')
const bcrypt = require('bcrypt')
const router = Router();
const redis = require('redis')

const redisClient = redis.createClient({
  password : process.env.REDIS_PASSWORD,
  socket : {
    host : process.env.REDIS_HOST,
    port : process.env.REDIS_PORT
  }
})
const saltRound = 10;

const cookieOptions = {
  httpOnly : true,
  secure : false,
  sameSite : 'strict'
}
// csrf 토큰 발급
router.get('/csrf', secure.csrfProection, (req, res, next) => {
  res.cookie('csrfToken', req.csrfToken(), cookieOptions)
  
  res.status(200).json({
    status : 200,
    success : true,
    message : "CSRF 토큰 발급 성공"
  })
})

// signup
router.post('/signup', secure.csrfProtection, async (req, res, next) => {
  let body = req.body;
  const schema = joi.object().keys({
    username : joi.string().min(8).max(18).required(),
    password : joi.string().min(12).max(24).required()
    _csrf : joi.string().required()
  })
  try{
    await schema.validateAsync(body)    
    
    const recode = await User.findOne({ username : body.username })
    if(recode)
      return res.status(200).json({
        status : 203,
        success : false,
        message : "이미 가입된 유저입니다."
      })
    
    const hashed = await bcrypt.hash(body.password, saltRound)
    
    const payload = {
      username : body.username,
      password : hashed
    }
    
    await User.create(payload)
    
    res.status(200).json({
      status : 200,
      success : true,
      message : "회원가입 되었습니다."
    })
  }catch(err){
    res.status(200).json({
      status : 403, 
      success : false, 
      message : "잘못된 형식의 요청입니다."
    })
  }
})

router.post('/login', secure.csrfProtection, async (req, res, next) => {
  const body = req.body;
  const schema = joi.object().keys({
    username : joi.string().min(8).max(18).required(),
    password : joi.string().min(12).max(24).required()
    _csrf : joi.string().required()
  })
  
  try{
    await schema.validateAsync(body)
    
    const recode = await User.findOne({ username : body.useranme })
    if(!recode)
      return res.status(200).json({
        status : 203,
        success : false,
        message : "잘못된 아이디 입니다."
      })
      
    const ret = await bcrypt.compare(password, recode.password)
    if(!ret)
      return res.status(200).json({
        status : 203,
        success : false,
        message : "잘못된 패스워드 입니다."
      })
      
    const payload = {
      userId : recode._id.toString(),
      username : recode.username
    }
    
    const accessToken = await jwt.sign(accessToken, process.env.ACCESS_TOKEN_KEY)
    const refreshToken = await jwt.sign(refreshToken, process.env.REFRESH_TOKEN_KEY)
    
    // userId를 키값으로 refresh token을 redis server에 저장
    await redisClient.set(userId, refreshToken)
    
    res.cookie('accessToken', accessToken, cookieOptions)
    res.cookie('refreshToken', refreshToken, cookieOptions)
    
    res.status(200).json({
      status : 200,
      success : true,
      message : "로그인 되었습니다."
    })
  }catch(err){
    res.status(200).json({
      status : 403,
      success : false,
      message : "잘못된 형식의 요청입니다."
    })
  }
})


module.exports = router;

 

 4.6 로그인이 필요한 api 만들기

경로 :./src/routes/api.js

const secure = require('../middlewares/secure')
const passport = require('passport')
const { Router } = require('express')

const router = Router();

router.get('/api',
           secure.verifyInvalidToken,
           passport.authorizate('jwt', { session : false },
           (req, res, next) => {
    
    res.status(200).json({
      status : 200,
      success : true,
      message : "api 요청 성공"
    })
})

module.exports = router;

 

 GET /api 코드를 보시면 미들웨어 부분에 web token 검증 미들웨어와 passport를 이용한 인증 미들웨어 두 개를 놓아주었습니다. 해당 요청은 cookie에서 access token과 refresh token 유효성을 검증하고 검증에 실패한다면 GET /api에 도달 조차 하지 않습니다.

 

추후 개선

 

 앞서 코드에서 보시면 유효 기간 설정을 해주지 않았습니다. 

 

 1. redis에 refresh token을 저장할 때 

 2. cookie에 web tokens 넣을 때

 3. web tokens을 생성할 때 

 

 위 세 개 전부다 유효기간이 설정이 필요하며 서비스에 따라 유효기간을 다릅니다. 보통은 refresh token은 2주 정도로 access token은 24시간 또는 그 아래로 잡아서 발급합니다.

 유효 기간이 길수록 보안은 떨어지기 때문에 적절한 유효 기간을 반드시 설정해주셔야 합니다.