Authentication và Authorization cùng với NodeJS và JWT

 Dec-28-2020 03:35 PM
#news

Giới thiệu về JWT

JSON Web Token (JWT) là một tiêu chuẩn (RFC 7519) để truyền thông tin giữa các điểm dưới dạng object JSON. Nó được sử dụng để xác định rằng dữ liệu được truyền đi luôn bởi 1 nguồn xác thực đáng tin cậy.

Phân quyền người dùng là một ứng dụng tiêu biểu sử dụng JWT. Một khi người dùng đã đăng nhập vào một hệ thống nào đó, từ các request sau, mỗi một request sẽ được gắn kèm thêm một chuỗi JWT, giúp cho hệ thống của thể cho phép người dùng thực hiện các hành động truy cập (các đường dẫn, các service hoặc tài nguyên, ... ).

Việc sử dụng JWT sẽ có các ưu điểm như sau:

  1. Các HTTP request là hoàn toàn không trạng thái, khi hệ thống lớn dần và số lượng người dùng ngày càng tăng, chúng ta không cần mở rộng server hoặc thêm tài nguyên để phục vụ cho việc lưu trữ session
  2. Các service có thể chia sẻ JWT một cách dễ dàng

Để hiểu rõ hơn về JWT và session, các bạn có thể đọc bài viết này của mình nhé !

Cài đặt

Chúng ta sẽ cài đặt package như sau :

npm install jsonwebtoken --save

Trong bài viết này, mình sẽ xây dựng 2 API, đó là API đăng nhập - API này sẽ trả về một mã token, và API thay đổi password - API này cần một token hợp lệ để có thể thực hiện. Toàn bộ source code mình để ở link github này để cho các bạn tiện theo dõi nhé 😄

Đầu tiên thì đây sẽ là đoạn code thực hiện việc cập nhật password:

export const updateUserPassword = (db, userName,pwd) => {
  return db.collection('user').updateOne({'username': userName }, {
    $set: {password:pwd} 
  })
  .then((r) => {
    return Promise.resolve(r.matchedCount);
  })
  .catch((err) => {
    return Promise.reject(err);
  })
}

Toàn bộ file services/UserService.js của chúng ta sẽ như thế này :

export const getUserDetails = (db, userName) => {
   return new Promise((resolve, reject) =>
      db.collection('user')
        .find({ 'username': userName })
        .toArray((err, docs) => {
           if(docs && docs.length>0){
              resolve(docs[0]);
           }else{
              reject();
           }
      });
   });
}
export const updateUserPassword = (db, userName,pwd) => {
  return db.collection('user').updateOne({'username': userName }, {
    $set: {password:pwd} 
  })
  .then((r) => {
    return Promise.resolve(r.matchedCount);
  })
  .catch((err) => {
    return Promise.reject(err);
  })
}

Sau đó trong file common/authUtils.js chúng ta sẽ cài đặt đoạn code sau :

import jwt from 'jsonwebtoken';
const newSessionRoutes = [{ path: '/user/login', method: 'POST' }];
const authRoutes = [{ path: '/user/password', method: 'PUT' }];
const SECRET_KEY = "JWT_SECRET";

Trước hết chúng ta sẽ import module jsonwebtoken và khai báo 3 biến constants, 2 biến đầu tiên là các API methods và đường dẫn path của chúng, biến thứ 3 chính là secret key của JWT dùng để encrypt ra dữ liệu. Sau đó chúng ta sẽ cài đặt các hàm dưới đây. để kiểm tra xem API có cần một token mới hay không :

export const isNewSessionRequired = (httpMethod, url) => {
  for (let routeObj of newSessionRoutes) {
    if (routeObj.method === httpMethod && routeObj.path === url) {
      return true;
    }
  }
return false;
}
export const isAuthRequired = (httpMethod, url) => {
  for (let routeObj of authRoutes) {
    if (routeObj.method === httpMethod && routeObj.path === url) {
      return true;
    }
  }
  return false;
}

