Token และ JWT คืออะไร? + ทำ JWT Authentication ด้วย Hapi.js

Published on
NodeJS
2016/07/understanding-token-and-jwt-create-authentication-with-hapijs
Discord

เนื่องจากบทความนี้ ไม่ได้อัพเดทเนื้อหาที่เขียนไว้ตั้งแต่ปี 2016 แนะนำอ่านบทความล่าสุด ที่นี่แทนครับ JWT คืออะไร? + ลองทำ JWT Authentication ด้วย Express.js


เนื่องจากปัจจุบันได้หันมาพัฒนาเว็บไซต์ในรูปแบบ RESTFul API ซึ่งเป็น Web Server ในรูปแบบ stateless สำหรับเป็น API ทั้ง Single Page Application และ Mobile คือไม่มีการจดจำ state ของผู้ใช้แต่ว่าใช้ token base แทน ซึ่งปกติก็ใช้แต่ตัว jwt.io ของ Node.js ในการ sign, verify token แต่ไม่รู้ว่าส่วนประกอบของ token นั้นมันประกอบด้วยอะไรบ้าง วันนี้ก็เลยไปนั่งอ่านแล้วสรุปมาเป็นบทความครับ

Token คืออะไร?

เป็นรหัสชุดนึงที่เอาไว้สำหรับทดแทน session ซึ่งเอาไว้ระบุว่าคนๆนั้นคือใคร ตัวอย่างเช่น Facebook เมื่อล็อคอินเสร็จแล้วจะมี accessToken เพื่อระบุตัวตนว่าเป็นใคร ซึ่งตัว token เอามาใช้ในการทำ RESTFul API ทดแทนการทำ Web Server แบบเดิมๆ ที่เก็บในรูปแบบ session โดยตัว token จะถูกส่งไปทุกๆ request ผ่าน HTTP Headers

Authorization: Bearer <token>

JWT คืออะไร?

JWT ย่อมาจาก JSON Web Token เป็นรูปแบบหนึ่งที่ใช้ในการสร้างรหัส token จากข้อมูล JSON Data แล้วทำการเข้ารหัสด้วย Base64Url Encoded ซึ่งมีหน้าตาลักษณะประมาณนี้

eyJ0eXAiOiJKV1tiLCJhbGciOiJIUzI1Nis9.eyJpZCI6IjU3NzRhMjM1Zm
I1OTdlMWIxMWY3YzY3ZiIsImVtYWlsIjoiY2hhaUBuZXh0enkuY29tIiwic2
NvcGUiOiJVU0VSIiwiaWF0IjoxNDY3NzgzMTgyLCJleHAiOjE0Njc4Njk40
DJ9.CGXxDtTJD0LBpY7oTbm-ZWB1o6J7isu09ZNk1Q2uTc0

จะเห็นว่าตัวอย่าง JWT ด้านบน จะมีตัวจุด (.) ขั้นไว้ คือ

<base64url-encoded header>.<base64url-encoded payload>.<base64url-encoded signature>

ซึ่งทั้ง 3 ส่วนประกอบไปด้วย

  1. Header : (คือข้อมูล metadata ของ token ซึ่งบอกว่า เป็น type และใช้ algorithm อะไร)
  2. Body หรือ Payload หรือ Claims : ข้อมูลทั้งหมดที่เราเอาไว้ sign token
  3. Signature : ส่วนสำคัญของข้อมูล เป็นการรวมกันของ Header และ Body ใช้ algorithm และ secret key ในการ sign

Header ประกอบไปด้วย 2 ส่วนคือ type เป็น JWT และ hash algorithm ที่ใช้ เช่น HMAC SHA256 ตัวอย่าง

{
  "alg": "HS256",
  "typ": "JWT"
}

เมื่อนำข้อมูล header มาเข้ารหัสด้วย Base64Url encoded ก็จะได้ข้อมูลประมาณ

