Tuesday, January 19, 2021

ทำ Stateless Authentication บน Express ด้วย Passport.js + JWT

 

Image for post
  1. Setup API server อย่างง่ายด้วย Express.js
  2. Middleware คืออะไร?
  3. Stateless vs Stateful อย่างคร่าวๆ
  4. JWT คืออะไร?
  5. ทำ Authentication ด้วย JWT + Passport.js
    - สร้าง JWT ยังไง?
    - Passport.js คืออะไร? Strategy คืออะไร?
    - Applied JWT + Passport Authentication

ปล. ถ้าเข้าใจหัวข้อ 1–4 อยู่แล้วก้ข้ามไป 5 เลยก็ได้นะครับ!
ปล2. ถ้าหลุดส่วนไหน ท้ายสุดจะมีโค้ดสรุปให้อีกทีนึง หรือทักถามเข้ามาได้ครับ

ก่อนอื่นเลยไม่พูดพร่ำทำเพลง ทำการ Setup API server ขึ้นมาก่อน โดยในบทความนี้จะเลือกใช้ expressjs

1. Set up API server with Express.js

  • สำหรับคนไม่มี nodejs ให้ติดตั้งก่อนนะครับ*
  • สร้าง directory ใหม่สำหรับลอง follow บทความนี้นะครับ
  1. ติดตั้ง dependencies ที่เกี่ยวข้องด้วยคำสั่ง
    npm install express --save && npm install -g nodemon
  2. สร้างไฟล์ index.js ตามโครงสร้างด้านล่าง
//index.js
const express = require("express");
const app = express();
app.get("/", (req, res) => { //รอฟังการ GET มายัง endpoint "/"
res.send("Hello World"); //ตอบกลับ คืน "Hello World"
});
app.listen(3000); //บอกให้ server รอที่ port 3000
Image for post
ตัวอย่างการใช้ Postman ขอ HTTP GET / ไปยัง localhost:3000

3. ทดสอบด้วยการรันคำสั่ง nodemon index.js และใช้ Postman ในการทดสอบ เรียก GET ไปที่ localhost:3000/

.

.

.

2. Middleware คืออะไร

Image for post
รูปแบบการขอ HTTP GET / แบบไม่มี middleware ที่เราได้ทำไปข้างต้น

.

โดยปกติแล้วเนี่ย การติดต่อร้องขอ GET / ไปยังเซิร์ฟเวอร์ ก็หน้าตาประมาณรูปซ้ายมือ ไม่มีการคัดกรองอะไร ขอปุบ ได้ปับ

.

คราวนี้ลองนึกภาพว่า ถ้าข้อมูลตอบกลับเป็นข้อมูลที่มีความเป็นส่วนตัวระดับนึงเช่น การร้องขอ ยอดเงินคงเหลือ ของบัญชี นายบอย เราคงไม่อยากให้ใครที่ไม่ใช่นายบอยมาดูได้ เราจึงต้องใช้ “middleware” หรือตัวกลาง ที่จะมาคั่นการร้องขอนี้ และคัดกรองก่อนว่า จะอนุญาตให้คืนกลับไปไหม

Image for post
ตัวอย่างการคัดกรองด้วย middleware

เห็นรูปแล้วไปดูโค้ดกันดีกว่า ซึ่งการยืนยันตัวตนว่าเป็นนายบอยมั้ย เราจะทำการแนบสิ่งที่เรียกว่า Authorization Header มากับการร้องขอ

//index.js
const express = require("express");
const app = express();
const middleware = (req, res, next) => {
/* ตรวจสอบว่า authorization คือ Boy หรือไม่*/
if(req.headers.authorization === "Boy")
next(); //อนุญาตให้ไปฟังก์ชันถัดไป
else
res.send("ไม่อนุญาต")
};
app.get("/", middleware, (req, res) => { //เพิ่ม middleware ขั้นกลาง
res.send("ยอดเงินคงเหลือ 50");
});
app.listen(3000);
Image for post
ทดสอบเรียก เมื่อ Authorization มีค่าเป็น Man
Image for post
เมื่อ Authorization มีค่าเป็น Boy