Bên cạnh đó, để tạo ra một chuỗi JWT token từ dữ liệu của người dùng và xác nhận xem chuỗi token này có hợp lệ hay không, chúng ta sẽ cài đặt 2 hàm generateJWTToken và verifyToken :

export const generateJWTToken = (userData) =>{
   return jwt.sign(userData, SECRET_KEY);
}
export const verifyToken = (jwtToken) =>{
   try{
      return jwt.verify(jwtToken, SECRET_KEY);
   }catch(e){
      console.log('e:',e);
      return null;
   }
}

Cuối cùng thì file authUtils.js của chúng ta sẽ như sau :

import {getClientDetails} from '../services/ClientService';
import jwt from 'jsonwebtoken';
const newSessionRoutes = [{ path: '/user/login', method: 'POST' }];
const authRoutes = [{ path: '/user/password', method: 'PUT' }];
const SECRET_KEY = "JWT_SECRET";
export const clientApiKeyValidation = async (req,res,next) => {
   let clientApiKey = req.get('api_key');
if(!clientApiKey){
      return res.status(400).send({
         status:false,
         response:"Missing Api Key"
      });
   }
try {
      let clientDetails = await getClientDetails(req.db, clientApiKey);
      if (clientDetails) {
         next();
      }
   } catch (e) {
      console.log('%%%%%%%% error :', e);
      return res.status(400).send({
         status: false,
         response: "Invalid Api Key"
      });
   }
}
export const isNewSessionRequired = (httpMethod, url) => {
  for (let routeObj of newSessionRoutes) {
    if (routeObj.method === httpMethod && routeObj.path === url) {
      return true;
    }
  }
return false;
}
export const isAuthRequired = (httpMethod, url) => {
  for (let routeObj of authRoutes) {
    if (routeObj.method === httpMethod && routeObj.path === url) {
      return true;
    }
  }
  return false;
}
export const generateJWTToken = (userData) =>{
   return jwt.sign(userData, SECRET_KEY);
}
export const verifyToken = (jwtToken) =>{
   try{
      return jwt.verify(jwtToken, SECRET_KEY);
   }catch(e){
      console.log('e:',e);
      return null;
   }
}

Trong file app.js, chúng ta sẽ có đoạn code sau để thực hiện việc xác thực token trong mỗi request (nếu request đó cần token để thực hiện )

app.use(async (req, res, next) => {
  var apiUrl = req.originalUrl;
  var httpMethod = req.method;
  req.session = {};

  if (isNewSessionRequired(httpMethod, apiUrl)) {
    req.newSessionRequired = true;
  } else if (isAuthRequired(httpMethod, apiUrl)) {
    let authHeader = req.header('Authorization');
    let sessionID = authHeader.split(' ')[1];
    if (sessionID) {
      let userData = verifyToken(sessionID);
      if (userData) {
        req.session.userData = userData;
        req.session.sessionID = sessionID;
      }
      else {
        return res.status(401).send({
          ok: false,
          error: {
            reason: "Invalid Sessiontoken",
            code: 401
          }
        });
      }
    } else {
      return res.status(401).send({
        ok: false,
        error: {
          reason: "Missing Sessiontoken",
          code: 401
        }
      });
    }
  }
  next();
})

Đoạn code trên sẽ thực hiện công việc như sau: đầu tiên sẽ kiểm tra xem API có cần token để thực hiện hay không. Trong trường hợp này, chúng ta sẽ set một trường kiểu boolean, biến này sẽ được sử dụng để thực hiện các response sau này.

Một trường hợp khác đó là, chúng ta giả sử request sẽ được đính kèm một Bearer Token header như sau:

let authHeader = req.header('Authorization');
    let sessionID = authHeader.split(' ')[1];
if (sessionID) {
      let userData = verifyToken(sessionID);
      if (userData) {
        req.session.userData = userData;
        req.session.sessionID = sessionID;
      }
      else {
        return res.status(401).send({
          ok: false,
          error: {
            reason: "Invalid Sessiontoken",
            code: 401
          }
        });
      }
}

