Sep 6, 2023
รู้จักรูปแบบ Authentication ระหว่าง Frontend และ Backend
เราจะพามาทำ Authentication กับการ Login กัน ว่ามีกี่วิธีที่สามารถทำได้ และสามารถทำได้ยังไงกันบ้าง ซึ่งจะพาทำกันตั้งแต่ฝั่งของ API Backend
สารบัญ
สามารถดู 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: localStructure project จะเป็นตามนี้
├── docker-compose.yml
├── index.js -- ไฟล์หลักที่เราจะทำกัน
├── package.json
└── src
└── index.htmllibrary ที่ใช้ในรอบนี้
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 mysql
const initMySQL = async () => {
conn = await mysql.createConnection({
host: "localhost",
user: "root",
password: "root",
database: "tutorial",
});
};
/* เราจะแก้ไข code ที่อยู่ตรงกลาง */
// Listen
app.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)
