Nodejs: Xác thực người dùng sử dụng JWT và cơ chế Refresh Token

Ứng dụng Nodejs xác thực sử dụng JWT(Json Web Token) rất hữu ích khi bạn đang xây dựng một ứng dụng cho phép người dùng xác thực từ nhiều thiết bị (web app, mobile app…).

Một ứng dụng sử dụng xác thực bằng token hoạt động như thế nào:

  • Người dùng đăng nhập vào hệ thống và sau khi xác thực thành công, người dùng nhận được một mã token duy nhất và mã này có thời hạn sử dụng(ví dụ 15 phút).
  • Trong mỗi lần gọi API tiếp theo, người dùng phải đính kèm mã token trong request để truy cập các tài nguyên của hệ thống.
  • Khi hết thời gian, người dùng phải đăng nhập lại để nhận mã token mới.

Bước cuối cùng gây ra nhiều khó chịu, chúng ta không thể bắt người dùng đăng nhập lại mỗi khi mã token bị hết hạn.

Chúng ta có 2 cách để giải quyết vấn đề trên:

  1. Tăng thời gian hết hạn của token
  2. Sử dụng Refresh token để yêu cầu một token mới

Trong bài viết này, mình hướng dẫn xây dựng một ứng dụng Nodejs có bước xác thực người dùng và sử dụng giải pháp Refresh token để xử lý trường hợp mã token bị hết hạn.

Chú ý: Code trong bài viết chỉ nên sử dụng để giải thích, không nên sử dụng trong các ứng dụng thực tế.

Khởi tạo project

Chúng ta sẽ đi thẳng vào việc code xây dựng ứng dụng. Mình sẽ giải thích trên các đoạn code.

Chúng ta cần tạo mới một thư mục, thư mục này sẽ chứa toàn bộ code của dự án.

Từ phần này về sau, các câu lệnh đều được chạy trong thư mục này

Sử dụng câu lệnh dưới đây để tạo mới một ứng dụng Nodejs:

npm init --y

Câu lệnh trên sẽ tạo ra file package.json

Cài đặt các thư viện cần thiết

Chúng ta sẽ cần một vài thư viện để ứng dụng có thể chạy được. Chạy câu lệnh bên dưới để cài đặt chúng:

npm i --S express body-parser jsonwebtoken

Các thư viện đã được cài đặt, nội dung file package.json cũng đã được cập nhật, chúng ta sẽ tới phần tiếp theo.

Gitignore

Bạn cần thêm tệp này để tránh các thư mục hay file nhất định được thêm vào Git Repository.

Bạn cần tạo mới file .gitignore và thêm dòng dưới đây:

node_modules/

Điều này có nghĩa chúng ta sẽ không thêm thư mục node_modules vào git repo.

Ok, tới lúc viết code rồi!

Khởi tạo http server và các route cơ bản

Chúng ta sẽ sử dụng express để tạo mới một http server bằng Nodejs. Đây là nội dung file app.js

const express = require('express');
const bodyParser = require('body-parser');
const jwt = require('jsonwebtoken');
const router = express.Router();
const config = require('./config');
const utils = require('./utils');
const tokenList = {};
const app = express();

router.get('/', (req, res) => {
  res.send('Ok');
});

/**
 * Đăng nhập
 * POST /login
 */
router.post('/login', (req, res) => {
  const postData = req.body;
  const user = {
    "email": postData.email,
    "name": postData.name
  }

  // Thực hiện việc kết nối cơ sở dữ liệu (hay tương tự) để kiểm tra thông tin username and password
  // Đăng nhập thành công, tạo mã token cho user
  const token = jwt.sign(user, config.secret, {
    expiresIn: config.tokenLife,
  });

  // Tạo một mã token khác - Refresh token
  const refreshToken = jwt.sign(user, config.refreshTokenSecret, {
    expiresIn: config.refreshTokenLife
  });

  // Lưu lại mã Refresh token, kèm thông tin của user để sau này sử dụng lại
  tokenList[refreshToken] = user;

  // Trả lại cho user thông tin mã token kèm theo mã Refresh token
  const response = {
    token,
    refreshToken,
  }

  res.json(response);
})

/**
 * Lấy mã token mới sử dụng Refresh token
 * POST /refresh_token
 */
router.post('/refresh_token', async (req, res) => {
  // User gửi mã Refresh token kèm theo trong body
  const { refreshToken } = req.body;

  // Kiểm tra Refresh token có được gửi kèm và mã này có tồn tại trên hệ thống hay không
  if ((refreshToken) && (refreshToken in tokenList)) {

    try {
      // Kiểm tra mã Refresh token
      await utils.verifyJwtToken(refreshToken, config.refreshTokenSecret);

      // Lấy lại thông tin user
      const user = tokenList[refreshToken];

      // Tạo mới mã token và trả lại cho user
      const token = jwt.sign(user, config.secret, {
        expiresIn: config.tokenLife,
      });
      const response = {
        token,
      }
      res.status(200).json(response);
    } catch (err) {
      console.error(err);
      res.status(403).json({
        message: 'Invalid refresh token',
      });
    }
  } else {
    res.status(400).json({
      message: 'Invalid request',
    });
  }
});