Chúng ta sẽ truyển đoạn mã JWT vào trong hàm verifyToken đã khai báo từ trước. Nếu việc verify thực hiện thành công, và trả về userData, chúng ta sẽ thực hiện set trường userData của session: req.session.userData = userData;. Còn nếu không, chúng ta sẽ gửi một lỗi Invalid sesstion-token.

Sau khi cài đặt các bước trên, chúng ta sẽ tiến hành cài đặt việc trả response của các API :

app.use((req, res, next) => {
  if (!res.data) {
    return res.status(404).send({
      status: false,
      error: {
        reason: "Invalid Endpoint",
        code: 404
      }
    });
  }
  if(req.newSessionRequired && req.session.userData){
    try{
      res.setHeader('session-token',
generateJWTToken(req.session.userData));
      res.data['session-token'] = generateJWTToken(req.session.userData);
    }catch(e){
      console.log('e:',e);
    }
  }

  if (req.session && req.session.sessionID) {
    try {
      res.setHeader('session-token', req.session.sessionID);
      res.data['session-token'] = req.session.sessionID;
    } catch (e) {
      console.log('Error ->:', e);
    }
  }
  res.status(res.statusCode || 200)
    .send({ status: true, response:res.data });
})

Trong trường hợp cần một session mới, chúng ta sẽ sinh một đoạn mã JWT Token và gửi lại đoãn mã này qua API response. Còn trong trường hợp các session token hợp lệ, chúng ta sẽ gửi lại session token tương tự qua response.

Tổng hợp lại thì file app.js sẽ như sau :

import express from 'express';
import bodyParser from 'body-parser';
import user from './routes/user';
import {MongoClient} from 'mongodb';
import { clientApiKeyValidation, isNewSessionRequired, isAuthRequired, generateJWTToken, verifyToken } from './common/authUtils';
const CONN_URL = 'mongodb://localhost:27017';
let mongoClient = null;
MongoClient.connect(CONN_URL,{ useNewUrlParser: true }, function (err, client) {
   mongoClient = client;
})
let redisClient = null;
redisClient = redis.createClient({
  prefix: 'node-sess:',
  host: 'localhost'
});
let app = express();
// parse application/x-www-form-urlencoded
app.use(bodyParser.urlencoded({ extended: false }));
// parse application/json
app.use(bodyParser.json());
app.use((req,res,next)=>{
   req.db = mongoClient.db('test');
   next();
})
app.get('/',(req,res,next)=>{
    res.status(200).send({
      status:true,
      response:'Hello World!'
    });
});
app.use(clientApiKeyValidation);
app.use(async (req, res, next) => {
  var apiUrl = req.originalUrl;
  var httpMethod = req.method;
  req.session = {};

  if (isNewSessionRequired(httpMethod, apiUrl)) {
    req.newSessionRequired = true;
  } else if (isAuthRequired(httpMethod, apiUrl)) {
    let authHeader = req.header('Authorization');
    let sessionID = authHeader.split(' ')[1];
if (sessionID) {
      let userData = verifyToken(sessionID);
      if (userData) {
        req.session.userData = userData;
        req.session.sessionID = sessionID;
      }
      else {
        return res.status(401).send({
          ok: false,
          error: {
            reason: "Invalid Sessiontoken",
            code: 401
          }
        });
      }
    } else {
      return res.status(401).send({
        ok: false,
        error: {
          reason: "Missing Sessiontoken",
          code: 401
        }
      });
    }
  }
  next();
})
app.use('/user',user);
app.use((req, res, next) => {
  if (!res.data) {
    return res.status(404).send({
      status: false,
      error: {
        reason: "Invalid Endpoint",
        code: 404
      }
    });
  }
if(req.newSessionRequired && req.session.userData){
    try{
      res.setHeader('session-token',
generateJWTToken(req.session.userData));
      res.data['session-token'] = generateJWTToken(req.session.userData);
    }catch(e){
      console.log('e:',e);
    }
  }

  if (req.session && req.session.sessionID) {
    try {
      res.setHeader('session-token', req.session.sessionID);
      res.data['session-token'] = req.session.sessionID;
    } catch (e) {
      console.log('Error ->:', e);
    }
  }
res.status(res.statusCode || 200)
    .send({ status: true, response:res.data });
})
app.listen(30006,()=>{
   console.log(' ********** : running on 30006');
})
process.on('exit', (code) => {
   mongoClient.close();
   console.log(`About to exit with code: ${code}`);
});
process.on('SIGINT', function() {
   console.log("Caught interrupt signal");
   process.exit();
});
module.exports = app;

