Bài viết này sẽ hướng dẫn cơ bản về Node.js JWT Authentication With HTTP Only Cookie. Bao gồm logic đăng nhập và đăng xuất với ví dụ thực tế.

Hello anh em, như đã phân tích ở bài viết Có phải bạn thường lưu JWT Token sau khi login vào LocalStorage ? (sharetolearn.vercel.app) thì anh em đều đã biết cách bảo mật nhất để lưu jwt token là dùng cookie với flag httpOnly secure sameSite đúng không.

Vậy hôm nay mình sẽ hướng dẫn anh em triển khai setup một Nodejs Express app authentication với JWT và set httpOnly cookie nhé.


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


Sử dụng npm hoặc yarn để cài đặt các thư viện sau đây:


  1. express: để xây dựng ứng dụng Express.
  2. jsonwebtoken: để tạo và xác thực JWT.
  3. bcrypt: để mã hóa mật khẩu người dùng.
  4. cookie-parser: để đọc và gửi cookie httpOnly.
  5. mongoose: để tương tác với cơ sở dữ liệu MongoDB.
  6. mongoose-unique-validator
  7. cors: config CORS
  8. express-async-handler
  9. nodemon:: auto restart app when changes
  10. dotenv: read env file


Trước khi cài thư viện thì anh em tạo 1 folder ví dụ là jwt-authentication-http-only-cookie sau đó mở terminal gõ npm init để tạo file package.json cho app. Trong phần script thì anh em thêm câu lệnh "dev": "nodemon index.js" để chút nữa run app còn file index.js thì anh em sẽ tạo ở bước 2 sau.


Tiếp theo mình sẽ sử dụng npm để cài list thư viện trên:

npm install express jsonwebtoken bcrypt cookie-parser mongoose mongoose-unique-validator cors express-async-handler nodemon dotenv


Và đây sẽ là file package.json:


{
  "name": "jwt-authentication-http-only-cookie",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "dev": "nodemon index.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "gnutyud",
  "license": "ISC",
  "dependencies": {
    "bcrypt": "^5.1.0",
    "cookie-parser": "^1.4.6",
    "cors": "^2.8.5",
    "dotenv": "^16.0.3",
    "express": "^4.18.2",
    "express-async-handler": "^1.2.0",
    "jsonwebtoken": "^9.0.0",
    "mongoose": "^7.0.4",
    "mongoose-unique-validator": "^4.0.0",
    "nodemon": "^2.0.22"
  }
}


Bước 2: Thiết lập ứng dụng Express


Tạo một file index.js để thiết lập ứng dụng Express:

require("dotenv").config();
const express = require("express");
const app = express();
const path = require("path");
const cookieParser = require("cookie-parser");
const PORT = 5000;


// middlewares
app.use(express.json());
app.use(cookieParser());
app.use('/', express.static(path.join(__dirname, 'public')));


app.listen(PORT, () => {
  console.log(`Server is running on port ${PORT}`);
});


Tạo file .env cùng cấp với file index.js với các key như bên dưới còn value thì anh em tự fill nhé:


DATABASE_URI=
JWT_SECRET_TOKEN=
JWT_SECRET_REFRESH_TOKEN=
REFRESH_TOKEN_COOKIE_NAME=


Bây giờ anh em mở gõ npm run dev ở terminal nếu app run như thế này là ok nhé:



Bước 3: Tạo Model, Configs, helper folders


Trước hết anh em phải tạo User model và các config để connect đến mongoDb.

Ở root folder, tạo models/User.js và define các user field cũng như mình có thêm một số method để tiện sử dụng khi implement các API trả về user response như này:

const mongoose = require("mongoose");
const uniqueValidator = require("mongoose-unique-validator");
const jwt = require("jsonwebtoken");


const UserSchema = new mongoose.Schema({
  username: {
    type: String,
    required: true,
    unique: true,
    lowercase: true,
  },
  password: {
    type: String,
    required: true,
  },
  roles: {
    type: String,
    default: "user",
  },
  status: {
    type: String,
    default: "",
  },
  email: {
    type: String,
    required: true,
    lowercase: true,
    unique: true,
    match: [/\S+@\S+\.\S+/, "is invalid"],
    index: true,
  },
  avatar: {
    type: String,
    default: "",
  },
});