/**
 * Middleware xác thực người dùng dựa vào mã token
 * @param {*} req 
 * @param {*} res 
 * @param {*} next 
 */
const TokenCheckMiddleware = async (req, res, next) => {
  // Lấy thông tin mã token được đính kèm trong request
  const token = req.body.token || req.query.token || req.headers['x-access-token'];

  // decode token
  if (token) {
    // Xác thực mã token và kiểm tra thời gian hết hạn của mã
    try {
      const decoded = await utils.verifyJwtToken(token, config.secret);

      // Lưu thông tin giã mã được vào đối tượng req, dùng cho các xử lý ở sau
      req.decoded = decoded;
      next();
    } catch (err) {
      // Giải mã gặp lỗi: Không đúng, hết hạn...
      console.error(err);
      return res.status(401).json({
        message: 'Unauthorized access.',
      });
    }
  } else {
    // Không tìm thấy token trong request
    return res.status(403).send({
      message: 'No token provided.',
    });
  }
}

router.use(TokenCheckMiddleware);

router.get('/profile', (req, res) => {
  // all secured routes goes here
  res.json(req.decoded)
})

app.use(bodyParser.json());

app.use('/api', router);

app.listen(config.port || process.env.PORT || 3000);

Nội dung file utils.js:

const jwt = require('jsonwebtoken');

module.exports = {
  verifyJwtToken: (token, secretKey) => {
    return new Promise((resolve, reject) => {
      jwt.verify(token, secretKey, (err, decoded) => {
        if (err) {
          return reject(err);
        }
        resolve(decoded);
      });
    });
  }
}

File này cung cấp một hàm để xác thực các mã token.

Mình chuyển từ một callback function thành Promise để dễ sử dụng.

Đây là nội dung file config.js

module.exports = {
  "secret": "s0me-secr3t-goes-here",
  "refreshTokenSecret": "some-s3cret-refre2h-token",
  "port": 3000,
  "tokenLife": 900, // 15 phút
  "refreshTokenLife": 86400 // một ngày
}

Mình sử dụng 2 chuỗi bí mật và 2 thời gian hết hạn khác nhau cho 2 loại token. Sau khi đã có 2 loại token, mình tiền hành lưu lại thông tin Refresh token vào một biến: Sử dụng chính token đó làm key và value là thông tin người dùng

tokenList[refreshToken] = user;

Chú ý: Bạn nên sử dụng một nơi lưu trữ ổn định thay vì một biến cục bộ trên product, bạn có thể dùng Redis.

Với route POST refresh_tokenchúng ta sẽ lấy thông tin Refresh token từ trong body client gửi lên, nếu token này tồn tại, chúng ta kiểm tra tính hợp lệ của nó (Có tồn tại trên hệ thống hay không,  có phải do hệ thống sinh ra hay không).

Nếu nhận được một mã Refresh token hợp lệ, chúng ta sẽ tạo mới một mã token và gửi mã mới này về cho người dùng. Bằng cách này người dùng không cần phải đăng nhập lại.

Middleware xác thực cho các API cần bảo vệ

Chúng ta cần có một phần code luôn luôn phải được thực thi để kiểm tra mã token mà client gửi kèm theo các request có hợp lệ hay không.

Như bạn thấy, mình có tạo một hàm để làm việc này:

/**
 * Middleware xác thực người dùng dựa vào mã token
 * @param {*} req 
 * @param {*} res 
 * @param {*} next 
 */
const TokenCheckMiddleware = async (req, res, next) => {
  // Lấy thông tin mã token được đính kèm trong request
  const token = req.body.token || req.query.token || req.headers['x-access-token'];

  // decode token
  if (token) {
    // Xác thực mã token và kiểm tra thời gian hết hạn của mã
    try {
      const decoded = await utils.verifyJwtToken(token, config.secret);

      // Lưu thông tin giã mã được vào đối tượng req, dùng cho các xử lý ở sau
      req.decoded = decoded;
      next();
    } catch (err) {
      // Giải mã gặp lỗi: Không đúng, hết hạn...
      console.error(err);
      return res.status(401).json({
        message: 'Unauthorized access.',
      });
    }
  } else {
    // Không tìm thấy token trong request
    return res.status(403).send({
      message: 'No token provided.',
    });
  }
}

Thực hiện kiểm tra mã token một cách đơn giản. Bằng middleware này, mọi route được đăng ký sau đó đều được bảo vệ, mỗi request đều cần một mã token hợp lệ để có thể truy cập được các tài nguyên của hệ thống.

