รู้จักรูปแบบ Authentication ระหว่าง Frontend และ Backend

/ 8 min read

Share on social media

auth-express สามารถดู video ของหัวข้อนี้ก่อนได้ ดู video

เนื้อหานี้ทำมาเนื่องจากใน Web development 101 ที่ผมทำไปก่อนหน้านี้ ผมยังไม่ได้นำเสนอวิธีการทำ Authenticaion ผ่าน Backend เลย

Session นี้เลยจะพามาทำ login และการทำเรื่องกั้นสิทธิ์ว่าสามารถทำอย่างไรได้บ้าง

เราจะแบ่งหัวข้อออกจากกันตามนี้

เราจะมาตอบทั้ง 4 คำถามนี้ใน Session นี้กัน

  1. เราจะเก็บ password ไว้ในฐานข้อมูลอย่างไร ?
  2. เราจะยืนยันได้อย่างไรว่า เราที่เข้ามาคือใคร ?
  3. เราจะทำ login ได้ยังไง ? และทำ login แล้วได้อะไรออกมาเป็นเครื่องยืนยันตัวตน ?
  4. เราจะป้องกันไม่ให้คนทั่วไปเข้ามายิง 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 API
  • cors สำหรับการเปิดให้ฝั่ง Frontend สามารถยิงเข้ามาผ่าน cross domain ได้
  • mysql2 สำหรับจัดการฐานข้อมูล mysql
  • jsonwebtoken สำหรับการเข้ารหัสข้อมูลสำหรับแนบเข้า token ตอน login สำเร็จ
  • cookie-parser สำหรับเรียกใช้และ save cookie
  • bcrypt สำหรับเข้ารหัส password
  • express-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

  1. POST /api/register สำหรับสมัครสมาชิก email, password เข้ามา (จะใช้ในข้อ 1)
  2. POST /api/login สำหรับ login สมาชิกด้วย email, password เพื่อยืนยันตัวตนและเข้าสู่ระบบ (จะใช้ในข้อ 2,3)
  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 แต่ ไม่เหมาะสำหรับเก็บข้อมูลที่มีความปลอดภัยสูง

https://jwt.io/

บทความอ้างอิง https://medium.com/rootusercc/json-web-token-%E0%B8%A1%E0%B8%B2%E0%B8%95%E0%B8%A3%E0%B8%90%E0%B8%B2%E0%B8%99%E0%B9%83%E0%B8%AB%E0%B8%A1%E0%B9%88-%E0%B9%83%E0%B8%99%E0%B8%81%E0%B8%B2%E0%B8%A3%E0%B8%97%E0%B8%B3-authentication-b0760dd9acd1

sequenceDiagram
  participant Frontend
  participant Backend
  participant Database

  note over Frontend: On Browser
  Frontend ->> Backend: POST /api/login<br>(email, password)
  Backend ->> Database: Get User data
  Database -->> Backend: return user data
  alt User found
    note over Backend : use email = create JWTtoken
    Backend -->> Frontend: return Login success <br>with JWTtoken
    note over Frontend: collect JWTtoken <br>in localstorage
  else User not found
    Backend -->> Frontend: return Login Fail
  end
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 });
});
sequenceDiagram
  participant Frontend
  participant Backend
  participant Database

  note over Frontend: On Browser
  Frontend ->> Backend: POST /api/login<br>(email, password)
  Backend ->> Database: Get User data
  Database -->> Backend: return user data
  alt User found
    note over Backend : use email = create JWTtoken
    Backend -->> Frontend: write JWTtoken <br>to cookie <br>(with Backend)
    Backend -->> Frontend: return Login success
  else User not found
    Backend -->> Frontend: return Login Fail
  end
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)
sequenceDiagram
  participant Frontend
  participant Backend
  participant Database

  note over Frontend: On Browser
  Frontend ->> Backend: POST /api/login<br>(email, password)
  Backend ->> Database: Get User data
  Database -->> Backend: return user data
  alt User found
    Backend ->> Backend: write User session
    Backend ->> Frontend: return Login success
  else User not found
    Backend -->> Frontend: return Login Fail
  end
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 เก็บไว้

sequenceDiagram
  participant Frontend
  participant Backend
  participant Database

  note over Frontend: On Browser
  Frontend ->> Backend: GET /api/users<br> with Bearer <token> <br> (token from localstorage)
  note over Backend: check JWT Token (Authentication)
  alt Authen pass
    Backend ->> Backend: write req.user with user data
    Backend ->> Database: get Users
    Database -->> Backend: return users
    Backend -->> Frontend: return users
  else Authen fail
    Backend -->> Frontend: return 403 Fobbidded permission
  end
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);
}
};
sequenceDiagram
  participant Frontend
  participant Backend
  participant Database

  note over Frontend: On Browser
  Frontend ->> Backend: GET /api/users<br> with Cookie (token)
  note over Backend: check JWT Token (Authentication)
  alt Authen pass
    Backend ->> Backend: write req.user with user data
    Backend ->> Database: get Users
    Database -->> Backend: return users
    Backend -->> Frontend: return users
  else Authen fail
    Backend -->> Frontend: return 403 Fobbidded permission
  end
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

sequenceDiagram
  participant Frontend
  participant Backend
  participant Database

  note over Frontend: On Browser
  Frontend ->> Backend: GET /api/users
  note over Backend: check Session User
  alt found Session User
    Backend ->> Backend: write req.user with user data
    Backend ->> Database: get Users
    Database -->> Backend: return users
    Backend -->> Frontend: return users
  else Not found session user
    Backend -->> Frontend: return 403 Fobbidded permission
  end
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 ก็แกะมาได้)

ข้อดี

  • 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 อื่นๆ

  1. https://dev.to/honeybadger/complete-guide-to-authentication-in-javascript-3576 (ในบทความนี้มี refreshtoken)

Related Post
  • มาลองเล่น Apps SDK บน ChatGPT กัน
    มี Video มี Github
    สร้าง App บน ChatGPT! เรียนรู้หลักการของ Apps SDK และ MCP Protocol พร้อม Demo สร้าง UI Component ด้วย React และ Typescript แสดงผลและสื่อสารกับ ChatGPT โดยตรง
  • มาทำ Authentication ด้วย NestJS และ Passport กัน
    มี Video
    เรียนรู้การผสานพลังระหว่าง NestJS framework ยอดนิยมฝั่ง Node.js กับ Passport
  • รู้จักกับ React Hook และ Component
    มี Video
    พาทัวร์ feature ต่างๆของ React กันแบบรวดเร็วกัน สำรวจไปทุกๆ feature พร้อมกัน
  • NoSQL, MongoDB และ ODM
    มี Video
    พามารู้จักกับ NoSQL พื้นฐาน database อีกตัวหนึ่ง ว่ามันคืออะไร มันเกิดขึ้นมาจากโจทย์อะไร มีลักษณะที่แตกต่างกับ SQL และมีวิธีการใช้งานที่ต่างกับ SQL ยังไงบ้าง

Share on social media