JWT: Xác thực người dùng trong Rest API (phần 3)

0
287
views
jwt

Xác thực người dùng trong Rest API dùng JWT

1. Mã hóa mật khẩu trước khi lưu

bài trước ta đã cho phép tạo mới người dùng với các trường như name, username, email … nhưng có một vấn đề là trường password đang được lưu ở dạng plain text. Tức là nếu database bị chiếm quyền điều khiển thì password của người dùng sẽ bị đọc được một cách dễ dàng. Do đó ta phải mã hóa mật khẩu trước khi lưu vào DB bằng phương pháp salted-SHA. SHA là thuật toán “băm” dữ liệu đầu vào và cho ra một đoạn mã hex với độ dài cố định (160 bit). Tuy nhiên phương pháp này có một vấn đề là một mật khẩu sẽ cho ra một mã hóa, có thể bị hack bằng phương pháp rainbow table. Phương pháp salted-SHA khác ở chỗ mỗi lần mã hóa một đoạn ký tự ngẫu nhiên sẽ được tham gia vào qúa trình mã hóa. Do đó, cùng một mật khẩu nhưng không có đoạn mã hóa nào giống nhau.

Để làm được điều đó, ta tạo thư mục services trong thư mục src, ta có cấu trúc thư mục như sau :

restify_part3/
|__src/
|   |_models/
|   |_controllers/
|   |_repositories/
|   |_routes/
|   |_services
|
|__index.js
|__package.json

Tạo file hashService.js trong thư mục services có nội dung như sau:

'use strict';

const crypto = require('crypto');

/**
 * generates random string of characters i.e salt
 * @function
 * @param {number} length - Length of the random string.
 */
function genRandomString(length) {
  return crypto.randomBytes(Math.ceil(length/2))
    .toString('hex') /** convert to hexadecimal format */
    .slice(0,length);   /** return required number of characters */
}

/**
 * hash password with sha512.
 * @function
 * @param {string} password - List of required fields.
 * @param {string} salt - Data to be validated.
 */
function sha512(password, salt){
  const hash = crypto.createHmac('sha512', salt); /** Hashing algorithm sha512 */
  hash.update(password);
  const value = hash.digest('hex');

  return {
    salt:salt,
    passwordHash:value
  };
}

/**
 *
 * @param userpassword
 * @param salt
 *
 * @returns {{salt, passwordHash}}
 */
function saltHashPassword(userpassword, salt) {
  if (!salt) {
    salt = genRandomString(32); /** Gives us salt of length 16 */
  }

  return sha512(userpassword, salt);
}

module.exports = {
  saltHashPassword: saltHashPassword,
  genRandomString: genRandomString
};

hàm genRandomString tạo ra một đoạn text ngẫu nhiên dạng hex với độ dài định trước. Hàm sha512 tạo mã băm theo thuật toán SHA-512 với đầu vào là password dạng plain text và salt ngẫu nhiên, đầu ra là đoạn mã băm dạng hex và giá trị salt được sử dụng. Giá trị salt này cũng được lưu cùng với password trong một bản ghi.

Sửa hàm create trong file userController.js với nội dung sau :

const HashService = require('../services/hashService');
const errors = require('restify-errors');

/**
 * @param req
 * @param res
 * @param next
 */
function create(req, res, next) {
  let data = req.body || {};

  if (!data.password || !data.email) {
      return next(new errors.InvalidContentError('"password" và "email" không được để trống!'));
  }

  //encrypt plain password
  let hashed = HashService.saltHashPassword(data.password);
  data.password = .passwordHash;
  data.salt = hashed.salt;

  UserRepository.save(data)
    .then(function (user) {
        res.send(201, user);
        next();
    })
    .catch(function (error) {
        console.error(error);
        return next(error);
    })
    .done();
}

Ở đây ta đã kiểm tra 2 trường bắt buộc là emailpasword, đây cũng là 2 trường điền vào trong trang Login. Nếu một trong 1 trường vắng mặt thì một lỗi sẽ được trả về client. Tiếp đó mật khẩu plain text mà người dùng truyền lên trong quá trình Register sẽ được thay thế bằng mật khẩu đã được mã hóa, đồng thời giá trị salt cũng được lưu kèm theo.