Test ứng dụng

Tới lúc chúng ta sẽ xem ứng dụng hoạt động như thế nào.

Khởi động ứng dụng bằng câu lệnh:

node app.js

Mở công cụ làm việc với API mà bạn yêu thích lên và test, mình hay sử dụng Postman.

Mẹo: Mình sẽ sử dụng curl để mô tả các request ở phần dưới, các bạn có thể copy đoạn request bằng curl và import vào Postman.

Đầu tiên sẽ là API login POST http://localhost:3000/api/login

curl -X POST \
  http://localhost:3000/api/login \
  -H 'Content-Type: application/json' \
  -d '{
    "email": "[email protected]",
    "name": "hoangdv"
  }'

Kết quả nhận được sẽ tương tự:

{
    "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImhvYW5nLmR2QG91dGxvb2suY29tIiwibmFtZSI6ImhvYW5nZHYiLCJpYXQiOjE1NjAzNTIyOTAsImV4cCI6MTU2MDM1MzE5MH0.xyna1mYwRbRo-Jdo_ZiwjnOJHpSscHrg93ZbIDawNfo",
    "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImhvYW5nLmR2QG91dGxvb2suY29tIiwibmFtZSI6ImhvYW5nZHYiLCJpYXQiOjE1NjAzNTIyOTAsImV4cCI6MTU2MDQzODY5MH0.ZrsVpCfzUOsch_s-51uIdavzMH-N-jmBD3dQz3ssFkw"
}

Bây giờ, hãy copy nội dung của token, nó sẽ được dùng để xác thực khi gọi các api cần xác thực.

Như đoạn code của middleware const token = req.body.token || req.query.token || req.headers['x-access-token']; điều này có nghĩa chúng ta có thể gửi kèm token ở trong body, query string hoặc header của request, mình sẽ lấy ví dụ token được gửi kèm trong header của request: (Mình dùng giá trị token tại thời điểm viết bài)

curl -X GET \
  http://localhost:3000/api/profile \
  -H 'x-access-token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImhvYW5nLmR2QG91dGxvb2suY29tIiwibmFtZSI6ImhvYW5nZHYiLCJpYXQiOjE1NjAzNTIyOTAsImV4cCI6MTU2MDM1MzE5MH0.xyna1mYwRbRo-Jdo_ZiwjnOJHpSscHrg93ZbIDawNfo'

Kết quả, thông tin các nhân của mình(kèm theo một vài thông tin của mã jwt):

{
    "email": "[email protected]",
    "name": "hoangdv",
    "iat": 1560352290,
    "exp": 1560353190
}

Có được thông tin này vì ở bước middleware, sau khi xác thực và giải mã token, mình đã gán thông tin giải mã được vào đối tượng req, ở trong route profile chỉ cần lấy thông tin đó ra trả về.

Nếu mình request POST /api/profile mà không có thông tin token thì sẽ nhận được http status là 403, kèm theo tin nhắn:

{
    "message": "No token provided."
}

nếu mã token không đúng hoặc bị hết hạn, http status là 401, kèm theo tin nhắn:

{
    "message": "Unauthorized access."
}

Rồi, khi token bị hết hạn (các bạn có thể để thời gian sống của token ngắn một chút để có thể test dễ dàng), chúng ta sẽ gọi API sử dụng Refresh token để lấy một mã token mới:

curl -X POST \
  http://localhost:3000/api/refresh_token \
  -H 'Content-Type: application/json' \
  -d '{
    "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImhvYW5nLmR2QG91dGxvb2suY29tIiwibmFtZSI6ImhvYW5nZHYiLCJpYXQiOjE1NjAzNTMyNjMsImV4cCI6MTU2MDQzOTY2M30.-TUGYfNdrYq8wphEsOzZXQvolgyOS88bvqGEAtieejM"
}'

kết quả, chúng ta nhận được một mã token mới hợp lệ:

{
    "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImhvYW5nLmR2QG91dGxvb2suY29tIiwibmFtZSI6ImhvYW5nZHYiLCJpYXQiOjE1NjAzNTMyNjMsImV4cCI6MTU2MDQzOTY2M30.-TUGYfNdrYq8wphEsOzZXQvolgyOS88bvqGEAtieejM"
}

Kết luận

Xây dựng một ứng dụng web bằng Nodejs thì việc phải đảm bảo các cơ chế xác thực người dùng là không thể thiếu. Bạn nên xây dựng kèm cơ chế làm mới mã xác thực token, việc này giúp cho ứng dụng phía client có thể hoạt động một cách liền mạch.

Mình đã trình bày những điều cơ bản nhất, hy vọng bạn có thể vận dụng được gì đó trong dự án của mình.

Toàn bộ code trong bài viết: Gist