.

เมื่อปรับค่า Authorization เป็น Boy ฟังก์ชัน middleware ที่เราเขียนขึ้นมาก็จะยอมเรียก next(); และเรียกฟังก์ชัน res.send(“ยอดเงินคงเหลือ 50”); กลับมา

.

.

3. Stateless vs Stateful อย่างคร่าวๆ

ก่อนที่จะไปถึงหัวข้อถัดไปเนี่ย ขอเกริ่นนิดนึงว่าทำไมถึงต้องใช้ JWT หรือการยืนยันตัวตนแบบที่สอนในบทความนี้

ถ้าแปลอย่างคร่าวๆ
Stateful หมายถึง “การเก็บสถานะ” ส่วน
Stateless หมายถึง “การไม่เก็บสถานะ”
แต่บทความนี่เราจะไม่ลงลึกถึงขั้นโค้ดมากนัก แต่จะอธิบายด้วยภาพอย่างคร่าวๆแทน เกี่ยวกับการยืนยันตัวตนของระบบ

Stateful หรือการที่ server แต่ละเครื่องมีการเก็บสถานะ หรือจดจำ
ยกตัวอย่างง่ายๆคือ เครื่อง 1 เครื่องจะเก็บตัวแปรว่า ผู้ใช้คนนี้เคยยืนยันตัวตนรึยัง ก่อนจะคืนข้อมูลที่ร้องขอกลับไป

Image for post
ตัวอย่าง stateful อย่างง่าย

ก็ดูดี และ่ายดายเหมือนไม่มีปัญหาอะไร ขั้นตอนการเขียนโค้ดจริงๆก็ง่ายกว่ามาก แต่จะมีปัญหาเมื่อเครื่องเซิร์ฟเวอร์ 1 เครื่องไม่สามารถรองรับการร้องขอข้อมูล ของผู้ใช้ที่เข้ามาจำนวนมากเกินไป เราจึงต้องขยายระบบ หรือ “Scale” ซึ่งเราจะยกตัวอย่างการทำ “horizontal scaling ด้วย load balancer” อย่างง่ายๆดังนี้

การวางระบด้านล่าง หมายถึงการที่มีหลายๆเครื่อง รองรับการ request โดย load balancer จะเป็นตัวที่กระจายงานอย่างทั่วถึง ให้ทุกๆ server

และเราจะสมมติให้การ ยืนยันตัวตน (username, password) ครั้งแรกไปยัง server 1 และทำการขอดูข้อมูลเงินคงเหลือ

Image for post
Authentication ครั้งแรก load balancer พาไปยัง server 1
Image for post
เมื่อ A ร้องขอข้อมูลคงเหลือ ในกรณีที่ load balancer จ่ายงานไปที่เครื่อง 1 ที่เคยยืนยันตัวตนแล้ว

ก็ดูไม่มีปัญหาอะไร แต่ถ้าเกิดเมื่อการจ่ายงานครั้งต่อไป ถูกส่งไปยังเครื่องอื่น

Image for post
เมื่อถูกร้องขอดูข้อมูลไปยังเครื่องอื่น ที่ยังไม่เคยยืนยันตัวตนมาก่อน จึงต้องยืนยันตัวตนใหม่

ผลลัพธ์คือ Server 2 นั้นไม่เคยจดจำว่านาย A มีสิทธิเข้าถึงข้อมูลมาก่อน ทำให้นาย A ต้องยืนยันตัวตนใหม่อีกครั้ง

