บทที่ 3backend|21 KB

บทที่ 3: ระบบ Authentication (JWT & bcrypt)

บทที่ 3: ระบบ Authentication (JWT & bcrypt)

🎯 เป้าหมายการเรียนรู้

หลังจากเรียนบทนี้แล้ว คุณจะสามารถ:

  • เข้าใจหลักการทำงานของ Authentication และ Authorization
  • Hash รหัสผ่านด้วย bcrypt เพื่อความปลอดภัย
  • สร้างและตรวจสอบ JWT (JSON Web Token)
  • สร้าง API endpoints สำหรับ Register และ Login
  • ป้องกัน Routes ด้วย Authentication Middleware

📚 ทฤษฎีและแนวคิด

Authentication vs Authorization

Authentication (การยืนยันตัวตน)Authorization (การอนุญาต)
"คุณคือใคร?""คุณทำอะไรได้บ้าง?"
Login ด้วย email/passwordตรวจสอบ role (user/admin)
ตรวจสอบ credentialsตรวจสอบ permissions

ตัวอย่าง:

  • 🔐 Authentication: ผู้ใช้ login ด้วย email และ password
  • 🛡️ Authorization: เฉพาะ admin เท่านั้นที่เข้าถึง admin dashboard ได้

Password Hashing คืออะไร?

ห้าม เก็บรหัสผ่านแบบ plain text ใน Database!

// ❌ อันตราย!
{ password: "123456" }

// ✅ ปลอดภัย
{ passwordHash: "$2b$10$N9qo8uLOickgx2ZMRZoMye..." }

bcrypt คือ algorithm สำหรับ hash รหัสผ่าน

คุณสมบัติ:

  • 🔒 One-way function (hash ได้ แต่ decrypt ไม่ได้)
  • 🧂 Salt (เพิ่มความสุ่ม ทำให้ hash ของรหัสผ่านเดียวกันไม่เหมือนกัน)
  • ⏱️ Slow by design (ป้องกัน brute-force attacks)

JWT (JSON Web Token) คืออะไร?

JWT คือ Token ที่ใช้สำหรับ Authentication แบบ stateless

โครงสร้าง JWT:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfaWQiOiI2NWE...
│                                      │
│         Header (Base64)              │
                                       │
                  eyJ1c2VySWQiOiIxMjM0NTY3ODkwIiwibmFtZSI6Ikpva...
                  │
                  │         Payload (Base64)
                                                 │
                                    HsV3K9qLmNoPqRsTuVwXyZ...
                                    │
                                    │         Signature

ส่วนประกอบ:

  1. Header: ข้อมูลเกี่ยวกับ algorithm
  2. Payload: ข้อมูลที่ต้องการเก็บ (user ID, role, etc.)
  3. Signature: ลายเซ็นเพื่อตรวจสอบความถูกต้อง

💻 โค้ดจริงจาก QUOM App

1. Register Endpoint (สมัครสมาชิก)

ไฟล์: backend/src/routes/auth.js

import { Router } from "express";
import bcrypt from "bcrypt";
import jwt from "jsonwebtoken";
import { z } from "zod";
import { User } from "../models/User.js";

export const authRouter = Router();

authRouter.post("/register", async (req, res) => {
  // 1. Validate input data
  const schema = z.object({
    email: z.string().email(),
    password: z.string().min(6),
    displayName: z.string().optional()
  });
  const data = schema.parse(req.body);

  // 2. Check if email already exists
  const exists = await User.findOne({ email: data.email });
  if (exists) {
    return res.status(409).json({ 
      ok: false, 
      message: "Email already used" 
    });
  }

  // 3. Hash password
  const passwordHash = await bcrypt.hash(data.password, 10);
  
  // 4. Create user
  const user = await User.create({
    email: data.email,
    passwordHash,
    displayName: data.displayName || ""
  });

  // 5. Generate JWT token
  const token = jwt.sign(
    { 
      _id: user._id.toString(), 
      email: user.email, 
      role: user.role 
    }, 
    process.env.JWT_SECRET, 
    { expiresIn: "30d" }
  );
  
  // 6. Send response
  res.json({ 
    ok: true, 
    token, 
    user: { 
      _id: user._id, 
      email: user.email, 
      displayName: user.displayName, 
      role: user.role,
      profile: user.profile 
    } 
  });
});

