รู้จักรูปแบบ Authentication ระหว่าง Frontend และ Backend
/ 8 min read
สามารถดู video ของหัวข้อนี้ก่อนได้ ดู video
เนื้อหานี้ทำมาเนื่องจากใน Web development 101 ที่ผมทำไปก่อนหน้านี้ ผมยังไม่ได้นำเสนอวิธีการทำ Authenticaion ผ่าน Backend เลย
Session นี้เลยจะพามาทำ login และการทำเรื่องกั้นสิทธิ์ว่าสามารถทำอย่างไรได้บ้าง
เราจะแบ่งหัวข้อออกจากกันตามนี้
เราจะมาตอบทั้ง 4 คำถามนี้ใน Session นี้กัน
- เราจะเก็บ password ไว้ในฐานข้อมูลอย่างไร ?
- เราจะยืนยันได้อย่างไรว่า เราที่เข้ามาคือใคร ?
- เราจะทำ login ได้ยังไง ? และทำ login แล้วได้อะไรออกมาเป็นเครื่องยืนยันตัวตน ?
- เราจะป้องกันไม่ให้คนทั่วไปเข้ามายิง API ที่เราไม่อยากให้คนทั่วไปยิงได้ยังไง ?
z
- สำหรับ project นี้เราจะ setup docker 2 ตัวคือ mysql และ phpmyadmin เท่านั้น
- โดยโจทย์ของ mysql คือเราจะสร้าง table ชื่อ users ขึ้นมาและเราจะทำการเก็บข้อมูล email, password เอาไว่้เพื่อทำการ login
docker-compose.yml
version: "3.7"
services: db: image: mysql:latest container_name: mysql_db command: --default-authentication-plugin=mysql_native_password environment: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: tutorial ports: - "3306:3306" volumes: - mysql_data:/var/lib/mysql networks: - my_network
phpmyadmin: image: phpmyadmin/phpmyadmin:latest container_name: phpmyadmin environment: PMA_HOST: db PMA_PORT: 3306 PMA_USER: root PMA_PASSWORD: root ports: - "8080:80" depends_on: - db networks: - my_network
networks: my_network: driver: bridge
volumes: mysql_data: driver: local
Structure project จะเป็นตามนี้
├── docker-compose.yml├── index.js -- ไฟล์หลักที่เราจะทำกัน├── package.json└── src └── index.html
library ที่ใช้ในรอบนี้
express
สำหรับ library node สำหรับทำ Rest APIcors
สำหรับการเปิดให้ฝั่ง Frontend สามารถยิงเข้ามาผ่าน cross domain ได้mysql2
สำหรับจัดการฐานข้อมูล mysqljsonwebtoken
สำหรับการเข้ารหัสข้อมูลสำหรับแนบเข้า token ตอน login สำเร็จcookie-parser
สำหรับเรียกใช้และ save cookiebcrypt
สำหรับเข้ารหัส passwordexpress-session
สำหรับการ login ในเคสที่ใช้ session
โดย index.js
เราจะเพิ่ม code ไว้ตามนี้เพื่อ config เริ่มต้นก่อน
const cors = require("cors");const express = require("express");const mysql = require("mysql2/promise");const jwt = require("jsonwebtoken");const cookieParser = require("cookie-parser");const session = require("express-session");const bcrypt = require("bcrypt");
const app = express();app.use(express.json());app.use( cors({ credentials: true, origin: ["http://localhost:8888"], }),);app.use(cookieParser());
app.use( session({ secret: "secret", resave: false, saveUninitialized: true, }),);
const port = 8000;const secret = "mysecret";
let conn = null;
// function init connection mysqlconst initMySQL = async () => { conn = await mysql.createConnection({ host: "localhost", user: "root", password: "root", database: "tutorial", });};
/* เราจะแก้ไข code ที่อยู่ตรงกลาง */
// Listenapp.listen(port, async () => { await initMySQL(); console.log("Server started at port 8000");});
โจทย์ของหัวข้อนี้เราจะทำ API อะไรกันบ้าง
เราจะทำ API
- POST
/api/register
สำหรับสมัครสมาชิก email, password เข้ามา (จะใช้ในข้อ 1) - POST
/api/login
สำหรับ login สมาชิกด้วย email, password เพื่อยืนยันตัวตนและเข้าสู่ระบบ (จะใช้ในข้อ 2,3) - GET
/api/users
สำหรับ list users ทั้งหมดในระบบโดยจะอนุญาตเฉพาะคนที่เป็น role admin เท่านั้น (จะใช้ในข้อที่ 4)
1. เราจะเก็บ password ไว้ในฐานข้อมูลอย่างไร ?
- ใช้ bcrypt ซึ่งเป็น password hashing function ที่สร้างขึ้นจากพื้นฐานของ Blowfish cipher เป็นการเข้ารหัสแบบทางเดียว
- ปกติการเข้ารหัสมี 2 แบบ (แบ่งแบบง่ายๆนะ) คือ เข้ารหัสแบบ decrypt ได้ (แกะข้อมูลเข้ารหัสออกมาได้) และ เข้ารหัสแบบทางเดียว (เช่น hashing function)
app.post("/api/register", async (req, res) => { const { email, password } = req.body;
const [rows] = await conn.query("SELECT * FROM users WHERE email = ?", email); if (rows.length) { return res.status(400).send({ message: "Email is already registered" }); }
// Hash the password const hash = await bcrypt.hash(password, 10); // 10 = salt (การสุ่มค่าเพื่อเพิ่มความซับซ้อนในการเข้ารหัส) // และมันจะถูกนำมาใช้ตอน compare
// Store the user data const userData = { email, password: hash };
try { const result = await conn.query("INSERT INTO users SET ?", userData); } catch (error) { console.error(error); res.status(400).json({ message: "insert fail", error, }); }
res.status(201).send({ message: "User registered successfully" });});
2. เราจะยืนยันได้อย่างไรว่า เราที่เข้ามาคือใคร ?
app.post("/api/login", async (req, res) => { const { email, password } = req.body;
const [result] = await conn.query("SELECT * from users WHERE email = ?", email); const user = result[0]; const match = await bcrypt.compare(password, user.password); if (!match) { return res.status(400).send({ message: "Invalid email or password" }); }
res.send({ message: "Login successful" });});
3. เราจะทำ login ได้ยังไง ? และทำ login แล้วได้อะไรออกมาเป็นเครื่องยืนยันตัวตน ?
1. gen token ส่งให้ Frontend เก็บไว้
- JWT Token คือ token เข้ารหัสมาตรฐาน RFC 7519 ที่สามารถนำมา decode ข้อมูลกลับได้
- มีความปลอดภัยในแง่การส่งข้อมูลไปมาระหว่าง Frontend, Backend แต่ ไม่เหมาะสำหรับเก็บข้อมูลที่มีความปลอดภัยสูง
app.post("/api/login", async (req, res) => { // code จากหัวข้อ 2 เรื่องเช็ค if (!match) { return res.status(400).send({ message: "Invalid email or password" }); }
const token = jwt.sign({ email, role: "admin" }, secret, { expiresIn: "1h" });
res.send({ message: "Login successful", token });});
2. ใส่ผ่าน cookie เข้า domain Frontend
app.post("/api/login", async (req, res) => { // code จากหัวข้อ 2 เรื่องเช็ค if (!match) { return res.status(400).send({ message: "Invalid email or password" }); }
const token = jwt.sign({ email, role: "admin" }, secret, { expiresIn: "1h" }); res.cookie("token", token, { maxAge: 300000, secure: true, httpOnly: true, sameSite: "none", });
res.send({ message: "Login successful" });});
3. ใช้ผ่าน session (ไม่ต้องผ่าน client)
- session คือ value ที่จะถูกสร้างขึ้นมาเมื่อ Client มีการเปิดเว็บบราวเซอร์และติดต่อกับ URL ของเว็บไซต์นั้น (และจะถูกทำลายลงเมื่อผู้ใช้ได้ทำการปิด Browser หรือ Client)
app.post("/api/login", async (req, res) => { // code จากหัวข้อ 2 เรื่องเช็ค if (!match) { return res.status(400).send({ message: "Invalid email or password" }); }
// ใส่ข้อมูล user เก็บคู่กับ session ไว้ console.log("get session", req.sessionID); req.session.user = user; req.session.userId = user.id; res.send({ message: "Login successful" });});
4. เราจะป้องกันไม่ให้คนทั่วไปเข้ามายิง API ที่เราไม่อยากให้คนทั่วไปยิงได้ยังไง ?
เราจะเพิ่ม GET /api/users
กับ funtion middleware authenticateToken
เข้ามา
โดยเราจะจับคู่กับหัวข้อที่ 3 ในเรื่องของการ login คือ
- ถ้าใช้ วิธีที่ 1 เก็บผ่าน web storage = ต้องให้ client ส่ง token มาผ่าน Bearer เข้ามา เพื่อทำการเช็คว่า token ถูกต้องหรือไม่ (เป็นวิธีที่ 1 ของหัวข้อนี้)
- ถ้าใช้ วิธีทีี่ 2 เก็บผ่าน Cookie = server จะอ่าน cookie (ที่แนบมาคู่กับ HTTP request) เพื่อทำการเช็คว่า token ถูกต้องหรือไม่ (เป็นวิธีที่ 2 ของหัวข้อนี้)
- ถ้าใช้ วิธีทีี่ 3 เก็บผ่าน Session = server จะอ่านค่าจาก session (ใน server เอง) เช็คว่า มีการเก็บตัวแปรผ่าน request user มาไหม ? (เป็นวิธีที่ 3 ของหัวข้อนี้)
const authenticateToken = (req, res, next) => { // เดี๋ยวเรามา code ตรงนี้เพื่อเช็คกันต่อ next();};
app.get("/api/users", authenticateToken, async (req, res) => { try { // Get the users const [results] = await conn.query("SELECT email FROM users"); const users = results.map((row) => row.email);
res.send(users); } catch (err) { console.error(err); res.status(500).send({ message: "Server error" }); }});
1. ผ่าน header Authorization: Bearer <token>
1. gen token ส่งให้ Frontend เก็บไว้
const authenticateToken = (req, res, next) => { const authHeader = req.headers["authorization"]; const token = authHeader && authHeader.split(" ")[1];
if (token == null) return res.sendStatus(401); // if there isn't any token
try { const user = jwt.verify(token, secret); req.user = user; console.log("user", user); next(); } catch (error) { return res.sendStatus(403); }};
2. ผ่าน cookie
const authenticateToken = (req, res, next) => { const token = req.cookies.token; // เปลี่ยนมาเช็คผ่าน cookie ที่ใส่ไปแทน
if (token == null) return res.sendStatus(401); // if there isn't any token
try { const user = jwt.verify(token, secret); req.user = user; console.log("user", user); next(); } catch (error) { return res.sendStatus(403); }};
3. เช็คผ่านค่าใน session
const authenticateToken = (req, res, next) => { try { if (!req.session.userId) { return res.sendStatus(401); } req.user = req.session.user; next(); } catch (error) { return res.sendStatus(403); }};
ข้อดี / ข้อเสียของแต่ละวิธี
วิธีที่ 1 ใช้ web storage เก็บ token
ข้อดี
- implement ง่าย
- สามารถเก็บข้อมูลขนาดเท่าไหร่ก็ได้ ไม่จำกัด เนื่องจาก Web storage ไม่ได้มี limit เอาไว้
- access ผ่าน client ได้ง่าย
- data ก็จัดการง่ายเนื่องจาก DB บน Browser เป็น key - value อยู่แล้ว
ข้อสังเกต
- สามารถโดน Cross-site Scripting ได้ หาก token หลุดไป
- Data ถูกเก็บเป็น plain text
- ไม่มี built-in expiry ในตัว data (แต่ปัญหานี้แก้จากฝั่ง Backend ได้)
- เก็บ sensitive data ไม่ได้เลย เพราะจะถูกแกะมาอ่านได้ (แม้จะเป็น jwt ก็แกะมาได้)
วิธีที่ 2 ใช้ cookie เก็บ token
ข้อดี
- cookie เมื่อเปิดใช้งาน นี้จะถูกส่งผ่าน http request เสมอ สามารถตรวจสอบทุกๆ request ที่ต้องการเช็ค cookie ได้ง่าย (ไม่ต้องคอยระมัดระวังว่าจะลืมส่ง header ไหม)
- มี built-in expiry
- สามารถเพิ่มความ secure ผ่าน flag “HttpOnly”, “Secure” ได้ (คือ client อยู่ๆมาเรียกใช้ตรงๆไม่ได้)
ข้อสังเกต
- ขนาดจำกัด (4KB per Cookie) แตกต่างกับ Web storage
- การส่ง cookie ไปในทุก request อาจจะเพิ่ม load server (นิดนึง)
- เจอปัญหาการโจมตีด้วยวิธี Cross-Site Request Forgery (CSRF) = การอ่าน cookie ผ่าน site อื่น โดยจำลองทำเหมือนเว็บไซต์หลักกำลังส่งข้อมูลเข้าไป (ปกติแก้ได้โดยการ check site ที่มาให้แน่ใจก่อนดำเนินการ)
- สามารถโดน block หรือ ลบโดย user บน Browser ได้ (กรณีที่ user block cookie ซึ่งสมัยนี้คิดว่าไม่น่ามีแล้ว)
วิธีที่ 3 ใช้ session
ข้อดี
- data ทั้งหมดจะอยู่บน server เท่านั้น ความปลอดภัยจะมากกว่า 2 วิธีที่ผ่านมา
- สามารถเก็บข้อมูลขนาดใหญ่เท่าไหร่ก็ได้ (ตาม storage server)
- data จะไม่ส่งระหว่าง http request (เนื่องจากมันเป็นการเก็บแค่บน server) = ประหยัด load server ได้
- สามารถเก็บในรูปแบบไหนก็ได้ (in-memory, file, database)
ข้อสังเกต
- ต้องเพิ่มส่วนของ data management ส่วน session มา (เนื่องจาก server ต้องเป็นคนจัดการเอง)
- ถ้า server crash แล้วไม่ได้ handle เรื่อง data ใน session ไว้ = session จะหายไป ทำให้ login ทั้งระบบหลุดได้ (session จะเปลี่ยนไปตอน server กลับมาใหม่)
- ต้องเพิ่ม mechanism ส่วนของการ manage expire ใน session (เพื่อไม่ให้ login ค้างอยู่ตลอดเวลา)
- ถ้าเกิด user ในระบบเยอะ = data session ก็จะเยอะตาม ต้องนึกถึงการ scale ระบบนี้เพิ่มเติมมา
Github
https://github.com/mikelopster/auth-express-example
Reference อื่นๆ
- https://dev.to/honeybadger/complete-guide-to-authentication-in-javascript-3576 (ในบทความนี้มี refreshtoken)
Related Post
- ลองเล่น Supabase กับ Next.js กันมี Video มี Github
รู้จักกับ Supabase เทคโนโลยีฐานข้อมูลที่เรียกตัวเองว่าเป็น Firebase alternative กันว่าใช้ทำอะไรได้บ้าง
- มารู้จักกับ gRPC และ Go กันมี Video
เรียนรู้การใช้งาน gRPC กับ Go ตั้งแต่การสร้าง Protocol Buffers, การทำ Server/Client และการจัดการ Error รวมถึง Best Practice ในการใช้ API Gateway
- ลอง Rust Basic (1)มี Video
ลองไมค์สัปดาห์นี้ เรามาทำความรู้จักกับภาษา Rust กันน (เปิด line ผลิตใหม่ของช่อง mikelopster 😆)
- มาลองเล่น LIFF และ Messaging API กันมี Video มี Github
พามาทำความรู้จักกับ LIFF (LINE Frontend Framework) กันว่ามันคืออะไร เราสามารถพัฒนา Web app ลงบน LINE ได้อย่างไร