แล้วเราจะแก้ปัญหาข้างต้นยังไงล่ะ? จริงๆแล้วก็มีหลายวิธีในการแก้ไขปัญหาด้านบน เช่น การให้ load balancer จดจำว่านาย A จะต้องถูกส่งไปเครื่อง server 1 เท่านั้นนะ แต่ผลก็คือ แบบนี้ workload ก็จะไม่สามารถกระจายแบบทั่วถึงได้อย่างมีประสิทธิภาพ

จึงมาถึงพระเอกของเรา หรือการทำ Stateless

แวะดูคอนเสปกันสักหน่อย

Image for post
การติดต่อกับ server แบบ stateless

หลักๆคือ client จะต้องแนบสิ่งที่เรียกว่า JWT มาทุกครั้งในการร้องขอ และ server จะทำการตรวจสอบ JWT ที่แนบมาทุกครั้งก่อนที่จะอนุญาตการเข้าถึงข้อมูล

มาถึงตรงนี้อาจจะ งงๆหน่อย ว่าเอ้า ต่างกับตอนแรกตรงไหน แล้ว JWT หน้าตาเป็นยังไง ไม่ต้องรีบไป ไปต่อที่หัวข้อหน้ากันเลย!

4. JWT คืออะไร

JWT ย่อมาจาก JSON Web Token หมายความว่าการเก็บข้อมูล JSON ไว้ใน token อันหนึ่ง ที่เป็นสายสตริงตัวอักษร ยาวๆ หน้าตาประมาณนี้

Image for post
ด้านซ้ายเป็นสายอักขระยาวที่เรียกว่า JWT ดูเพิ่มเติมได้ที่ https://jwt.io/

JWT จะประกอบด้วยกัน 3 ส่วน คั่นด้วย “.” <Header>.<Payload>.<Signature>

Header จะประกอบด้วยการบอกว่าข้อมูลข้างต้นถูกเข้ารหัสด้วย อัลกอริทึมอะไรและมีชนิดอะไร

Payload จะเป็นข้อมูลที่เราใช้เก็บ ที่ไม่สำคัญ ห้ามเก็บ password โดยตรงเพราะสามารถนำไปถอดรหัสกลับได้ นิยมใช้เก็บไอดีของผู้ใช้ หรือ “sub” มาจากคำว่า subject

Signature จะเป็นการนำ Header และ Payload มาเข้ารหัสด้วย Secret-key ที่เราเก็บไว้ เพื่อเป็นการะยืนยันว่า JWT นี้ไม่ถูกปลอมแปลงจากคนอื่น

สามารถไปลอง ถอดรหัส และเข้ารหัส JWT เพิ่มเติมได้ที่เว็บ https://jwt.io/

แล้ว stateless กับ JWT เกี่ยวข้องกันยังไง

Image for post
การติดต่อกับ server แบบ stateless

เมื่อ JWT ยากที่จะถูกปลอมแปลง การที่ user เข้ามายืนยันตัวตน เราก็จะทำการสร้าง JWT ที่มีการอ้างถึงไอดีของเขา

และทุกครั้งที่ต้องการเข้าถึงข้อมูลให้แนบ JWT นั้นมาด้วย

และเซิร์ฟเวอร์จะทำการตรวจสอบว่ามีสิทธิเข้าถึง หรือไม่

.

  1. ตรวจสอบโดยการดู Signature ที่เข้ารหัสจาก Header+Payload ด้วย Secret key ที่เก็บไว้ ว่าถูกปลอมแปลงหรือไม่
Image for post

2. นำส่วนของ payload หลังจาก decode แล้วมาตรวจสอบใน database เช่น
นำ id_kennaruk_user ไปหาในฐานข้อมูล และคืนยอดเงินคงเหลือกลับไป

** เพราะฉะนั้นเราจึงไม่ควรใส่ข้อมูลสำคัญเช่น รหัสผ่านของผู้ใช้ ลงไปใน payload เนื่องจาก decode ออกมาได้ตรงๆเลย **

5. ทำ Authentication ด้วย JWT + Passport.js