2. Xác thực người dùng bằng JWT

Hiện tại bất cứ ai khi gọi vào route /api/v1/users đều có thể lấy về danh sách tất cả user, bao gồm cả name, email, password. Do đó ta cần áp dụng một cơ chế để hạn chế khả năng truy cập tự do này. Một phương pháp phổ biến nhất đó là dùng Json Web Token (JWT).

Theo đó, mỗi một request gửi từ client đều phải chứa một token trong headers, token này được tạo ra bằng cách cung cấp usernamepassword. Token này thường được gửi là nội dung của header key Authorization. Dữ liệu dùng để tạo ra JWT thường bao gồm name, username, email, ip, expire date … bạn có thể cho thêm các trường khác tùy vào nhu cầu.

Trước khi xử lý request, token này sẽ được server chiết tách, giải mã, kiếm tra các thông tin, ngày hết hạn… Nếu token hết hạn thì người dùng phải quay lại trang login để thực hiện yêu cầu tạo mới một token phục vụ cho các request tiếp theo. Rất may mắn tất cả các thao tác trên đã được đóng gói thành một thư viện tên là : jsonwebtoken

JWT được tạo ra dựa trên một cặp public key và private key nên đầu tiên chúng ta phải tạo ra cặp key này đã.

$ ssh-keygen -t rsa -b 4096 -f private.pem
$ openssl rsa -in private.pem -pubout -outform PEM -out public.pem    

vậy là ta đã có một cặp public key – private key (public.pem/private.pem) để dùng rồi. Download thư viện

$ npm install jsonwebtoken --save

Tạo file authorizationService.js trong thư mục services với nội dung như sau :

"use strict";

const jwt = require('jsonwebtoken');
const fs = require('fs');

/**
 *
 * @param req
 * @returns {null}
 */
function getUser(req) {
  let authorization = req.header('Authorization');
  if (!authorization) {
    return null;
  }

  let token = authorization.split(' ')[1];
  if (!token) {
    return null;
  }

  let publicKey = fs.readFileSync('./public.pem');

  return jwt.verify(token, publicKey, function (error, user) {
    if (error) {
        return null;
    }

    return user;
  });
}

module.exports = {
  getUser: getUser,
};

Hàm getUser trích xuất token từ header và tiến thành verify token, nếu token không được tạo ra bởi private key trong cùng một cặp hoặc token đã hết hạn thì hàm sẽ trả về null. Ví dụ ta thực hiện hạn chế ở route /api/v1/users get list. Sửa hàm list trong file userController.js như sau :

const AuthorizationService = require('../services/authorizationService');
const errors = require('restify-errors');

/**
 * @param req
 * @param res
 * @param next
 */
function list(req, res, next) {
  let user = AuthorizationService.getUser(req);

  if(!user) {
    return next(
        new errors.UnauthorizedError("No token provided or token expired !")
    );
  }

  UserRepository
    .getList()
    .then(function (users) {
        res.send(users);
        next();
    })
    .catch(function (error) {
        console.error(error);
        return next(error);
    })
    .done();
}

Tuy nhiên, nếu có 1000 route cần bảo vệ thì ta phải thêm 1000 đoạn code trên ? Đó chính là lúc phải dùng đến middleware. Middleware thực chất là các hàm được gọi trước khi các request thực sự được xử lý, chúng ta có thể xử lý business ở bước này. Nếu các request không đáp ứng được nhu cầu sẽ bị drop và gửi trả lại mã lỗi cho client. Tạo thêm thư mục middlewares trong thư mục src, tạo file dropUnauthorizedRequest.js với nội dung như sau :

"use strict";

const errors = require('restify-errors');
const AuthorizationService = require('../services/authorizationService');
const unless = require('express-unless');

const middleware = function(req, res, next) {
  let user = AuthorizationService.getUser(req);

  if(!user) {
    return next(
        new errors.UnauthorizedError("No token provided or token expired !")
    );
  }

  req.user = user;
  next()
};

middleware.unless = unless;

module.exports = middleware;

unless là một tiện ích nhỏ của express dùng để đặt điều kiện khi nào thì áp dụng middleware lên một route nào đó. Mặc định middleware sẽ áp dụng lên mọi route trừ khi route đó nằm trong một danh sách cho trước, chính là unless. Để sử dụng middleware mới viết ta sửa file index.js như sau :

