[Node.js / JWT] Express.js 서버에서 JWT 기반 회원인증 시스템 구현하기


이 강의에서는 Node.js 의 인기있는 웹서버 프레임워크인 Express.js 서버에서 JSON Web Token 을 사용하여 회원인증 시스템을 구현하는 방법을 알아보겠습니다.

사전 지식

이 강의에서는 Node.js에 대한 배경지식이 있어야합니다. Node.js 를 잘 모르시는분들은 Node.js 기초 강의 를 먼저 읽어주세요. 추가적으로, 토큰 기반 시스템 (포스트 i)과 JWT(포스트 ii)에 대한 이해가 필요하니, 지난 포스트들을 읽지 않으신분들은 강의를 시작하기전에 한번 참조해주세요.

추가적으로, 이 강의에서는 ES6 문법을 사용합니다.

* 이 강좌를 진행 하면서 이해가 가질 않거나 궁금한것이 있으면 언제든지 덧글로 달아주세요.

소개

facebook-390860_640

회원인증 시스템은 모든 어플리케이션에서 정말 중요한 부분입니다. 기존의 어플리케이션들은 세션 기반 회원인증 시스템을 많이 사용해왔습니다. 지금도, 많은 곳에서 사용되고 있죠. 하지만 요즘은 토큰을 기반으로 회원인증 시스템을 구축하는 서비스 회사들이 늘어가고있습니다.  Facebook, LinkedIn, Instagram, GitHub, Google 등 수많은 공룡급 회사에서 사용을 하고있지요. 지금은 이런 인증 시스템이 가히 ‘대세‘ 라고 할 수 있겠습니다. (주관적인 표현입니다.)

이를 사용함으로서, 서버측의 확장성이 높아지고, 보안시스템을 강화 할 수 있습니다. 하지만 무조건 좋은것만은 아니겠죠. 토큰을 사용함으로서 보안시스템을 강화 할 수도 있겠지만, 오히려 잘못사용하면 매우 취약해지기때문에 잘 알고 사용하는것이 좋습니다.

토큰기반인증 시스템은 API 모델을 가진 어플리케이션에 매우 적합합니다. 요즘은 Angular2, React, Vue 등을 사용해서 많은 서비스들이 만들어져있고 그런 자바스크립트 라이브러리 혹은 프레임워크를 사용하는 어플리케이션들은 REST API 던 GraphQL API 던.. 정말 많이 의존하고 있죠. 모바일앱도 마찬가지구요.

세션기반인증 시스템도 충분히 제구실을 하지만, 여러분이 ‘모던 웹/앱 개발자’라면, 토큰기반인증 시스템은 한번쯤 배워볼 가치가있습니다. 사용해보고, 여러분의 서비스에 적합하다 싶으면 도입을 하면되죠.

자, 그럼 시작해봅시다!

이 프로젝트에 사용된 코드는 GitHub 에서 에서 열람 하실 수 있습니다.

준비물

이 강의를 진행하기 위해 필요한 주요 준비물은 다음과 같습니다.

  1. Node.js LTS 버전(현재 기준 6.91) 과 npm
  2. MongoDB 서버 (강의에서는 편의상 mLab 에서 호스팅을 받아 사용합니다. 본인이 원한다면 몽고디비 서버를 직접 설치하여 사용해도 됩니다)
  3. 코드 에디터 (자신이 가장 좋아하는 에디터를 사용하세요. 강의에서는 VS Code 를 사용하도록 하겠습니다)
  4. POSTMAN – API 테스팅 크롬 확장 프로그램

 

#1 프로젝트 생성 및 설정하기

프로젝트 설정

먼저 nodejs-jwt-example 이라는 디렉토리를 만들은후, 터미널로 해당 디렉토리를 열어 npm 으로 프로젝트를 생성하세요.

npm -y

그러면 디렉토리내에서 프로젝트를 기본설정으로 설정하게되면서 package.json 파일이 생성됩니다. 한번 열어보세요.