Bây giờ chúng ta sẽ thực hiện các route cho API đăng nhập và thay đổi password. Các đoạn code này được đặt trong file routes/user.js. Dưới đây sẽ là đoạn code của API đăng nhập :

router.post('/login', async (req, res, next) => {
  let uname = req.body.username;
  let pwd = req.body.password;
  let userDetails = await getUserDetails(req.db, uname);
  if (userDetails) {
    let { password } = userDetails;
    if (pwd === password) {
      res.data = userDetails;
      req.session.userData = userDetails;
    } else {
      res.statusCode = 400;
      res.data = {
        status: false,
        error: 'Invalid Password'
      };
    }
  } else {
    res.statusCode = 400;
    res.data = {
      status: false,
      error: 'Invalid Username'
    };
  }
  next();
});

Trong đoạn code trên, chúng ta thực hiện lấy thông tin người dùng từ database dựa vào trường username. Nếu không tìm thấy dữ liệu, response sẽ trả về một lỗi invalide username. Còn nếu được dữ liệu, chúng ta sẽ tiến hành kiểm tra password.

Trong trường hợp password không trùng với database, chúng ta sẽ gửi mỗi lỗi về client. Còn nếu mật khẩu trùng khớp, chúng ta sẽ gửi userDetails qua response đồng thời cũng cập nhật session: req.session.userData = userDetails. Phần dữ liệu này cũng sẽ được sử dụng để sinh một mã JWT Token mới.

Bây giờ hãy xem đoạn code dưới đây :

router.put('/password', async (req, res, next) => {
  try {
    let oldPwd = req.body.old_password;
    let newPwd = req.body.new_password;
    if (!oldPwd && !newPwd) {
      res.statusCode = 400;
      res.data = {
        status: false,
        error: 'Invalid Parameters'
      }
    }
    let uname = req.session.userData.username;
    let userDetails = await getUserDetails(req.db, uname);
    if (oldPwd !== userDetails.password) {
      res.statusCode = 400;
      res.data = {
        status: false,
        error: "Old Password doesn't match"
      }
    } else {
      let updateRes = await updateUserPassword(req.db,uname,newPwd)
      res.data = { message :"Password updated successfully"};
    }  
    next();
  } catch (e) {
    next(e)
  }
})

Đoạn code trên có chức năng là thực hiện API cập nhật mật khẩu. Đây cũng là đoạn code thú vị nhất trong cả project này của mình 😄. Có một điều đặc biết ở đây đó là params của request không hề chứa một thông tin cụ thể nào của người dùng (username, các thông tin cá nhân ). Tuy nhiên chúng ta có thể thực hiện lấy các thông tin này qua session:

let uname = req.session.userData.username;

Sau khi hoàn thành việc cài đặt, chúng ta sẽ chạy file index.js. Sau đó sẽ thực hiện gọi API bằng Postman như sau:

Chúng ta set trường session-token cho cả header và body của response:

Bây giờ chúng ta sẽ thử gọi API thay đổi mật khẩu nhé, ban đầu nếu không có token đính kèm thì tất nhiên response sẽ trả về lỗi Invalid token rồi 😄

Để API trả về như mong muốn, chúng ta copy phần mã token mà chúng ta nhận được trong response của API đăng nhập vào phần header của API này nhé :

Và response trả về sẽ là :

Vậy là chúng ta đã cài đặt thành công một ứng dụng nhỏ sử dụng xác thực và phân quyền rồi 😄 Hẹn các bạn trong các bài viết tiếp theo nhé !

Lê Việt Hoàng

Hỗ trợ trực tuyến
Online Offline
Hỗ trợ trực tuyến