const dropUnauthorizedRequest = require('./middlewares/dropUnauthorizedRequest');
const unless = require('express-unless');

server.use(dropUnauthorizedRequest.unless({ path: ['/api/v1/todos'] }));

Như vậy tất cả các route đều cần phải gửi kèm theo token trừ route /api/v1/todos (bao gồm cả GET, POST, PUT …). Bước tiếp theo là phải tạo thêm một route dành cho việc getToken từ usernamepassword. Tạo file authenRouter.js trong thư mục routes với nội dung như sau :

const HashService = require('../services/hashService');
const UserRepository  = require('../repositories/userRepository');
const jwt = require('jsonwebtoken');
const fs = require('fs');

module.exports = function (server) {

  /**
   * @method POST
   * Provided registered credential then get back a token for further access.
   */
   server.post('/api/v1/getToken', (req, res, next) => {
      UserRepository.findByUsernameOrEmail(req.body.username)
        .then(function (user) {
            let hashed = HashService.saltHashPassword(req.body.password, user.salt);
            if (hashed.passwordHash === user.password) {
                const payload = {
                    id: user._id,
                    username: user.username,
                    email: user.email,
                    name: user.name,
                    photo: user.photo
                };

                let privateKey = fs.readFileSync('../private.pem');
                jwt.sign(payload, privateKey, {
                    expiresIn: '1d',
                    algorithm: 'RS256'
                }, function (err, token) {
                    if (err) {
                        console.log(err);
                        return next(err);
                    } else {
                        // return the information including token as JSON
                        res.send({
                            id: user._id,
                            username: user.username,
                            token: token,
                            name: user.name,
                            email: user.email,
                            photo: user.photo
                        });
                        next();
                    }
                });

            } else {
                return next(new errors.InvalidCredentialsError('Invalid Credentials'));
            }
        })
        .catch(function (error) {
            console.error(error);
            return next(new errors.InvalidCredentialsError('Invalid Credentials'));
        })
        .done();
  });
}

Thêm hàm findByUsernameOrEmail trong file userRepository.js :

/**
 *
 * @param usernameOrEmail
 * @returns {*|promise}
 */
function findByUsernameOrEmail(usernameOrEmail) {
  const deferred = Q.defer();

  User
    .findOne({$or: [{username: usernameOrEmail}, {email: usernameOrEmail}]})
    .exec(function (err, user) {
        if (err) {
            console.error(err);
            deferred.reject(new errors.InvalidContentError(err.message));
        } else if (!user) {
            deferred.reject(new errors.ResourceNotFoundError('The resource you requested could not be found.'));
        } else {
            deferred.resolve(user);
        }
    });

  return deferred.promise;
}

Kết nối authenRouter trong file index.js :

const AuthenRouter = require('./routes/authenRouter');

server.listen(8080, function() {
  // establish connection to mongodb
  mongoose.Promise = global.Promise;
  mongoose.connect('mongodb://localhost');

  const db = mongoose.connection;
  db.on('error', function (err) {
    process.exit(1);
  });

  db.once('open', function () {
    UserRouter(server);
    TodoRouter(server);
    AuthenRouter(server);
  });

  console.log('%s listening at %s', server.name, server.url);
});

Trước khi đăng nhập thì người dùng phải gọi đến route /api/v1/getToken nên chúng ta phải cho route này vào trong danh sách public, tức là danh sách có thể gọi mà không cần cung cấp token. Sửa file index.js như sau :

const dropUnauthorizedRequest = require('./middlewares/dropUnauthorizedRequest');
const unless = require('express-unless');

server.use(dropUnauthorizedRequest.unless({ path: ['/api/v1/todos', '/api/v1/getToken'] }));

Dùng Rest Client để get token jwt-get-token

Để ý chỗ header Authorization, giá trị ở đây là Bearer + [space] + token. Bearer ở đây là từ khóa bắt buộc còn token chính là kết quả trả về khi gọi route /api/v1/getToken.

Toàn bộ source code của bài này được lưu ở đây

Tiếp theo: NodeJs Event: ý nghĩa và cách sử dụng

BÌNH LUẬN

Please enter your comment!
Please enter your name here