{
  "name": "nodejs-jwt-example",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

잘 됐나요? 아직은 npm 을 사용하여 설치한 모듈들이 없기때문에, dependency 가 존재하지 않습니다.

의존(dependency) 모듈 설치

자, 이제 저희 프로젝트에서 필요한 의존 모듈을 설치하겠습니다.

npm install --save express body-parser jsonwebtoken mongoose morgan
  • express: 저희 프로젝트에서 사용 할 웹서버 프레임워크입니다.
  • body-parser: 클라이언트측에서 요청을 받을때, url-encoded 쿼리 및 json 형태의 바디를 파싱하는데 도움을 주는 모듈입니다.
  • jsonwebtoken: 이 예제프로젝트에서 사용되는 핵심 모듈입니다. JSON Web Token 을 손쉽게 생성하고, 또 검증도 해줍니다.
  • mongoose: 서버에서 MongoDB 를 사용하기 위하여 설치합니다.
  • morgan: Express 서버에서 발생하는 이벤트들을 기록해주는 미들웨어입니다

설치가 끝났으면, 다시 package.json 을 확인해보세요. (...) 은 생략되었다는 의미 입니다.

{
  (...)
  "license": "ISC",
  "dependencies": {
    "body-parser": "^1.15.2",
    "express": "^4.14.0",
    "jsonwebtoken": "^7.1.9",
    "mongoose": "^4.7.1",
    "morgan": "^1.7.0"
  }
}

디렉토리 구조

프로젝트에서 사용 할 디렉토리 구조는 다음과 같습니다. 각 파일들은 진행을 하면서 하나하나 생성 할 것이며 설명 또한 진행하면서 하도록 하겠습니다.

%ec%9d%b4%eb%af%b8%ec%a7%80-28

문법 검사를 위하여 ESLint 를 사용하고있습니다. 위 프로젝트의 ESLint 설정 파일의 내용은 여기서 확인할수있습니다. ESLint 를 설정하는건 필수는 아닙니다. 사용을 원하시는 분들은 .eslintrc 파일을 생성하시고 npm 으로 ESlint 를 설치하세요

npm install --save-dev eslint

 

#2 User 스키마 작성하기

mongoose 에서 User 정보를 MongoDB 에 넣기 위해선, 스키마를 먼저 만들어야합니다.
models/user.js 에 다음 코드를 작성해주세요

models/user.js

const mongoose = require('mongoose')
const Schema = mongoose.Schema

const User = new Schema({
    username: String,
    password: String,
    admin: { type: Boolean, default: false }
})

// create new User document
User.statics.create = function(username, password) {
    const user = new this({
        username,
        password
    })

    // return the Promise
    return user.save()
}

// find one user by using username
User.statics.findOneByUsername = function(username) {
    return this.findOne({
        username
    }).exec()
}


// verify the password of the User documment
User.methods.verify = function(password) {
    return this.password === password
}

User.methods.assignAdmin = function() {
    this.admin = true
    return this.save()
}

module.exports = mongoose.model('User', User)

앞으로 User 모델을 사용하여 처리를 하기 위한 작업들을 멤버 / 스태틱 메소드로 미리 준비했습니다.

  • create 메소드는 새 유저를 생성합니다. 원래는 이 메소드처럼 비밀번호를 그대로 문자열 형태로 저장하면 보안적으로 매우 나쁩니다. 일단 지금은 배우는 과정이니 간단하게 문자열로 저장을 하지만, 포스트의 후반부에서는 비밀번호를 해쉬하여 저장하도록 하겠습니다
  • findOneByUsername 메소드는 username 값을 사용하여 유저를 찾습니다
  • verify 메소드는 비밀번호가 정확한지 확인을 합니다. 지금은 그냥 === 를 사용해서 비교 후 결과를 반환하지만 포스트 후반부에서는 해쉬를 확인하여 결과를 반환하겠습니다
  • assignAdmin 메소드는 유저를 관리자 계정으로 설정해줍니다. 저희 예제 프로젝트에서는, 가장 처음으로 가입한 사람과, 관리자가 나중에 API 를 사용하여 지정한사람이 관리자 권한을 부여 받습니다.

 

#3 MongoDB 준비 및 설정 파일 (config.js) 만들기

config.js 파일은 우리 예제 프로젝트에서 사용할 MongoDB 서버의 정보와, JWT 토큰을 만들 때 사용 될 secret 키의 정보를 지니고있습니다. 이렇게 보안에 관련된 정보는 따로 파일에 분리하여 관리를 하는게 좋습니다. 이렇게 하면, 예를들어, github 에 오픈 소스를 할 때, .gitignore 에 추가해서 해당 파일은 싱크가 되지 않도록 설정 할 수 있겠죠? (이 프로젝트의 github 저장소에서도 config.js 파일은 커밋이 되어있지 않습니다. 예제 정보가 적혀있는 config.example.js 파일의 이름을 config.js 로 수정하고 사용해야합니다.

설정 파일을 작성하기 전에, MongoDB 서버를 준비해주어야 합니다. 개인 서버에서 MongoDB 를 돌려도 되고, 아니면 mLab 이라는 MongoDB 호스팅 서비스를 사용해도 됩니다. 이 포스트에서는 mLab 을 사용하도록 하겠습니다.

mlab 가입 및 데이터베이스 생성

1


2


untitled-4


5


_


untitled-7


untitled-8


자, 이제 데이터베이스를 설정하는 과정이 끝났습니다. mLab 에서 데이터베이스 페이지의 상단을 보시면 다음과 같은 텍스트가 있습니다.

%ec%9d%b4%eb%af%b8%ec%a7%80-25

이 값을 복사하세요.

config.js 파일 작성

이제, config.js 파일을 생성하여 다음 코드를 작성하세요.

config.js

module.exports = {
    'secret': 'SeCrEtKeYfOrHaShInG',
    'mongodbUri': 'mongodb://velopert:password@ds127428.mlab.com:27428/jwt-tutorial'
}

위에서 복사했던 MongoDB URI 를 코드에 붙여넣고, <dbuser>:<password> 가 있던 부분에 여러분이 아까 만들었던 계정 정보를 입력하세요.

여기서 secret 은 나중에 JWT 토큰을 검증하는 서명부분을 만들 때, 해싱 알고리즘에서 사용 될 비밀 키 입니다.

 

#4 서버 코드 작성하기

이제 서버의 메인 부분인 app.js 의 코드를 작성해봅시다.
주요코드를 먼저 작성하고, 추후 라우터들을 만든 다음에 또 수정 하겠습니다

app.js

/* =======================
    LOAD THE DEPENDENCIES
==========================*/
const express = require('express')
const bodyParser = require('body-parser')
const morgan = require('morgan')
const mongoose = require('mongoose')

/* =======================
    LOAD THE CONFIG
==========================*/
const config = require('./config')
const port = process.env.PORT || 3000 

/* =======================
    EXPRESS CONFIGURATION
==========================*/
const app = express()

// parse JSON and url-encoded query
app.use(bodyParser.urlencoded({extended: false}))
app.use(bodyParser.json())

// print the request log on console
app.use(morgan('dev'))

// set the secret key variable for jwt
app.set('jwt-secret', config.secret)

// index page, just for testing
app.get('/', (req, res) => {
    res.send('Hello JWT')
})

// open the server
app.listen(port, () => {
    console.log(`Express is running on port ${port}`)
})



/* =======================
    CONNECT TO MONGODB SERVER
==========================*/
mongoose.connect(config.mongodbUri)
const db = mongoose.connection
db.on('error', console.error)
db.once('open', ()=>{
    console.log('connected to mongodb server')
})

코드 작성이 완료되었다면, 터미널에서 node app.js 를 입력하여 서버를 실행해보세요.

> node app.js
Express is running on port 3000
connected to mongodb server

잘 되었나요? 그렇다면 Postman을 열어서 http://localhost:3000/ 에 GET 요청을 해보세요.

%ec%9d%b4%eb%af%b8%ec%a7%80-26

Hello JWT 라고 응답을 하네요! 게속해서 진행해봅시다..

앞으로 서버를 수정하고 테스팅을 할 때 마다 서버를 재시작해주어야 합니다. 재시작 하는게 귀찮다면 nodemon 도구를 사용해서 서버를 실행하면 서버가 수정 될 때마다 자동으로 재시작됩니다.

npm install -g nodemon
nodemon server.js

 

#5 회원가입 구현

app.js 파일에서 모든 코드를 입력해버리면 파일도 너무 커지고 보기 힘드니까, 라우터를 작성해봅시다. 우리는 라우터의 기본 틀을 먼저 만들고 나서, 기능구현은 그 다음에 하도록 하겠습니다.

회원가입 API 준비하기

auth 라우터부터 작성을 해볼까요?  회원가입의 라우트는 /api/auth/register 로 설정하도록 하겠습니다.

routes/api/auth/controller.js 파일을 생성하여 다음 코드를 작성하세요.

routes/api/auth/controller.js

/*
    POST /api/auth/register
    {
        username,
        password
    }
*/
exports.register = (req, res) => {
    res.send('this router is working')
}

회원가입 API 의 기본 틀을 만들어놓았습니다.

그 다음엔, routes/api/auth/index.js 파일에서 방금 만든 컨트롤러 파일을 불러온 후, 라우터를 정의해봅시다.

routes/api/auth/index.js

const router = require('express').Router()
const controller = require('./auth.controller')

router.post('/register', controller.register)

module.exports = router

우리는 auth 라는 라우터를 정의했습니다.

이제 상위 폴더로 올라가서 api 라는 라우터를 정의 한 뒤, 해당 라우터에서 /auth 로 요청이 들어오면 위 라우터로 연결시켜줍시다.

routes/api/index.js

const router = require('express').Router()
const auth = require('./auth')

router.use('/auth', auth)

module.exports = router

코드가 거의 비슷하지요?

마지막으로, app.js 파일을 다시 열어서 /api/ 경로로 요청이 들어오면 위 라우터로 연결시켜줍시다.

app.js

// (...)

// index page, just for testing
app.get('/', (req, res) => {
    res.send('Hello JWT')
})

// configure api router
app.use('/api', require('./routes/api'))

// open the server
app.listen(port, () => {
    console.log(`Express is running on port ${port}`)
})

// (...)

지금까지 한 작업을 정리하자면, auth → api → app 순으로 한단계 한단계 거슬러 올라간 것 입니다. 자 이제 코드를 저장 후, Postman 으로 http://localhost:3000/api/auth/register 경로에 POST 요청을 해보세요

%ec%9d%b4%eb%af%b8%ec%a7%80-34

제대로 작동 하는군요.

 

회원가입 API 기능 구현하기

회원가입 기능을 구현하기전에 우리 프로젝트에서 회원가입부분에서 사용할 알고리즘을 알아봅시다

untitled-diagram

회원가입 요청이 들어오면, 아이디가 중복되는지 확인을 하고, 새 회원을 등록합니다. 그리고 만약에 해당 유저가 첫번째 유저라면, 그 유저를 관리자 권한을 부여합니다.

이 작업에서 우리는 MongoDB에 3번의 쿼리를 해야합니다. 데이터베이스에 쿼리를 하는것은, 비동기 작업이죠. Node.js 에서 비동기작업은 보통 콜백으로 처리를하지요. 하지만, 이렇게 비동기작업이 많아지면 콜백안에 콜백안에 콜백이 생기게됩니다.

콜백이 많아지면… 콜백지옥이라고도 부르죠.

francois-bg

업데이트된 자바스크립트의 문법 ES6 에서는 Promise 라는 기능을 지원하여 조금더 코드를 보기 좋게 작성 할 수 있게 해줍니다.  만약에 Promise 에 대해서 잘 모르신다면 Webframeworks.kr 의 ES6 Promises(1) – the API 포스트를 참고하세요.

 

자, 이제 routes/api/auth/auth.controller.js 파일을 열어서 상단에 User 모델을 require 해주고, 함수에 기능을 구현해봅시다.

/api/auth/auth.controller.js

const User = require('../../../models/user')

/*
    POST /api/auth
    {
        username,
        password
    }
*/

exports.register = (req, res) => {
    const { username, password } = req.body
    let newUser = null

    // create a new user if does not exist
    const create = (user) => {
        if(user) {
            throw new Error('username exists')
        } else {
            return User.create(username, password)
        }
    }

    // count the number of the user
    const count = (user) => {
        newUser = user
        return User.count({}).exec()
    }

    // assign admin if count is 1
    const assign = (count) => {
        if(count === 1) {
            return newUser.assignAdmin()
        } else {
            // if not, return a promise that returns false
            return Promise.resolve(false)
        }
    }

    // respond to the client
    const respond = (isAdmin) => {
        res.json({
            message: 'registered successfully',
            admin: isAdmin ? true : false
        })
    }

    // run when there is an error (username exists)
    const onError = (error) => {
        res.status(409).json({
            message: error.message
        })
    }

    // check username duplication
    User.findOneByUsername(username)
    .then(create)
    .then(count)
    .then(assign)
    .then(respond)
    .catch(onError)
}

만약에 Promise 를 사용하지 않았더라면, 조금 더 nested 되어있고 복잡한 구조였겠죠.

코드를 조금 더 깔끔하게 작성하기 위하여 각 작업들을 따로 함수로 만든다음에 Promise chain 을 만들었습니다.

위 구조도, 보기에 꽤 괜찮긴하지만, ES6 보다 더 최신문법인 ES7 에서는 비동기작업을 더 깔끔하게 할 수 있는 async & await 이라는 문법을 지원합니다. 하지만 그 문법을 사용하려면 babel 설정을 해야하기에.. 이 강의에선 Promise 만 사용을 했습니다. 나중에 시간이 있으시면 이 문법도 알아보시길 바랍니다. 정말 편하거든요. (자세한 내용은 여기서)

코드를 저장하고, Postman 으로 회원가입 요청을 넣어보세요.

%ec%9d%b4%eb%af%b8%ec%a7%80-35

오호.. 가입에 성공했다고합니다. 관리자로도 지정이 되었습니다. 한번 똑같은 요청을 다시한번 넣어보세요.

%ec%9d%b4%eb%af%b8%ec%a7%80-32

유저네임이 중복된다고 뜨네요. 이번엔 username 을 다른 값으로 설정해서 다시 요청을 해보세요

%ec%9d%b4%eb%af%b8%ec%a7%80-33

이번엔 관리자 지정이 되지 않았습니다. 저희가 작성한 코드가 제대로 작동을 하는군요.

 

#6 로그인 구현

로그인 API 준비하기

자, 이제 드디어 로그인을 하고 JWT 토큰을 발급받는 방법을 다뤄보도록 하겠습니다.

우선, 회원가입 API 를 만들때 했었던것처럼 로그인 API 도 라우팅을 위한 코드를 미리 작성하겠습니다.

로그인의 라우트는 POST /api/auth/login 으로 설정하겠습니다.

routes/api/auth/auth.controller.js

// (...)

/*
    POST /api/auth/login
    {
        username,
        password
    }
*/

exports.login = (req, res) => {
    res.send('login api is working')
}

routes/api/auth/index.js

const router = require('express').Router()
const controller = require('./auth.controller')

router.post('/register', controller.register)
router.post('/login', controller.login)

module.exports = router

 

로그인 API 기능 구현하기

자, 이제 login 함수의 기능을 구현해줄 차례입니다. jwt 토큰을 발급하려면, 강좌의 시작부분에서 npm 을 통해 설치한 jsonwebtoken 모듈을 사용해야합니다. 우선, 컨트롤러 파일의 상단에 해당 모듈을 불러와주세요

routes/api/auth/auth.controller.js

const jwt = require('jsonwebtoken')
const User = require('../../../models/user')

// (...)

jsonwebtoken 으로 JWT 발급하기

사용법: jwt.sign(payload, secret, options, [callback])

만약에 callback 이 전달되면 비동기적으로 작동하며, 콜백함수의 파라미터는 (err, token) 입니다. 전달되지 않을시엔 동기적으로 작동하며, JWT 를 문자열 형태로 리턴합니다.

payload 는  객체, buffer, 혹은 문자열형태로 전달 될 수있습니다.

secret 은 서명을 만들 때 사용되는 알고리즘에서 사용되는 문자열 혹은 buffer 형태의 값 입니다.

options:

  • algorithm: 기본값은 HS256 으로 지정됩니다.
  • expiresIn: JWT 의 등록된 클레임중 exp 값을 x 초후 혹은 rauchg/ms 형태의 기간 후로 설정합니다.
    (예제: (60, “2 days”, “10h”, “7d”)
  • notbefore: JWT 의 등록된 클레임중 nbf 값을 x 초후 혹은 rauchg/ms 형태의 기간 후로 설정합니다.
    (예제: (60, “2 days”, “10h”, “7d”)
  • audience
  • issuer
  • jwtid
  • subject
  • noTimestamp
  • header

더 자세히보기..

이제 login 함수 내에서 유저 정보를 확인하고, jwt 토큰을 발급해줍시다

routes/api/auth/auth.controller.js

// (...)

/*
    POST /api/auth/login
    {
        username,
        password
    }
*/

exports.login = (req, res) => {
    const {username, password} = req.body
    const secret = req.app.get('jwt-secret')

    // check the user info & generate the jwt
        // check the user info & generate the jwt
    const check = (user) => {
        if(!user) {
            // user does not exist
            throw new Error('login failed')
        } else {
            // user exists, check the password
            if(user.verify(password)) {
                // create a promise that generates jwt asynchronously
                const p = new Promise((resolve, reject) => {
                    jwt.sign(
                        {
                            _id: user._id,
                            username: user.username,
                            admin: user.admin
                        }, 
                        secret, 
                        {
                            expiresIn: '7d',
                            issuer: 'velopert.com',
                            subject: 'userInfo'
                        }, (err, token) => {
                            if (err) reject(err)
                            resolve(token) 
                        })
                })
                return p
            } else {
                throw new Error('login failed')
            }
        }
    }

    // respond the token 
    const respond = (token) => {
        res.json({
            message: 'logged in successfully',
            token
        })
    }

    // error occured
    const onError = (error) => {
        res.status(403).json({
            message: error.message
        })
    }

    // find the user
    User.findOneByUsername(username)
    .then(check)
    .then(respond)
    .catch(onError)

}

여기에서도 마찬가지로 각 작업을 함수로 만든다음에, Promise chain을 만들어주었습니다. 함수의 내부에 check 함수는 유저의 정보를 확인하고 토큰을 발급해주는데요, 여기서 토큰을 비동기적을 만들기에, 내부에서 새로운 Promise 를 만들어 리턴을 하도록 설정하였습니다. 그러면 Promise 에서 이 함수가 실행되면, 로그인이 성공 했을 때 다음. then() 에서는 토큰값을 전달받겠지요.

코드를 저장하고, Postman 을 열어 다음과 같이 로그인 요청을 해봅시다.
%ec%9d%b4%eb%af%b8%ec%a7%80-2

토큰이 정상적으로 생성이 되었네요! 한번 이 토큰을 https://jwt.io/ 에 붙여넣어볼까요?

fb

잘 만들어졌군요. (서명 부분에서 secret 부분에 우리가 config.js 파일에서 입력한 값을 넣어주어야 합니다)

 

#7 JWT 검증 구현

유저가 JWT 값을 헤더에 x-access-token 으로 설정하거나, url parameter 로 서버로 전달하면, 서버측에서 그 토큰을 가지고 검증 한 후 현재 계정의 상태를 보여주는 기능을 구현해보겠습니다.

계정정보 체킹 라우트는 GET /api/auth/check 로 설정하겠습니다.

routes/api/auth/auth.controller.js

/*
    GET /api/auth/check
*/

exports.check = (req, res) => {
    // read the token from header or url 
    const token = req.headers['x-access-token'] || req.query.token

    // token does not exist
    if(!token) {
        return res.status(403).json({
            success: false,
            message: 'not logged in'
        })
    }

    // create a promise that decodes the token
    const p = new Promise(
        (resolve, reject) => {
            jwt.verify(token, req.app.get('jwt-secret'), (err, decoded) => {
                if(err) reject(err)
                resolve(decoded)
            })
        }
    )

    // if token is valid, it will respond with its info
    const respond = (token) => {
        res.json({
            success: true,
            info: token
        })
    }

    // if it has failed to verify, it will return an error message
    const onError = (error) => {
        res.status(403).json({
            success: false,
            message: error.message
        })
    }

    // process the promise
    p.then(respond).catch(onError)
}

routes/api/auth/index.js

const router = require('express').Router()
const controller = require('./auth.controller')

router.post('/register', controller.register)
router.post('/login', controller.login)
router.get('/check', controller.check)

module.exports = router

코드를 저장하고 테스팅을 해봅시다. 첫 시도에서는 아까 발급받은 토큰을 x-access-token 헤더값으로 지정하고 다음과 같이 요청을 날려보세요.

%ec%9d%b4%eb%af%b8%ec%a7%80-4

잘 되는군요. 두번째 시도는 기존 헤더 값을 지우고, 다음과 같이 url-encoded 쿼리 값을 설정하여 전달을 해보세요.

gg
이 또한 잘 됩니다. 이번엔 토큰 값을 막무가내로 수정하고 요청을 해봅시다.

%ec%9d%b4%eb%af%b8%ec%a7%80-11

실패도 제대로 반환이 되네요.

 

#8 JWT 검증 미들웨어 구현

어느정도 감을 잡았죠? 그런데, 토큰을 필요로하는 모든 요청에 토큰검증 코드를 넣기엔 너무나 자주 반복되잖아요? 이 작업을 더 간단하게 하는 방법이 있습니다. 바로 미들웨어죠.

라우터에서 주어진 요청을 설정하기전에, JWT 검증 미들웨어를 통하여 JWT 검증 작업을 하고 나서, 주어진 작업을 하게 하도록 구현을 해봅시다.

middlewares/auth.js 파일을 열어 다음 코드를 작성해주세요.

middlewares/auth.js

const jwt = require('jsonwebtoken')

const authMiddleware = (req, res, next) => {
    // read the token from header or url 
    const token = req.headers['x-access-token'] || req.query.token

    // token does not exist
    if(!token) {
        return res.status(403).json({
            success: false,
            message: 'not logged in'
        })
    }

    // create a promise that decodes the token
    const p = new Promise(
        (resolve, reject) => {
            jwt.verify(token, req.app.get('jwt-secret'), (err, decoded) => {
                if(err) reject(err)
                resolve(decoded)
            })
        }
    )

    // if it has failed to verify, it will return an error message
    const onError = (error) => {
        res.status(403).json({
            success: false,
            message: error.message
        })
    }

    // process the promise
    p.then((decoded)=>{
        req.decoded = decoded
        next()
    }).catch(onError)
}

module.exports = authMiddleware

사전에 작성했었던 check 함수와 비슷하죠? 이제 이 미들웨어를 GET /api/auth/check 라우트에 적용해보겠습니다.

/routes/api/auth/index.js

const router = require('express').Router()
const controller = require('./auth.controller')
const authMiddleware = require('../../../middlewares/auth')

router.post('/register', controller.register)
router.post('/login', controller.login)

router.use('/check', authMiddleware)
router.get('/check', controller.check)

module.exports = router

그러면, auth.controller.js 안에 있는 check 함수를 이렇게 간단하게 수정하면됩니다. 만약에 토큰 검증에 실패 될 땐, 미들웨어에서 next() 함수가 실행되지 않기때문에, 이 check 함수 내부에서는 토큰이 검증실패했을때를 고려하지 않아도 된답니다.

/routes/api/auth/auth.controller.js

// (...)
/*
    GET /api/auth/check
*/

exports.check = (req, res) => {
    res.json({
        success: true,
        info: req.decoded
    })
}

여기까지 잘 따라오셨나요? 축하합니다! 이 강의에서 배울 핵심적인 내용을 다 배우셨습니다! 이 포스트 하단부로는 유저 인증 시스템의 추가적인 기능들을 구현하고, plain text 로 저장하던 비밀번호를 해싱하여 저장하도록 수정합니다.

Node.js 실습을 추가적으로 하고 싶다면 섹션 #9 와 섹션 #10 도 진행을 하시고, 그렇지 않다면 최하단의 마치면서.. 섹션으로 스크롤하세요.

 

 

#9 유저 인증 시스템 추가기능 구현

자, 이제 유저 인증 시스템에서 필요한 두가지의 추가 기능들을 구현해보도록 하겠습니다.

  • 관리자 계정으로 모든 유저 리스팅
  • 관리자 계정으로 특정 유저 관리자권한 부여

user 라우터 컨트롤러 코드 작성

이 라우터에서는 jwt 와 발급 / 검증 작업을 하지 않기 때문에 (미들웨어가 해줄것이기 때문이죠) 작성 할 코드는 꽤나 간단합니다.

routes/api/user/user.controller.js

const User = require ('../../../models/user')

/* 
    GET /api/user/list
*/

exports.list = (req, res) => {
    // refuse if not an admin
    if(!req.decoded.admin) {
        return res.status(403).json({
            message: 'you are not an admin'
        })
    }

    User.find({})
    .then(
        users=> {
            res.json({users})
        }
    )

}


/*
    POST /api/user/assign-admin/:username
*/

exports.assignAdmin = (req, res) => {
    // refuse if not an admin
    if(!req.decoded.admin) {
        return res.status(403).json({
            message: 'you are not an admin'
        })
    }

    User.findOneByUsername(req.params.username)
    .then(
        user => user.assignAdmin
    ).then(
        res.json({
            success: true
        })
    )
}

 

user 라우터 코드 작성

routes/api/user/index.js

const router = require('express').Router()
const controller = require('./user.controller')

router.get('/list', controller.list)
router.post('/assign-admin/:username', controller.assignAdmin)

module.exports = router

user 라우터의 경우,라우터 내부의 모든 API 들이 JWT 토큰 검증이 필요하므로, 여기서 authMiddleware 적용하지 않고, routes/api/index.js 파일에서 적용합니다.

 

api 라우터 수정

api 라우터에서 방금 만든 user 라우터를 불러오고, 그 라우터에 authMiddleware 도 적용을 해줍니다.

/routes/api/index.js

const router = require('express').Router()
const authMiddleware = require('../../middlewares/auth')
const auth = require('./auth')
const user = require('./user')

router.use('/auth', auth)
router.use('/user', authMiddleware)
router.use('/user', user)

module.exports = router

코드를 저장하고, 방금 만든 API들이 잘 작동하는지 체크해봅시다.

api-test잘 되는군요. API 요청을 할 때, x-access-token 값 설정하는것 잊지 마세요!

 

#10 비밀번호를 암호화하여 저장하기

비밀번호를 데이터베이스에 저장 할때, plain text 그대로 저장하는것은 매우 위험합니다.  따라서 우리는 HMAC-SHA1 으로 비밀번호를 해쉬하여 저장하도록 하겠습니다.

그러기 위해선, 유저 모델에서 node.js 내장 모듈인 crypto 를 불러와서 사용하면 됩니다. 해싱을 할 때 사용 할 비밀키는 편의상 config 파일을 다시 불러와서 jwt 에서 사용하는 비밀키와 동일하게 사용하겠습니다.

create 메소드와 verify 메소드를 다음과 같이 수정해주세요.

models/user.js

const mongoose = require('mongoose')
const Schema = mongoose.Schema
const crypto = require('crypto')
const config = require('../config')

// (...)

// create new User document
User.statics.create = function(username, password) {
    const encrypted = crypto.createHmac('sha1', config.secret)
                      .update(password)
                      .digest('base64')

    const user = new this({
        username,
        password: encrypted
    })

    // return the Promise
    return user.save()
}

// (...)

// verify the password of the User documment
User.methods.verify = function(password) {
    const encrypted = crypto.createHmac('sha1', config.secret)
                      .update(password)
                      .digest('base64')

    return this.password === encrypted
}

// (...)

주의: 이전에 만든 계정들은 작동을 안할테니, 데이터베이스를 비워주시고 새로 가입을 하세요.

cdcdc

자, 이제 기존에 만들었던 API 들이 제대로 되는지 테스팅을 해보세요. 도중에 막히는게 있다면 소스코드를 참고해보시길 바랍니다.

 

마치면서..

이번 포스트에서는 JSON Web Token 을 사용하는 간단한 인증시스템을 구현해보았습니다. 다음 이어질 글에서는 JWT 를 사용 할 때의 보안 이슈, 토큰을 클라이언트측에서 어디에 저장해야 할 지 알아보도록 하겠습니다. 추후, React 어플리케이션에서 이 JWT 기반 인증 시스템을 연동하는 방법도 알아보겠습니다.

  • Park Eden

    만드느라 고생 좀 하셨겠어요. 게으른 저는 라이브러리나 thrid-party authentication를 쓰는데 또 이렇게 보니 못할것도 없다는 생각이 들다가 보안 이라는 단어를 들으니 움찔하네요 ㅎㅎ

    • 라이브러리를 사용해서 인증을 구현해도 보안이슈는 똑같이 존재하지요 ㅎㅎㅎ

      그래도 해커에 의해 발생할수있는 피해를 최소화시키고 너무나 쉽게 공격당하지 않으려면 기본적인 보안처리를 해야겠지요?

  • Danny Park

    항상 올라오는 강좌들 잘 읽고 있습니다. 매번 유용한 올리시느라 고생이 많으세요 🙂
    한가지 궁금한 점이 있는데요, 보통 third party oauth같은 경우,

    server 자체에서 authentication 및 accessToken 를 받은후(passport-facebook, passport-google 같은 경우) 그 token를 다시 client side를 주므로써 토큰의 사용가능 시간동안 사용 가능케 하는것이 있고,

    반대로 client 에서 third oauth javascript API 를 사용하여 authentication 후 토큰을 server로 token를 준후 서버 자체에서, 토큰의 verification를 체크 하는 방식이 있는걸로 아는데

    제가 알기론 후자의 방식이 보편적인 방법이라 알고 있는데, 특정적인 이유가 있을까여?
    후자의 경우 결국 token를 서버로 보낸후 서버에서 최종적으로 이 토큰이 실제 저의 application에서 등록한 token 인지 확인을 해야 하는 경우라면, 결국 데이터 usage는 비슷 할거로 생각되는데.. 혹시 아시는 정보 있으시면 부탁드립니다 🙂

    • 보통 후자의 방식으로 하지 않을까요? 저 같은 경우는 써드파티 oauth에서 발급된 토큰을 클라이언트에 보내서 할 생각을 잘 안해봐서..

      제가 이해하고 있는 바로는, 예를들어 페이스북에서 발급받은 access token은 서버에 담아두는 용입니다. 물론 구조에 따라 클라이언트에 담아둘수도 있긴합니다.

      그런데 그 토큰의 용도는 자신의 서비스에서 인증하는 용도가아니라 자신의 서비스에서 그 토큰을 활용하여 페이스북의 Graph API를 요청할때 사용하는 용도로 사용됩니다.

      만약에 그 액세스토큰을 인증용으로 사용한다면, 서버측에선 자체적으로는 해당 액세스토큰의 데이터가 위조되지는 않았는지 확인이 불가능합니다. 비밀키가 없어서 서명을 검증하지 못하거든요. 토큰을 검증하는 유일한 방법은 요청을 받았을때 그 토큰을 페이스북에 검증을 요청해야합니다.

      위 방법이 불가능한건 아니죠. 하지만 제대로 하려면 자원이 낭비됩니다.

      도움이 되었으면 좋겠네요 😀

  • 정진욱

    routes/api/auth/index.js 에서
    const controller = require(‘./auth.controller’)
    이부분이 같은 폴더에 있어서
    const controller = require(‘./controller’) 가 되야할 것 같습니다!

    • 처음에는 파일명을 controller.js 로 했었는데, auth.controller 와 같은 형식으로 하게 된 계기가,
      컨트롤러 파일을 여러개 열었을때 조금 헷갈리더라구요! 그걸 방지하기 위해서 앞에 auth 를 추가했습니다.

      본인의 필요에 따라 없애셔도됩니다

    • 정진욱

      아하 다시보니 파일이름이 auth.controller 였네요!

  • unknowned

    덕분에 공부잘하고 있습니다. 근데 저만안되나요.. 409 에러나면서 username exists 가 자꾸 나오네요 그래서 모델에서 find를 findOne으로 바꿨더니 db에 써지긴하는데 admin만 true 이고 usernamer과 password는 기록이안되네요

    컨트롤러에
    exports.register = (req, res) => {
    const { username,password } = req.body

    이부분에서 바디파서가 못읽는건지.. 아니면 제컴퓨터가 이상한건가요..

    • unknowned

      아.. 포스트맨에서 text가아니라 json 형식으로 바꿔놓고 해야되는군요 ㅠㅠ 죄송합니다.

      • 앗! find -> findOne 부분은 제가 나중에 수정해놓고 포스트에 반영을 하지 않았었네요.
        방금 수정했습니다 ;D

        json 형식으로 하니 해결됐나요? 다행이네요!!

      • 전민혁

        전 수정된 findOne도 안됩니다!
        json형식으로 바꾸면 되네요!! 감사합니다!!
        글쓴이님도 감사합니다 덕분에 공부가 잘 되고 있습니다~~

  • 이방인

    제가 접속하는 블로그가 딱 하나 있는데 그게 이곳입니다.. 양질의 정보를 최대한 요약해서 효율적으로, 그것도 참 잘 알려주십니다. 감사합니다.
    이렇게 포스트 하시면서 정리도 하시고 공부도 하시고 하는건가요? 어디에 팔아도 살 것 같은 내용들이 많은데 많은 시간을 들여 포스트 해주시는 이유가 갑자기 궁금해졌습니다 ㅎㅎ

    • 블로깅의 첫 목적은 지식을 정리하는거였습니다. 에버노트같은거에 개인적으로 정리하는것보단, 남들이 볼 수 있게 공개적으로 올리는것도 좋다고 생각해서요. 요즘은 글 쓰는것 자체를 꽤 즐기고 있는 것 같습니다 ㅎㅎㅎ

      응원이 되는 덧글 감사합니다 🙂

      • 이방인

        그렇군요 감사합니다. 지금은 작은 스타트업의 대표입니다만, 언젠가 기회가 된다면 velopert님을 꼭 헤드헌팅 하겠습니다. 수고하세요.

  • 박지성

    잘봤습니다.
    로그인 인증 후 발급 된 토큰을 어딘가 저장해두어야 할 것 같은데,
    모바일의 경우 자체db나 임시저장소에 넣어두면 되는 것 인지요?
    웹의 경우는 쿠키에 넣고 다시 꺼내쓰는게 맞는것인지 헷갈리네요.
    토큰 정보를 세션이나 서버에 넣어두자니 그건 그거대로 낭비가 될것 같아서요

    • 브라우저측에서는 웹스토리지 혹은 쿠키에 담습니다. 쿠키에 담을때는 httpOnly 설정을 하여 자바스크립트로는 접근 할 수 없게 해야 합니다.

      모바일의 경우 말씀하신대로 자체 db에 넣으면 되겠구요.

      이 토큰 정보는 서버측에서는 담아두지 않고 오직 클라이언트측에만 담습니다.

      토큰을 임의로 파기해야할땐 보통 블랙리스트를 만들어서 파기합니다.

      • 백재인

        httpOnly로 하면은 자바스크립트 코드로 token값을 못불러오던데
        header에 어케 담나요? 이 부분이 너무어렵네요 ㅜ

  • 홍두라

    발급된 토근을 브라우저 쿠키에 담으려고하는데 모바일의 경우 자체 db라는게 어떤건지 자세하게 알수있을까요??

  • 리액트

    router.post(‘/register’, controller.register)
    이부분에서 router.use 로 해야 되네요.

    • 혹시 Postman 같은 API 테스팅 툴 사용하셨나요?

  • Jungho Son

    항상 좋은 강의 잘 보고 있습니다~
    routes/api/user/user.controller.js
    admin권한 주는 부분에서
    user => user.assignAdmin —-> user => user.assignAdmin()
    요렇게 하는게 맞는게 아닌지 문의 드려요
    전 작성해주신대로 하면 success는 떨어지지만
    admin 프로퍼티가 그대로 false로 남아 있는거 같아서요..

    • 맞아요! 오타가 났네요. 조만간 수정하도록 하겠습니다 😉

  • 김진관

    좋은 강의 감사합니다.
    질문이 있습니다. 다름이 아니라, api 엔 header 에 token 을 넣어서 질의가 가능한데요.
    유저가 url 창에 입력한다거나, redirect 될 때, 링크를 눌렀을 때 등의 상황일 때, token 정보를 기본적으로 세팅해서 query 가능한가요???

    • 웹 프로젝트 구조를, 정보같은걸 불러올 때 API를 사용하게 하도록 설계해야해요. 따라서 URL에 들어갔을때 로컬스토리지에서 토큰을 불러와서 데이터를 로딩하도록 하는거지요.

      아니면, 토큰을 httpOnly 속성으로 쿠키로 설정하셔도 되겠습니다.

      • 김진관

        답변 감사합니다.^^
        대부분은 API로 호출해서 header 를 세팅해서 쓰긴 하는데,
        1페이지 링크 -> 2페이지 동의 -> 3페이지 실제 페이지
        일 때 동의 후엔 1->3으로 바로 가려다보니.. 문의드렸습니다.^^;;
        httpOnly 로 한번 해보겠습니다.^^

  • jwt에 대해 궁금증이 풀리네요 : )
    다음 이어질 글(JWT의 보안이슈)도 궁금한데 언제쯤 추가가 될까요?

  • so s

    5.회원가입 구현하는 부분에서
    register 사용시 409 에러코드가 뜨며 “message” : Data must be a string or a buffer”이라는 에러가 뜨는데 초짜라 이유를 모르겠습니다 도와주세요 ㅠㅠ

  • so s

    위의 소스를 이용해서 구현하면 postman에 users 라는 컬렉션으로 생성 , 입력 되는데 왜 Users 컬렉션이 생성되는건가요? 어느 위치인가요 ㅠㅠ

  • daeq

    https://uploads.disquscdn.com/images/900ef05c5b0b0cc05a1411e47559d8127179ad6d62982d0d19b4ec697706cb91.png
    실습 중에 app.js 오류가 계속 나서 velopert님 소스코드를 받아서 실행 해보니 마찮가지로 auth.controller.js 에서 문제가 발생합니다.
    어떻게 해결해야 할 지 도통 모르겠습니다. 도움 부탁드려요.

    • daeq

      아아.. node.js 버전 문제였던것 같네요. 업데이트하고 해결 되었습니다.

  • HoSung

    좋은글 잘 읽었습니다.
    JWT 로그아웃은 어떻게 구현해야하나요?
    클라이언트에 남아있는 토큰만 파기하면 안전한건가요? Cookie에 저장하고 HttpOnly옵션을 주게되면 괜찮을지 궁금합니다. 서버쪽에서도 할 수 있는 작업이 있다면 어떤게 있을까요

    • coderavel

      기존의 로그아웃은 서버쪽의 세션에서 로그인한 사용자의 정보를 삭제하면 되는데. jwt를 사용할 경우에는 어차피
      expire time이 있으니 이걸로 오버됐으면 로그아웃으로 판단하면 되지 않을까요? 클라이언트 쪽에서 구지 삭제할 필요는 없을 듯요. 만약 명시적으로 클라이언트에서 해당 토큰을 삭제한다면 (거의 쿠키에 들어있겠죠?) 서버쪽에서는 토큰이 비어있으니 당연히 디코딩을 할 수 없고 로그아웃된 상태라고 알 수 있겠죠
      결국 서버쪽에서는 [ 토큰이 있다 or 없다 -> 있으면 파싱 -> 익스파이어타임 체크 ] 순으로 하면 되겠네요

  • JINHWANG KIM

    좋은 글 감사합니다. 정말 많은 도움 받았습니다.

  • 최대규

    좋은 강의 정말 감사합니다! 한 가지 궁금한게 있는데요~!!
    한 번 로그인에 성공한 후에, 다음 번에 방문해도 로그인 상태를 유지하게끔 하고 싶은데요,
    그렇게 하기 위해서는 토큰을 어딘가에 저장해놓아야 하는데,
    쿠키나 세션에 토큰을 저장해놓고 홈페이지에 방문할때마다(미들웨어를 만들어야하나요..?) 토큰을 불러오게 하면 되나요?
    혹시 쿠키나 세션에 토큰에 저장해놓으면 보안상 문제가 되지는 않는지 궁금합니다.
    좋은 글 정말 감사합니다. 덕분에 많이 배우고 있습니다.

    • 이준형

      localstorage에 토큰 값을 저장하고 클라이언트에서는 인증이 필요한 요청을 할 때마다 헤더 값(여기서는 x-access-token)에 토큰 값을 넘겨주시면 됩니다.
      혹은 쿠키에 토큰을 저장해도 되구요.
      원리는 같습니다.

  • 미르아빠 찌니남편

    안녕하세요
    좋은 글 덕분에 promise개념이 많이 좋아 졌습니다. 정말 고맙습니다.
    한가지 질문 드릴게 있어서 글 남깁니다..

    firestore 사용하여 작업 중입니다.

    정보 등록하고 그 등록된 아이디를 사용하여
    다른 페이지를 생성해야됩니다.
    단계가 4번 정도 되어야합니다.
    결과값이 돌아오는 시간이 늦어지는 경우
    빈 아이디로 생성이 되어 찾아보니 결과값이 넘어오기전에
    다음 단계가 진행되어 그런것같습니다.
    순차적으로 결과값을 이용해서 then 처리 하려면 어떻게 해야될지 알 수 있을까요?
    읽어 주셔서 고맙습니다

    • 프로미스 내부에서 Promise 를 리턴하면 됩니다
      참고: https://javascript.info/promise-chaining

      추가적으로, async / await 을 사용하면 조금 더 편하게 하실 수 있습니다 ㅎㅎ

      • 미르아빠 찌니남편

        와~~~ 고맙습니다. 다른 방법들을 검색하다 적용해보면 뭔가 어색하고 억지로 다음 함수를 호출하고 해서 이상하지만 어쩔수없이 썼는데 이렇게 정확한 방법이 있었네요..
        고맙습니다.
        async / await 은 글은 읽었습니다..
        위에 글 적용해 보고 async 찾아 보도록 하겠습니다
        고맙습니다!!

  • 임성훈

    안녕하세요. 벨로퍼트님 올려주신 글 보면서 공부하다가 궁금한게 생겨서 글을 남기게 됐는데요 ㅎㅎ

    // set the secret key variable for jwt
    app.set(‘jwt-secret’, config.secret)

    여기서 secret 키가 필요할 때 config.secret을 넣어주는것 대신 app.set에 저장하신 이유를 잘 모르겠어용 ㅎㅎㅎ
    이유가 뭔가요???

  • hans H

    안녕하세요 잘봤습니다. 코드가 너무 깔끔하게 술술 읽히네요. 제가 초보라 궁금한 게 많아서 혹시 시간 되시면 답변 좀 부탁드립니다 ㅎㅎ.
    1. 클라이언트에서 리퀘스트 보낼 때 local storage에 저장되어 있는 token을 header의 x-access-token에 포함하는 것과 토큰 만료 설정은 클라이언트 app에서 코딩해야 하는 거죠?
    2. 보통 config의 token secret 키와 password secret key는 주기적으로나 자동적으로 교체해주나요? 다른 키로 설정하는 게 일반적인가요?
    3. password hashing에 jwt를 사용하지 않는 이유는 있나요?
    4. login token 정보에 password를 포함하지 않는 건 관습적인 건가요?

  • John Naker

    ajax 이용해서 로그인 하고 받은 토큰을 요청헤더에 저장해서 redirect 하려고하면 어떻게 해야하죠?

  • 좋은글 감사합니다!
    질문이 하나있습니다.
    해더값으로 x-access-token을 넘겨주는게 좋나요?
    uri로 넘겨주는게 좋나요?

  • 박찬울

    admin 이 admin 이 아닌 일반 user 에게 권한을 줄 때,
    해당 user 가 없더라도 success message 가 뜨는 문제를 다음과 같이 해결하였습니다.

    routes/api/user/user.controller.js 의 assignAdmin

    User.findOneByUsername(req.params.username)
    .then(
    user => {
    user.assignAdmin();
    }
    ).catch (
    res.json({
    message: req.params.username+’ does not exist.’
    })
    ).then (
    res.json({
    success: true
    })
    )

  • ys

    처음 코드 init 누락 됐네요^^
    npm -y
    npm init -y

  • Stephen Lee

    감사드립니다. 덕분에 전체적으로 token 이 무엇이고 어떻게 사용하는지 알게 되었습니다.
    Apollo 랑 연동한다고 고생했네요.. graphql 도 처음이고 apollo 도 처음이여서 서로 연결하려하니 이것 저것 찾아보고 시도해보면서 많은걸 배운것 같습니다. jwt 로 인증하는 방법도 덕분에 알게됐습니다. 감사합니다!