อธิบายทีละบรรทัด (Imports):

  • import { Router } from "express"

    • นำเข้า Router เพื่อแยกไฟล์ routing (ไม่ให้ code รกใน server.js)
  • import bcrypt from "bcrypt"

    • Library สำหรับเข้ารหัสรหัสผ่าน (Hashing) ที่มีความปลอดภัยสูง
  • import jwt from "jsonwebtoken"

    • ใช้สร้าง (Sign) และตรวจสอบ (Verify) Token สำหรับยืนยันตัวตน
  • import { z } from "zod"

    • Library สำหรับ Validate ข้อมูล (ตรวจสอบว่า email ถูก pattern ไหม, password ครบกี่ตัว)
  • import { User } from "../models/User.js"

    • เรียกใช้ User Model เพื่อติดต่อกับฐานข้อมูล

อธิบาย Step-by-Step:

Step 1: Validate Input Data

const schema = z.object({
  email: z.string().email(),
  password: z.string().min(6),
  displayName: z.string().optional()
});
const data = schema.parse(req.body);

คำอธิบาย:

  • ใช้ Zod library สำหรับ validation
  • z.string().email(): ตรวจสอบว่าเป็น email format
  • z.string().min(6): รหัสผ่านต้องยาวอย่างน้อย 6 ตัวอักษร
  • z.string().optional(): displayName ไม่บังคับ
  • schema.parse(): ถ้า validation ผ่าน จะ return ข้อมูล, ถ้าไม่ผ่านจะ throw error

Step 2: Check Duplicate Email

const exists = await User.findOne({ email: data.email });
if (exists) {
  return res.status(409).json({ 
    ok: false, 
    message: "Email already used" 
  });
}

คำอธิบาย:

  • หา User ที่มี email เดียวกัน
  • ถ้าเจอ return status 409 (Conflict)
  • ป้องกันการสมัครซ้ำ

Step 3: Hash Password

const passwordHash = await bcrypt.hash(data.password, 10);

คำอธิบาย:

  • bcrypt.hash(password, saltRounds): hash รหัสผ่าน
  • saltRounds = 10: จำนวนรอบในการ hash (ยิ่งมากยิ่งปลอดภัย แต่ช้า)
  • ตัวอย่าง output:
    $2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
    

Step 4: Create User

const user = await User.create({
  email: data.email,
  passwordHash,
  displayName: data.displayName || ""
});

คำอธิบาย:

  • สร้าง User ใหม่ใน Database
  • เก็บ passwordHash แทน password
  • ถ้าไม่มี displayName ใช้ empty string

Step 5: Generate JWT Token

const token = jwt.sign(
  { 
    _id: user._id.toString(), 
    email: user.email, 
    role: user.role 
  }, 
  process.env.JWT_SECRET, 
  { expiresIn: "30d" }
);

คำอธิบาย:

  • jwt.sign(payload, secret, options): สร้าง JWT token
  • Payload: ข้อมูลที่ต้องการเก็บใน token (user ID, email, role)
  • Secret: รหัสลับสำหรับเซ็น token (เก็บใน .env)
  • expiresIn: token หมดอายุใน 30 วัน

⚠️ ข้อควรระวัง:

  • ห้ามเก็บข้อมูลลับใน payload (เช่น password)
  • ห้ามเก็บข้อมูลมากเกินไป (token จะใหญ่)

Step 6: Send Response

res.json({ 
  ok: true, 
  token, 
  user: { 
    _id: user._id, 
    email: user.email, 
    displayName: user.displayName, 
    role: user.role,
    profile: user.profile 
  } 
});

คำอธิบาย:

  • ส่ง token และข้อมูล user กลับไป
  • Frontend จะเก็บ token ไว้ใช้ในการเรียก API ครั้งต่อไป

2. Login Endpoint (เข้าสู่ระบบ)

authRouter.post("/login", async (req, res) => {
  try {
    // 1. Validate input
    const schema = z.object({ 
      email: z.string().email(), 
      password: z.string() 
    });
    const data = schema.parse(req.body);

    // 2. Find user by email
    const user = await User.findOne({ email: data.email });
    if (!user) {
      return res.status(401).json({ 
        ok: false, 
        message: "Invalid credentials" 
      });
    }

    // 3. Verify password
    const ok = await bcrypt.compare(data.password, user.passwordHash);
    if (!ok) {
      return res.status(401).json({ 
        ok: false, 
        message: "Invalid credentials" 
      });
    }

    // 4. Check JWT_SECRET
    if (!process.env.JWT_SECRET) {
      console.error("JWT_SECRET is not defined");
      return res.status(500).json({ 
        ok: false, 
        message: "Server configuration error" 
      });
    }

    // 5. Generate token
    const token = jwt.sign(
      { 
        _id: user._id.toString(), 
        email: user.email, 
        role: user.role 
      }, 
      process.env.JWT_SECRET, 
      { expiresIn: "30d" }
    );
    
    // 6. Send response
    res.json({ 
      ok: true, 
      token, 
      user: { 
        _id: user._id, 
        email: user.email, 
        displayName: user.displayName, 
        role: user.role, 
        profile: user.profile 
      } 
    });
  } catch (error) {
    console.error("Login error:", error);
    if (error.name === 'ZodError') {
      return res.status(400).json({ 
        ok: false, 
        message: "Invalid input data" 
      });
    }
    res.status(500).json({ 
      ok: false, 
      message: "Login failed. Please try again." 
    });
  }
});