eyJ0eXAiOiJKV1tiLCJhbGciOiJIUzI1Nis9

Payload

Payload ประกอบไปด้วยข้อมูล information ของคนๆนั้น เช่น id, name มีทั้งที่เป็น Private หรือ Public Data ข้อมูล JSON ของ payload มีหน้าตาประมาณนี้

{
  "sub": "1",
  "name": "Chai Phonbopit",
  "role": "admin",
  "exp": 1402374336,
  "iat": 1402338336
}
  • sub: subject เอาไว้สำหรับ authenticate user (เช่น user Id)
  • exp: คือเวลาหมดอายุของ token
  • iat: Issued at timestamp บอกว่า token สร้างเมือไหร่

และเมื่อนำข้อมูล payload มาเข้ารหัสด้วย Base64Url encoded ก็จะได้ข้อมูลประมาณ

eyJpZCI6IjU3NzRhMjM1ZmI1OTdlMWIxMWY3YzY3ZiIsImVtYWlsIjoiY2hhaUBuZXh0enkuY29tIiwic2NvcGUiOiJVU0VSIiwiaWF0IjoxNDY3NzgzMTgyLCJleHAiOjE0Njc4Njk40DJ9

ไม่ควรเก็บข้อมูลที่เป็น Sensitive Data ไว้ใน token นะครับ เพราะสามารถดูข้อมูลข้างในได้ แม้จะไม่มี secret ก็ตาม

Signature

การจะสร้าง Signature ได้จำเป็นต้องใช้

  • header ที่ได้จากการ encoded แล้ว
  • payload ที่ได้จากการ encoded แล้ว
  • secret key ที่จะใช้
  • algorithm ที่จะใช้ เช่น HMACSHA256

ตัวอย่างของการ gen signature คือ

HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secretKey)

เมื่อรวมทั้งสามส่วนมารวมกัน คั่นด้วยเครื่องหมายจุด(.) ก็จะได้ข้อมูลเป็น token นั่นเอง

eyJ0eXAiOiJKV1tiLCJhbGciOiJIUzI1Nis9.eyJpZCI6IjU3NzRhM
jM1ZmI1OTdlMWIxMWY3YzY3ZiIsImVtYWlsIjoiY2hhaUBuZXh0enk
uY29tIiwic2NvcGUiOiJVU0VSIiwiaWF0IjoxNDY3NzgzMTgyLCJle
HAiOjE0Njc4Njk40DJ9.CGXxDtTJD0LBpY7oTbm-ZWB1o6J7isu09ZNk1Q2uTc0

สร้างโปรเจ็คด้วย Hapi.js

ทดลองทำระบบ Authentication API ง่ายๆด้วย

ไฟล์ package.json

{
  "name": "hapi-auth-jwt-example",
  "version": "1.0.0",
  "scripts": {
    "start": "node server.js",
    "dev": "nodemon server.js"
  },
  "dependencies": {
    "hapi": "^13.5.0",
    "hapi-auth-jwt2": "^7.0.1",
    "jsonwebtoken": "^7.0.1"
  }
}

สร้างไฟล์ server.js ด้วย Hapijs แบบง่ายๆ โดยมีแค่ 2 routes คือ

  • / : assume ว่าส่ง POST username, password และได้ token กลับมา
  • /me : ต้องแนบ token ส่งไปด้วย เพื่อเรียกดูข้อมูลของ user นั้นๆ
'use strict';
const Hapi = require('hapi');
const server = new Hapi.Server();

server.connection({
  host: 'localhost',
  port: 5000
});

server.route({
  method: 'GET',
  path: '/',
  handler: (request, reply) => {
    // Gen token
    reply({
      token: 'token'
    });
  }
});

server.route({
  method: 'GET',
  path: '/me',
  handler: (request, reply) => {
    // Decoding token
    reply(decoded);
  }
});

server.start(() => {
  console.log('Server is running');
});