เกริ่นมาตั้งนาน โค้ดสักที!
*ถ้าไม่ทันส่วนไหน อ่านเอาความเข้าใจคร่าวๆ แล้วไปดูโค้ดสรุปตอนท้ายนะครับ*

อันดับแรกเตรียม dependencies ที่เกี่ยวข้อง

npm install body-parser jwt-simple passport passport-jwt --save

เราจะทำ middleware และ api ที่ใช้ในการ login เพื่อสร้าง JWT ให้ผู้ใช้

เพิ่มโค้ดด้านล่างต่อไปนี้ลงไปใน index.js

const bodyParser = require("body-parser");
app.use(bodyParser.json()); //ทำให้รับ json จาก body ได้
const loginMiddleware = (req, res, next) => {
if(req.body.username === "kennaruk" &&
req.body.password === "mak") next();
else res.send("Wrong username and password")
//ถ้า username password ไม่ตรงให้ส่งว่า Wrong username and password
}
app.post("/login", loginMiddleware, (req, res) => {
res.send("Login success");
});

ทดสอบด้วย Postman อีกที

Image for post
เมื่อ username password ไม่ตรงตามที่ตั้งไว้
Image for post
เมื่อ username password ตรงตามที่ตั้งไว้

คราวนี้สิ่งที่เราต้องการคือ ถ้าผู้ใช้ล็อคอินสำเร็จ เราจะคืน token กลับไป

แก้ไขโค้ดการตอบกลับใน app.post(“/login”) ดังต่อไปนี้

const jwt = require("jwt-simple");
//เพิ่มโค้ดลงไปใน app.post("/login")
app.post("/login, loginMiddleware, (req, res) => {
const payload = {
sub: req.body.username,
iat: new Date().getTime()//มาจากคำว่า issued at time (สร้างเมื่อ)
};
const SECRET = "MY_SECRET_KEY"; //ในการใช้งานจริง คีย์นี้ให้เก็บเป็นความลับ
res.send(jwt.encode(payload, SECRET));
}

ทดสอบด้วย Postman อีกทีนึง

Image for post
ผลลัพธ์ token ที่ถูก encode payload ด้วย secret key

เรียบร้อยเราสร้าง JWT ให้ผู้ใช้ได้แล้ว!! ลองเอา token ที่ได้ ก๊อปไปแปะใน https://jwt.io/ ลอง decode ดูสะหน่อยซิ

และก๊อปเก็บไว้ก่อนนะครับ เราจะใช้โทเคนตัวนี้ ทำในส่วนถัดไปด้วย

Image for post

ถ้าสังเกตคือ
1. payload ที่ decode ออกมาตรงกับที่เรา encode ไปเด๊ะๆ
และ
2. Invalid Signature ตรวจพบว่านี่เกิดจากการปลอมแปลงเนื่องจาก 3. ไม่ได้มี secret key ที่ถูกต้องใช้ในการ encode นั่นเอง

Image for post

Passport.js เป็นเหมือนตัวกลางการยืนยันตัวตนที่จะประกอบด้วย Strategy หรือ กลยุทธ ที่ใช้ในการยืนยันตัวตน ซึ่งมีหลายแบบมาก ไม่ว่าจะเป็น username, password แบบปกติ facebook, google 3rd party ต่างๆ แต่ในบทความนี้เราจะเจาะจงไปที่ JWT Strategy

เพราะงั้นหลักๆการทำงานคือ เราจะสร้าง Strategy แต่ละแบบแล้วเสียบเข้าไปใน passport นั่นเอง

ขั้นตอนหลังจากนี้ ถ้าหลุดระหว่างทาง ไม่เป็นอะไรนะครับ เดี๋ยวจะมีสรุปโค้ดหลังสุดไว้ให้

อันดับแรก สร้าง strategy ของ JWT ก่อน