อธิบายส่วนสำคัญ:

Password Verification

const ok = await bcrypt.compare(data.password, user.passwordHash);

คำอธิบาย:

  • bcrypt.compare(plainPassword, hash): เปรียบเทียบรหัสผ่าน
  • return true ถ้าตรงกัน, false ถ้าไม่ตรง
  • ไม่สามารถ decrypt hash กลับเป็น plain text ได้

Error Handling

try {
  // ... code
} catch (error) {
  if (error.name === 'ZodError') {
    return res.status(400).json({ 
      ok: false, 
      message: "Invalid input data" 
    });
  }
  res.status(500).json({ 
    ok: false, 
    message: "Login failed. Please try again." 
  });
}

คำอธิบาย:

  • จับ errors ทั้งหมด
  • ถ้าเป็น Zod validation error → status 400
  • ถ้าเป็น error อื่น → status 500

3. Authentication Middleware

ไฟล์: backend/src/middleware/auth.js

import jwt from "jsonwebtoken";

export function auth(req, res, next) {
  try {
    // 1. Get token from header
    const authHeader = req.headers.authorization;
    if (!authHeader) {
      return res.status(401).json({ 
        ok: false, 
        message: "No token provided" 
      });
    }

    // 2. Extract token (format: "Bearer <token>")
    const token = authHeader.startsWith("Bearer ") 
      ? authHeader.slice(7) 
      : authHeader;

    // 3. Verify token
    const decoded = jwt.verify(token, process.env.JWT_SECRET);

    // 4. Attach user info to request
    req.user = decoded;

    // 5. Continue to next middleware/route
    next();
  } catch (error) {
    if (error.name === "TokenExpiredError") {
      return res.status(401).json({ 
        ok: false, 
        message: "Token expired" 
      });
    }
    return res.status(401).json({ 
      ok: false, 
      message: "Invalid token" 
    });
  }
}

อธิบาย Step-by-Step:

Step 1: Get Token from Header

const authHeader = req.headers.authorization;
if (!authHeader) {
  return res.status(401).json({ 
    ok: false, 
    message: "No token provided" 
  });
}

คำอธิบาย:

  • อ่าน Authorization header
  • ตัวอย่าง: Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

Step 2: Extract Token

const token = authHeader.startsWith("Bearer ") 
  ? authHeader.slice(7) 
  : authHeader;

คำอธิบาย:

  • ถ้าขึ้นต้นด้วย "Bearer " ให้ตัดออก (เหลือแค่ token)
  • slice(7): ตัดตั้งแต่ตัวอักษรที่ 7 เป็นต้นไป

Step 3: Verify Token

const decoded = jwt.verify(token, process.env.JWT_SECRET);

คำอธิบาย:

  • jwt.verify(token, secret): ตรวจสอบ token
  • ถ้า token ถูกต้อง จะ return payload
  • ถ้า token ไม่ถูกต้องหรือหมดอายุ จะ throw error

ตัวอย่าง decoded:

{
  _id: "65a1b2c3d4e5f6g7h8i9j0k1",
  email: "user@example.com",
  role: "user",
  iat: 1705747200,  // issued at
  exp: 1708339200   // expiration
}

Step 4: Attach to Request

req.user = decoded;

คำอธิบาย:

  • เก็บข้อมูล user ไว้ใน req.user
  • Route handlers สามารถเข้าถึง req.user ได้

Step 5: Continue

next();

คำอธิบาย:

  • เรียก middleware หรือ route handler ถัดไป

4. การใช้งาน Auth Middleware

import { auth } from "./middleware/auth.js";

// Protected route (ต้อง login)
app.get("/api/profile", auth, async (req, res) => {
  const user = await User.findById(req.user._id);
  res.json({ user });
});