UserSchema.plugin(uniqueValidator);


// @desc generate access token for a user
// @required valid username and password
UserSchema.methods.generateAccessToken = function () {
  const accessToken = jwt.sign(
    {
      "user": {
        "id": this._id,
        "username": this.username,
        "email": this.email
      },
    },
    process.env.JWT_SECRET_TOKEN,
    { expiresIn: "20m" }
  );
  return accessToken;
};


UserSchema.methods.toUserResponse = function () {
  return {
    id: this._id,
    username: this.username,
    email: this.email,
    status: this.status,
    avatar: this.avatar,
    accessToken: this.generateAccessToken(),
  };
};


UserSchema.methods.toProfileJSON = function () {
  return {
    id: this._id,
    username: this.username,
    status: this.status,
    avatar: this.avatar,
    roles: this.roles,
  };
};


const User = mongoose.model("User", UserSchema);


module.exports = User;


Tiếp theo tạo folder configs chứa các file: dbConnect.js và corsOptions.js:

//dbConnect.js
const mongoose = require('mongoose');

const dbConnect = async () => {
    try {
        mongoose.set("strictQuery", false);
        await mongoose.connect(process.env.DATABASE_URI)
    } catch (error) {
        console.log('error', error)
    }
}

module.exports = dbConnect;


Như bài viết trước mình đã nói, nếu trong trường hợp BE và FE là không sameSite thì để tránh bị tấn công CSRF thì phía BE sẽ phải config CORS để tránh các site lạ có thể request lấy hay làm gì đó với data của mình.

Một lưu ý quan trọng là anh em phải set thuộc tính credentials = true để có thể sent được cookie nhé !!!


//corsOptions.js
const allowedOrigins = ["http://localhost:3000", "http://localhost:3001"]; //list url allow to access your database

const corsOptions = {
  origin: (origin, callback) => {
    if (allowedOrigins.indexOf(origin) !== -1 || !origin) {
      callback(null, true);
    } else {
      callback(new Error("Not allowed by CORS"));
    }
  },
  credentials: true,
  optionsSuccessStatus: 200,
};


module.exports = corsOptions;



Thực ra nếu chỉ để làm ví dụ thì mình có thể viết ngay các method API trong file `index.js` luôn nhưng mình vẫn sẽ chia rõ ràng các folder ra để trông nó clear hơn và sau này nếu có mở rộng app thì đỡ mất công refactor lại.


Tạo các function helper để sử dụng ở nhiều nơi:

Trong helper folder chúng ta sẽ tạo các file jwt.js (chứa các function về jwt như tạo token, verify token) và password.js (để hash pwd và verify pwd):

//jwt.js
const jwt = require('jsonwebtoken');

const createRefreshToken = async (user) => {
  return new Promise((resolve, reject) => {
    jwt.sign({
      "user": {
        "id": user._id,
        "username": user.username,
        "email": user.email
      },
    },
      process.env.JWT_SECRET_REFRESH_TOKEN,
      { expiresIn: "7d" }, (err, token) => {
        if (err) return reject(err);
        return resolve(token)
      })
  })
}


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

module.exports = { createRefreshToken, verifyJwtToken }
      


Dùng thư viện `bcrypt` để bảo vệ password:

//password.js

const bcrypt = require('bcrypt');
const SALT_ROUNDS = 10

module.exports.hashPassword = (password) => {
  return new Promise((resolve, reject) => {
    bcrypt.hash(password, SALT_ROUNDS, (err, encrypted) => {
      if (err) return reject(err);
      resolve(encrypted)
    })
  })
};

module.exports.matchPassword = (hash, password) => {
  return new Promise((resolve, reject) => {
    bcrypt.compare(password, hash, (err, result) => {
      if (err) return reject(err);
      resolve(result)
    })
  })
};


Bước 4: Config routing và controller API


Tạo folder controller để handle các API của app:

Mình sẽ tạo file controller/authController để handle các API authentication bao gồm: login, signup, refresh token và logout. Nó sẽ không khác gì so với cách anh em làm API thông thường chỉ khác duy nhất là với API login thì sau khi verify usernam, password xong thì trước khi trả về response data anh em sẽ làm thêm một bước đó là set cookie với các flag như:


  1. httpOnly = true : chỉ cho phép access bởi web server
  2. sameSite : nó có 2 option nhưng nếu BE và FE khác domain thì set là `none` còn không thì anh em có thể set là `lax` hoặc `scrict` nhưng bắt buộc anh em phải set field sameSite này thì mới set được cookie nhé. Nhiều anh em quên nên khi test phía client không thể sent cookie đi được.
  3. secure = true: chỉ cho phép https nên nếu anh em muốn test trên local hoặc postman thì phải set nó tạm là false nhé.
  4. path: để define chỉ path nào mới có thể được set cookie vào như ví dụ này mình để path = '/auth' thì chỉ những api bắt đầu là '/auth' thì mới cho phép set cookie.
  5. maxAge: là thời gian tồn tại của cookie


Với API logout thì chúng ta sẽ đi clear cookie đi, chú ý là các thuộc tính set cookie ở API login như thế nào thì khi clear mình phải để y chang như vậy nhé.

Ở đây API signup thì mình sẽ không set cookie vì mình muốn khi đăng ký tài khoản xong thì user sẽ phải back về trang login để đăng nhập. Nếu anh em nào muốn đăng ký xong thì coi như login luôn thì cũng sẽ thêm bước set cookie trước khi trả response giống như API login nhé.


Ok, dưới đây sẽ là code của auth controller:


//authController.js
const User = require("../models/User");
const { matchPassword, hashPassword } = require("../helpers/password");
const { createRefreshToken, verifyJwtToken } = require("../helpers/jwt");
const asyncHandler = require("express-async-handler");

// @desc Signup
// @route POST auth/signup
// @access Private
const signup = asyncHandler(async (req, res) => {
  const { username, password, roles, email } = req.body;
  //  check input data
  if (!username || !password || !email) {
    return res.status(400).json({ message: "All Fields are required!" });
  }
  // check duplicate
  const duplicate = await User.findOne({ username }).lean().exec();
  if (duplicate) {
    return res.status(409).json({ fieldError: "username", message: "username already exist" });
  }
  const duplicateEmail = await User.findOne({ email }).lean().exec();
  if (duplicateEmail) {
    return res.status(409).json({ fieldError: "email", message: "email already exist" });
  }
  // hash password
  const hashPwd = await hashPassword(password);


  let userObject = { username, password: hashPwd, email };
  if (roles) userObject = { ...userObject, roles };
  // Create and store new user
  const user = await User.create(userObject);
  if (user) {
    res.status(200).json({ user: user.toUserResponse() });
  } else {
    res.status(400).json({ message: "Invalid user data received" });
  }
});


// @desc Login
// @route POST /auth
// @access Public
const login = asyncHandler(async (req, res) => {
  const { username, password } = req.body;
  if (!username) throw new Error("Username is required!");
  if (!password) throw new Error("Password is required!");


  const foundUser = await User.findOne({ username }).exec();


  if (!foundUser) {
    return res.status(401).json({ fieldError: "username", message: "Incorrect username. Please try again." });
  }


  const matchPwd = await matchPassword(foundUser.password, password);


  if (!matchPwd) {
    return res.status(401).json({ fieldError: "password", message: "Incorrect password. Please try again!" });
  }


  const refreshToken = await createRefreshToken(foundUser);
  // Create secure cookie with refresh token
  let cookieName = process.env.REFRESH_TOKEN_COOKIE_NAME;
  res.cookie(cookieName, refreshToken, {
    httpOnly: true, //accessible only by web server
    secure: true, //https
    sameSite: "None", //cross-site cookie
    path: "/auth",
    maxAge: 7 * 24 * 60 * 60 * 1000, //cookie expiry: set to match rT
  });


  res.status(200).json({ user: foundUser.toUserResponse() });
});


