รู้จักรูปแบบ 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

DatabaseBackendFrontendDatabaseBackendFrontendOn Browseruse email = create JWTtokencollect JWTtoken in localstoragealt[User found][User not found]POST /api/login(email, password)Get User datareturn user datareturn Login success with JWTtokenreturn Login Fail

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 });
});

DatabaseBackendFrontendDatabaseBackendFrontendOn Browseruse email = create JWTtokenalt[User found][User not found]POST /api/login(email, password)Get User datareturn user datawrite JWTtoken to cookie (with Backend)return Login successreturn Login Fail

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)

DatabaseBackendFrontendDatabaseBackendFrontendOn Browseralt[User found][User not found]POST /api/login(email, password)Get User datareturn user datawrite User sessionreturn Login successreturn Login Fail

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 เก็บไว้

DatabaseBackendFrontendDatabaseBackendFrontendOn Browsercheck JWT Token (Authentication)alt[Authen pass][Authen fail]GET /api/users with Bearer <token> (token from localstorage)write req.user with user dataget Usersreturn usersreturn usersreturn 403 Fobbidded permission

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);
}
};

DatabaseBackendFrontendDatabaseBackendFrontendOn Browsercheck JWT Token (Authentication)alt[Authen pass][Authen fail]GET /api/users with Cookie (token)write req.user with user dataget Usersreturn usersreturn usersreturn 403 Fobbidded permission

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

DatabaseBackendFrontendDatabaseBackendFrontendOn Browsercheck Session Useralt[found Session User][Not found session user]GET /api/userswrite req.user with user dataget Usersreturn usersreturn usersreturn 403 Fobbidded permission

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

Share on social media