//ใช้ในการ decode jwt ออกมา
const ExtractJwt = require("passport-jwt").ExtractJwt;
//ใช้ในการประกาศ Strategy
const JwtStrategy = require("passport-jwt").Strategy;
const jwtOptions = {
jwtFromRequest: ExtractJwt.fromHeader("authorization"),
secretOrKey: SECRET,//SECRETเดียวกับตอนencodeในกรณีนี้คือ MY_SECRET_KEY
}
const jwtAuth = new JwtStrategy(jwtOptions, (payload, done) => {
if(payload.sub=== "kennaruk") done(null, true);
else done(null, false);
});

ในตอนใช้งานจริง payload.username ควรจะถูกนำไปค้นหาในฐานข้อมูลว่าผู้ใช้นี้ มีอยู่จริงหรือไม่ หรือมีสิทธิเข้าถึงมากพอ ที่จะได้ข้อมูลนั้นๆไปหรือไม่

ส่วน function callback “done” parameters ตัวแรกจะหมายถึง error ส่วนตัวที่สองคือ ผ่าน หรือไม่ผ่าน done(errorOrNotpassOrNot);

หลังจากนั้นทำการ เอา Strategy ที่สร้างนี้ เสียบเข้าไปใน passport

const passport = require("passport");passport.use(jwtAuth);

ทำการเรียกใช้เป็น middleware ใน app.get(“/”)

const requireJWTAuth = passport.authenticate("jwt",{session:false});app.get("/", requireJWTAuth, (req, res) => {
res.send("ยอดเงินคงเหลือ 50");
});

สรุปโค้ดตอนนี้

//index.js
/* import library ที่จำเป็นทั้งหมด */
const express = require("express");
const bodyParser = require("body-parser");
const app = express();
app.use(bodyParser.json());
const jwt = require("jwt-simple");
const passport = require("passport");
//ใช้ในการ decode jwt ออกมา
const ExtractJwt = require("passport-jwt").ExtractJwt;
//ใช้ในการประกาศ Strategy
const JwtStrategy = require("passport-jwt").Strategy;
const SECRET = "MY_SECRET_KEY";//สร้าง Strategy
const jwtOptions = {
jwtFromRequest: ExtractJwt.fromHeader("authorization"),
secretOrKey: SECRET
};
const jwtAuth = new JwtStrategy(jwtOptions, (payload, done) => {
if (payload.sub === "kennaruk") done(null, true);
else done(null, false);
});
//เสียบ Strategy เข้า Passport
passport.use(jwtAuth);
//ทำ Passport Middleware
const requireJWTAuth = passport.authenticate("jwt",{session:false});
//เสียบ middleware ยืนยันตัวตน JWT เข้าไป
app.get("/", requireJWTAuth, (req, res) => {
res.send("ยอดเงินคงเหลือ 50");
});
//ทำ Middleware สำหรับขอ JWT
const loginMiddleWare = (req, res, next) => {
if (req.body.username === "kennaruk"
&& req.body.password === "mak") next();
else res.send("Wrong username and password");
};
app.post("/login", loginMiddleWare, (req, res) => {
const payload = {
sub: req.body.username,
iat: new Date().getTime()
};
res.send(jwt.encode(payload, SECRET));
});
app.listen(3000);

สั่งรัน server ได้ด้วยคำสั่ง nodemon index.js หรือ node index.js ก็ได้

Image for post
GET / ไปโดยไม่ได้แนบ token ไปด้วยจึงได้ Unauthorized คืนมา
Image for post
ได้ข้อมูลกลับมา เมื่อแนบ JWT ที่ encode มาด้วย

เขียนครั้งแรกก็จะมึนๆหน่อย ติชมได้นะครับ ขอบคุณครับ!

source : https://medium.com/@kennwuttisasiwat/%E0%B8%97%E0%B8%B3-authentication-%E0%B8%9A%E0%B8%99-express-%E0%B8%94%E0%B9%89%E0%B8%A7%E0%B8%A2-passport-js-jwt-34fb1169a410

No comments:

Post a Comment