// @desc Refresh
// @route GET /auth/refresh
// @access Public - because access token has expired
const refresh = async (req, res) => {
  const cookies = req.cookies;
  let cookieName = process.env.REFRESH_TOKEN_COOKIE_NAME;


  if (cookies && !cookies[cookieName]) {
    return res.status(401).json({ message: "Unauthorized" });
  }


  const refreshToken = cookies[cookieName];
  try {
    const refreshTokenDecoded = await verifyJwtToken(refreshToken, process.env.JWT_SECRET_REFRESH_TOKEN);


    if (!refreshTokenDecoded) {
      return res.status(401).json({ message: "Unauthorized" });
    }


    const foundUser = await User.findOne({ username: refreshTokenDecoded.user.username }).exec();


    if (!foundUser) return res.status(401).json({ message: "Unauthorized" });


    res.status(200).json({ user: foundUser.toUserResponse() });
  } catch (error) {
    return res.status(403).json({
      errors: { body: ["Forbidden", error.message] },
    });
  }
};


// @desc Logout
// @route POST /auth/logout
// @access Public - just to clear cookie if exists
const logout = (req, res) => {
  const cookies = req.cookies;
  let cookieName = process.env.REFRESH_TOKEN_COOKIE_NAME;


  if (cookies && !cookies[cookieName]) return res.sendStatus(204); //No content
  res.clearCookie(cookieName, {
    httpOnly: true, //accessible only by web server
    secure: true, //https
    sameSite: "None", //cross-site cookie
    path: "/auth",
  });
  res.json({ message: "Cookie cleared" });
};


module.exports = {
  login,
  signup,
  refresh,
  logout,
};



Sau khi đã handle các API rồi thì mình sẽ phải define các route cho app, nên mình sẽ tạo folder routes/authRoutes.js như sau:


const express = require("express");
const router = express.Router();
const authController = require("./../controllers/authController");

router.route("/").post(authController.login);
router.route("/signup").post(authController.signup);
router.route("/refresh").get(authController.refresh);
router.route("/logout").post(authController.logout);

module.exports = router;


Bây giờ chỉ cần import để app sử dụng các routes trên trong file `index.js` là xong:


//index.js
require("dotenv").config();
const express = require("express");
const app = express();
const path = require("path");
const cookieParser = require("cookie-parser");
const PORT = 5000;
const mongoose = require("mongoose");
const cors = require('cors');
const corsOptions = require('./configs/corsOptions');
const dbConnect = require("./configs/dbConnect");

dbConnect();

// middlewares
app.use(cors(corsOptions));
app.use(express.json());
app.use(cookieParser());
app.use('/', express.static(path.join(__dirname, 'public')));

// app routes
app.use('/auth', require('./routes/authRoutes'));

mongoose.connection.once("open", () => {
  app.listen(PORT, () => {
    console.log("connected to mongoose database");
    console.log(`server is running on port: ${PORT}`);
  });
});

mongoose.connection.on("error", (err) => {
  console.log(err);
});


Bước 5: Test thành quả thôi


Giờ anh em cùng mình test các API trên với postman nhé!


  • Test API signup và login trước: signup thì sẽ không set cookie còn Login thì check cookie sẽ có nhé.



  • Cuối cùng test API refresh token và logout: Với api refresh token thì anh em sẽ thấy chúng ta không cần phải set token vào header nữa, nếu đã gắn cookie vào rồi thì cứ thế call API thôi. Lưu ý như mình đã nói ở trên, muốn test với postman hay http localhost thì anh em nhớ phải bỏ flag secure ở cookie đi nhé. Còn API logout thì không có gì cả, nó sẽ clear cookie đi thôi. Nên sẽ không thể refresh token lại => phải login lại đó là flow của nó.



Tổng kết:

Như vậy là mình đã hướng dẫn xong cho anh em các bước thực hiện để làm JWT Authentication với httpOnly cookie trong Nodejs. Hi vọng anh em đều có thể thực hiện được dễ dàng.

Bài viết sau mình sẽ hướng dẫn anh em làm JWT Authentication trong client app cụ thể là React nhé!

Cảm ơn anhh em đã đọc bài viết này, nếu có thắc mắc gì hãy để lại comment bên dưới nha =))