ทำ Stateless Authentication บน Express ด้วย Passport.js + JWT
เนื้อหาของบทความนี้จะคลอบคลุมหัวข้อดังต่อไปนี้
- Setup API server อย่างง่ายด้วย Express.js
- Middleware คืออะไร?
- Stateless vs Stateful อย่างคร่าวๆ
- JWT คืออะไร?
- ทำ 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 บทความนี้นะครับ
- ติดตั้ง dependencies ที่เกี่ยวข้องด้วยคำสั่ง
npm install express --save && npm install -g nodemon
- สร้างไฟล์ 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
3. ทดสอบด้วยการรันคำสั่ง nodemon index.js
และใช้ Postman ในการทดสอบ เรียก GET ไปที่ localhost:3000/
.
.
.
2. Middleware คืออะไร
.
โดยปกติแล้วเนี่ย การติดต่อร้องขอ GET / ไปยังเซิร์ฟเวอร์ ก็หน้าตาประมาณรูปซ้ายมือ ไม่มีการคัดกรองอะไร ขอปุบ ได้ปับ
.
คราวนี้ลองนึกภาพว่า ถ้าข้อมูลตอบกลับเป็นข้อมูลที่มีความเป็นส่วนตัวระดับนึงเช่น การร้องขอ ยอดเงินคงเหลือ ของบัญชี นายบอย เราคงไม่อยากให้ใครที่ไม่ใช่นายบอยมาดูได้ เราจึงต้องใช้ “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);
.
เมื่อปรับค่า Authorization เป็น Boy ฟังก์ชัน middleware ที่เราเขียนขึ้นมาก็จะยอมเรียก next(); และเรียกฟังก์ชัน res.send(“ยอดเงินคงเหลือ 50”); กลับมา
.
.
3. Stateless vs Stateful อย่างคร่าวๆ
ก่อนที่จะไปถึงหัวข้อถัดไปเนี่ย ขอเกริ่นนิดนึงว่าทำไมถึงต้องใช้ JWT หรือการยืนยันตัวตนแบบที่สอนในบทความนี้
ถ้าแปลอย่างคร่าวๆ
Stateful หมายถึง “การเก็บสถานะ” ส่วน
Stateless หมายถึง “การไม่เก็บสถานะ”
แต่บทความนี่เราจะไม่ลงลึกถึงขั้นโค้ดมากนัก แต่จะอธิบายด้วยภาพอย่างคร่าวๆแทน เกี่ยวกับการยืนยันตัวตนของระบบ
Stateful หรือการที่ server แต่ละเครื่องมีการเก็บสถานะ หรือจดจำ
ยกตัวอย่างง่ายๆคือ เครื่อง 1 เครื่องจะเก็บตัวแปรว่า ผู้ใช้คนนี้เคยยืนยันตัวตนรึยัง ก่อนจะคืนข้อมูลที่ร้องขอกลับไป
ก็ดูดี และง่ายดายเหมือนไม่มีปัญหาอะไร ขั้นตอนการเขียนโค้ดจริงๆก็ง่ายกว่ามาก แต่จะมีปัญหาเมื่อเครื่องเซิร์ฟเวอร์ 1 เครื่องไม่สามารถรองรับการร้องขอข้อมูล ของผู้ใช้ที่เข้ามาจำนวนมากเกินไป เราจึงต้องขยายระบบ หรือ “Scale” ซึ่งเราจะยกตัวอย่างการทำ “horizontal scaling ด้วย load balancer” อย่างง่ายๆดังนี้
การวางระบด้านล่าง หมายถึงการที่มีหลายๆเครื่อง รองรับการ request โดย load balancer จะเป็นตัวที่กระจายงานอย่างทั่วถึง ให้ทุกๆ server
และเราจะสมมติให้การ ยืนยันตัวตน (username, password) ครั้งแรกไปยัง server 1 และทำการขอดูข้อมูลเงินคงเหลือ
ก็ดูไม่มีปัญหาอะไร แต่ถ้าเกิดเมื่อการจ่ายงานครั้งต่อไป ถูกส่งไปยังเครื่องอื่น
ผลลัพธ์คือ Server 2 นั้นไม่เคยจดจำว่านาย A มีสิทธิเข้าถึงข้อมูลมาก่อน ทำให้นาย A ต้องยืนยันตัวตนใหม่อีกครั้ง
แล้วเราจะแก้ปัญหาข้างต้นยังไงล่ะ? จริงๆแล้วก็มีหลายวิธีในการแก้ไขปัญหาด้านบน เช่น การให้ load balancer จดจำว่านาย A จะต้องถูกส่งไปเครื่อง server 1 เท่านั้นนะ แต่ผลก็คือ แบบนี้ workload ก็จะไม่สามารถกระจายแบบทั่วถึงได้อย่างมีประสิทธิภาพ
จึงมาถึงพระเอกของเรา หรือการทำ Stateless
แวะดูคอนเสปกันสักหน่อย
หลักๆคือ client จะต้องแนบสิ่งที่เรียกว่า JWT มาทุกครั้งในการร้องขอ และ server จะทำการตรวจสอบ JWT ที่แนบมาทุกครั้งก่อนที่จะอนุญาตการเข้าถึงข้อมูล
มาถึงตรงนี้อาจจะ งงๆหน่อย ว่าเอ้า ต่างกับตอนแรกตรงไหน แล้ว JWT หน้าตาเป็นยังไง ไม่ต้องรีบไป ไปต่อที่หัวข้อหน้ากันเลย!
4. JWT คืออะไร
JWT ย่อมาจาก JSON Web Token หมายความว่าการเก็บข้อมูล JSON ไว้ใน token อันหนึ่ง ที่เป็นสายสตริงตัวอักษร ยาวๆ หน้าตาประมาณนี้
JWT จะประกอบด้วยกัน 3 ส่วน คั่นด้วย “.” <Header>.<Payload>.<Signature>
Header จะประกอบด้วยการบอกว่าข้อมูลข้างต้นถูกเข้ารหัสด้วย อัลกอริทึมอะไรและมีชนิดอะไร
Payload จะเป็นข้อมูลที่เราใช้เก็บ ที่ไม่สำคัญ ห้ามเก็บ password โดยตรงเพราะสามารถนำไปถอดรหัสกลับได้ นิยมใช้เก็บไอดีของผู้ใช้ หรือ “sub” มาจากคำว่า subject
Signature จะเป็นการนำ Header และ Payload มาเข้ารหัสด้วย Secret-key ที่เราเก็บไว้ เพื่อเป็นการะยืนยันว่า JWT นี้ไม่ถูกปลอมแปลงจากคนอื่น
สามารถไปลอง ถอดรหัส และเข้ารหัส JWT เพิ่มเติมได้ที่เว็บ https://jwt.io/
แล้ว stateless กับ JWT เกี่ยวข้องกันยังไง
เมื่อ JWT ยากที่จะถูกปลอมแปลง การที่ user เข้ามายืนยันตัวตน เราก็จะทำการสร้าง JWT ที่มีการอ้างถึงไอดีของเขา
และทุกครั้งที่ต้องการเข้าถึงข้อมูลให้แนบ JWT นั้นมาด้วย
และเซิร์ฟเวอร์จะทำการตรวจสอบว่ามีสิทธิเข้าถึง หรือไม่
.
ขั้นตอนการตรวจสอบ JWT อย่างคร่าวๆ
- ตรวจสอบโดยการดู Signature ที่เข้ารหัสจาก Header+Payload ด้วย Secret key ที่เก็บไว้ ว่าถูกปลอมแปลงหรือไม่
2. นำส่วนของ payload หลังจาก decode แล้วมาตรวจสอบใน database เช่น
นำ id_kennaruk_user ไปหาในฐานข้อมูล และคืนยอดเงินคงเหลือกลับไป
** เพราะฉะนั้นเราจึงไม่ควรใส่ข้อมูลสำคัญเช่น รหัสผ่านของผู้ใช้ ลงไปใน payload เนื่องจาก decode ออกมาได้ตรงๆเลย **
5. ทำ Authentication ด้วย JWT + Passport.js
เกริ่นมาตั้งนาน โค้ดสักที!
*ถ้าไม่ทันส่วนไหน อ่านเอาความเข้าใจคร่าวๆ แล้วไปดูโค้ดสรุปตอนท้ายนะครับ*
5.1 สร้าง JWT ให้ผู้ใช้
อันดับแรกเตรียม 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 อีกที
คราวนี้สิ่งที่เราต้องการคือ ถ้าผู้ใช้ล็อคอินสำเร็จ เราจะคืน 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 อีกทีนึง
เรียบร้อยเราสร้าง JWT ให้ผู้ใช้ได้แล้ว!! ลองเอา token ที่ได้ ก๊อปไปแปะใน https://jwt.io/ ลอง decode ดูสะหน่อยซิ
และก๊อปเก็บไว้ก่อนนะครับ เราจะใช้โทเคนตัวนี้ ทำในส่วนถัดไปด้วย
ถ้าสังเกตคือ
1. payload ที่ decode ออกมาตรงกับที่เรา encode ไปเด๊ะๆ
และ
2. Invalid Signature ตรวจพบว่านี่เกิดจากการปลอมแปลงเนื่องจาก 3. ไม่ได้มี secret key ที่ถูกต้องใช้ในการ encode นั่นเอง
5.2 Passport.js และ Strategy
Passport.js เป็นเหมือนตัวกลางการยืนยันตัวตนที่จะประกอบด้วย Strategy หรือ กลยุทธ ที่ใช้ในการยืนยันตัวตน ซึ่งมีหลายแบบมาก ไม่ว่าจะเป็น username, password แบบปกติ facebook, google 3rd party ต่างๆ แต่ในบทความนี้เราจะเจาะจงไปที่ JWT Strategy
เพราะงั้นหลักๆการทำงานคือ เราจะสร้าง Strategy แต่ละแบบแล้วเสียบเข้าไปใน passport นั่นเอง
5.3 ทำ JWT Authentication ด้วย Passport.js
ขั้นตอนหลังจากนี้ ถ้าหลุดระหว่างทาง ไม่เป็นอะไรนะครับ เดี๋ยวจะมีสรุปโค้ดหลังสุดไว้ให้
อันดับแรก สร้าง 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(errorOrNot, passOrNot);
หลังจากนั้นทำการ เอา 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
ก็ได้
ทดสอบโค้ดข้างต้นด้วย Postman อีกทีหนึ่ง
เขียนครั้งแรกก็จะมึนๆหน่อย ติชมได้นะครับ ขอบคุณครับ!
— Kennaruk