สำหรับ hapi-auth-jwt2 เวลาใช้งาน เราต้องทำการ

  1. Register Plugin ด้วย server.register()
  2. กำหนด auth strategy เป็น jwt
  3. ทุก route กำหนด authenticated ผ่าน config

ทำการ register plugin

const HapiAuth = require('hapi-auth-jwt2');

let user = {
  id: 1,
  name: 'Chai Phonbopit'
};

server.register(HapiAuth, (err) => {
  if (err) {
    return reply(err);
  }

  server.auth.strategy('jwt', 'jwt', {
    key: 'mysecretKey',
    validateFunc: validate
  });
  server.auth.default('jwt');
});

function validate(decoded, request, callback) {
  // ตัวอย่างการ validate คร่าวๆ เฉยๆ
  // (ส่วนการใช้งานจริง ขึ้นอยู่กับ logic ของเพื่อนๆครับ)
  if (decoded.name === 'Chai Phonbopit') {
    return callback(null, true);
  } else {
    return callback(null, false);
  }
}

อธิบายโค๊ดด้านบนคือ

  • ตัว hapi-auth-jwt2 จะมี validateFunc เอาไว้สำหรับทำการ validate jwt ซึ่งค่าที่ได้จากการ validate จะอยู่ในตัวแปร decoded ในตัวอย่างคือเช็คว่า decoded.name นั้นเท่ากับ Chai Phonbopit หรือไม่ ถ้าใช่ก็ return callback(null, false) กลับไป แสดงว่า token ถูกต้อง

โดยปกติแล้ว ตรงส่วน validate จะเอาไว้เช็คว่า User นี้มีจริงหรือไม่ เช่น ใช้ mongoose User.find({_id: decoded.id}).exec() เป็นต้น

ต่อมาแก้ไขตรง route / เพื่อเวลาเข้าหน้านี้จะให้มันทำการส่ง token ที่ sign ด้วย user กลับมา เพื่อเอาไว้ใช้ในหน้า /me นั่นเอง

const JWT = require('jsonwebtoken');

server.route({
  method: 'GET',
  path: '/',
  config: {
    auth: false
  },
  handler: (request, reply) => {
    let token = JWT.sign(user, 'mysecretKey', {
      expiresIn: '7d'
    });

    reply({
      token: token
    });
  }
});

โค๊ดด้านบน เราทำการเพิ่ม

config: {
  auth: false;
}

เพราะว่า default เรากำหนดว่าให้ server.auth.default('jwt'); ทำให้ทุกๆ request จะเช็ค headers.authorization ทุกครั้ง เมือเรากดหนด auth: false path / ก็จะไม่ต้อง require token นั่นเอง

ต่อมา

let token = JWT.sign(user, 'mysecretKey', {
  expiresIn: '7d'
});

ทำการ generate token ด้วย JWT.sign(payload, secretKey, options) และส่ง token กลับไปให้ User

สุดท้ายของเข้า /me แบบไม่ได้แนบ token

curl -i http://localhost:5000/me


HTTP/1.1 401 Unauthorized
WWW-Authenticate: Token
content-type: application/json; charset=utf-8
cache-control: no-cache
content-length: 76
Date: Sat, 09 Jul 2016 08:48:54 GMT
Connection: keep-alive

{"statusCode":401,"error":"Unauthorized","message":"Missing authentication"}%

และเข้า /me โดยการแนบ token ก็จะสามารถ access หน้านั้นได้

curl -i -H "authorization: <TOKEN>" http://localhost:5000/me"
HTTP/1.1 200 OK
content-type: application/json; charset=utf-8
cache-control: no-cache
content-length: 66
accept-ranges: bytes
Date: Sat, 09 Jul 2016 08:50:12 GMT
Connection: keep-alive

{"id":1,"name":"Chai Phonbopit","iat":1468053778,"exp":1468658578}%

เป็นอันเรียบร้อย

References:

Buy Me A Coffee
Authors
Discord