// Public route (ไม่ต้อง login)
app.get("/api/public", (req, res) => {
  res.json({ message: "Public data" });
});

🔄 Authentication Flow Diagram

┌─────────────┐
│   Client    │
└──────┬──────┘
       │
       │ 1. POST /api/auth/register
       │    { email, password }
       ▼
┌─────────────────────┐
│   Auth Router       │
│  ┌──────────────┐   │
│  │ Validate     │   │
│  └──────┬───────┘   │
│         │           │
│  ┌──────▼───────┐   │
│  │ Hash Password│   │
│  └──────┬───────┘   │
│         │           │
│  ┌──────▼───────┐   │
│  │ Create User  │   │
│  └──────┬───────┘   │
│         │           │
│  ┌──────▼───────┐   │
│  │ Generate JWT │   │
│  └──────┬───────┘   │
└─────────┼───────────┘
          │
          │ 2. Return { token, user }
          ▼
┌─────────────┐
│   Client    │
│ Store token │
└──────┬──────┘
       │
       │ 3. GET /api/profile
       │    Authorization: Bearer <token>
       ▼
┌─────────────────────┐
│  Auth Middleware    │
│  ┌──────────────┐   │
│  │ Verify Token │   │
│  └──────┬───────┘   │
│         │           │
│  ┌──────▼───────┐   │
│  │ Attach User  │   │
│  └──────┬───────┘   │
└─────────┼───────────┘
          │
          │ 4. Continue to route
          ▼
┌─────────────────────┐
│   Route Handler     │
│  Access req.user    │
└─────────┬───────────┘
          │
          │ 5. Return user data
          ▼
┌─────────────┐
│   Client    │
└─────────────┘

🛡️ Security Best Practices

1. Password Security

// ✅ ดี
const passwordHash = await bcrypt.hash(password, 10);

// ❌ อันตราย
const passwordHash = password; // ห้ามเก็บ plain text!

2. JWT Secret

// ✅ ดี - เก็บใน .env
JWT_SECRET=your-super-secret-key-min-32-chars

// ❌ อันตราย - hard-code ในโค้ด
const secret = "123456";

3. Token Expiration

// ✅ ดี - กำหนดเวลาหมดอายุ
jwt.sign(payload, secret, { expiresIn: "30d" });

// ❌ อันตราย - ไม่หมดอายุ
jwt.sign(payload, secret);

4. Error Messages

// ✅ ดี - ข้อความทั่วไป
return res.status(401).json({ message: "Invalid credentials" });

// ❌ อันตราย - เปิดเผยข้อมูล
return res.status(401).json({ message: "Password is incorrect" });
// ↑ Hacker รู้ว่า email ถูกต้อง

📝 แบบฝึกหัด

คำถามทบทวน

  1. ทำไมต้อง hash รหัสผ่าน?
  2. JWT ประกอบด้วยส่วนอะไรบ้าง?
  3. bcrypt.compare() ทำงานอย่างไร?
  4. Middleware auth ทำหน้าที่อะไร?
  5. ทำไมต้องกำหนด expiresIn ให้ JWT?

โจทย์ปฏิบัติ

โจทย์ที่ 1: สร้าง Logout Endpoint

// TODO: สร้าง POST /api/auth/logout
// Hint: JWT เป็น stateless, ต้องให้ client ลบ token

โจทย์ที่ 2: สร้าง Change Password Endpoint

// TODO: สร้าง POST /api/auth/change-password
// Input: { oldPassword, newPassword }
// 1. Verify old password
// 2. Hash new password
// 3. Update user

โจทย์ที่ 3: สร้าง Admin Middleware

// TODO: สร้าง middleware ที่ตรวจสอบว่า user เป็น admin
// ใช้หลัง auth middleware
// ตรวจสอบ req.user.role === "admin"

📌 สรุป

ในบทนี้เราได้เรียนรู้:

✅ bcrypt ใช้ hash รหัสผ่านเพื่อความปลอดภัย
✅ JWT ใช้สำหรับ stateless authentication
✅ Register สร้าง user ใหม่และ generate token
✅ Login ตรวจสอบ credentials และ generate token
✅ Auth Middleware ป้องกัน routes ที่ต้อง login
✅ Security Best Practices ป้องกันช่องโหว่ด้านความปลอดภัย

ไฟล์ที่เกี่ยวข้อง


🔜 บทถัดไป

บทที่ 4: API Routes และ CRUD Operations
เราจะเรียนรู้การสร้าง RESTful API สำหรับจัดการข้อมูล Notes


📚 แหล่งเรียนรู้เพิ่มเติม