Security Authentication Authorization

Authentication & Authorization — ระบบยืนยันตัวตนและจัดการสิทธิ์อย่างปลอดภัย 🔐

By Anirach Mingkhwan DevOps & Vibe Coding 2026
Authentication & Authorization

ทุก App ที่คุณใช้ทุกวันต้องรู้ว่าคุณคือใคร และควรเข้าถึงอะไรได้บ้าง — LINE (login + ดูได้แค่ chat ของตัวเอง), Banking App (OTP + ดูได้แค่บัญชีตัวเอง), GitHub (OAuth + edit ได้แค่ repo ที่มีสิทธิ์) 🔐

🚨 "99% of data breaches = authentication/authorization failures" — ระบบ auth ที่อ่อนแอ = ประตูหน้าบ้านที่เปิดค้างไว้

🐕 Authentication vs Authorization — ต่างกันยังไง?

คนส่วนใหญ่มักเข้าใจผิดว่า Authentication กับ Authorization เป็นเรื่องเดียวกัน แต่จริงๆ แล้วมันต่างกันมาก! Authentication (AuthN) คือการพิสูจน์ว่า "คุณคือใครจริงๆ" ส่วน Authorization (AuthZ) คือการกำหนดว่า "คุณทำอะไรได้บ้างหลังจากที่เรารู้แล้วว่าคุณคือใคร"

ลองนึกภาพโรงแรม — Authentication เหมือนการที่คุณต้องแสดง Passport ตอนเช็คอิน (พิสูจน์ตัวตน) และได้รับ Key Card Authorization เหมือนการที่ Key Card ตัวนั้นเปิดได้แค่ห้อง 401 ของคุณ ไม่ใช่ห้องอื่น และเข้า Pool ได้แค่เวลา 6-20 น. เท่านั้น

ทำไมต้องมีทั้งสองอย่าง? เพราะแค่รู้ว่าคุณคือใครไม่พอ — ต้องกำหนดสิทธิ์ด้วยว่าคุณเข้าถึงอะไรได้บ้าง ไม่งั้นทุกคนจะเข้าถึงทุกอย่างได้หมดแบบ Admin ซึ่งเป็นความเสี่ยงอย่างมาก

🔑 Authentication (AuthN)
"คุณคือใคร?"
Username + Password
Biometric (ลายนิ้วมือ)
OTP/MFA
Social Login (Google/GitHub)
🏨 Hotel Check-in
"พิสูจน์ตัวตนด้วย passport"
"ลงทะเบียนเช็คอิน"
"ได้ key card"
🛡️ Authorization (AuthZ)
"คุณทำอะไรได้?"
Admin: ✅✅✅
Editor: ✅✅❌
User: ✅❌❌
🔑 Room Key Card
"เข้าได้แค่ห้อง 401"
"เข้า pool ได้ตอน 6-20"
"เข้า gym ไม่ได้ (ไม่ได้จ่ายเพิ่ม)"

สิ่งสำคัญ: Authentication ต้องมาก่อน → แล้วค่อย Authorization


🔑 Password Security — ทำยังไงให้ปลอดภัย

Password Security เป็นหัวใจสำคัญของระบบ Authentication — หากทำผิด จะส่งผลร้ายต่อผู้ใช้ทุกคน การเก็บ password แบบ plaintext คือความผิดที่ร้ายแรงที่สุด เพราะหาก database รั่วไหล ผู้โจมตีจะได้ password จริงของทุกคนไปใช้ทันที

แม้แต่การใช้ Hash algorithms แบบเก่าอย่าง MD5 หรือ SHA-256 ก็ยังไม่ปลอดภัย เพราะสามารถใช้ Rainbow Tables (ตารางที่เก็บ password ยอดนิยมกับ hash value ที่ถูกคำนวณไว้แล้ว) มา crack ได้ภายในวินาที

วิธีแก้คือการใช้ algorithms ที่ออกแบบมาให้ "ช้าโดยเจตนา" อย่าง bcrypt, scrypt หรือ Argon2 เพื่อทำให้การ brute force ใช้เวลานานขึ้นมาก พร้อมกับ Salt (ข้อมูลสุ่มที่เพิ่มเข้าไปก่อน hash) เพื่อให้ password เดียวกันได้ hash ที่ต่างกันในแต่ละ user

Password Hashing — ห้ามเก็บ plaintext เด็ดขาด!

❌ NEVER DO THIS

users table:

email password
[email protected] MySecret123 ← Plaintext!
[email protected] password456 ← Plaintext!

ถ้าโดน hack → ได้ password ทุกคนเลย 💀

❌ DON'T: Simple hash (MD5, SHA-256)

MD5("MySecret123") → "8b1a9953c4611296..."

ปัญหา: Rainbow table attack ได้ง่าย

✅ DO: bcrypt / scrypt / Argon2

bcrypt("MySecret123", salt) → "$2b$12$LJ3m4ys3Rz.../K0Zy6VeO3Gk2..."

  • Salt unique ทุก user (random)
  • Slow by design (cost factor: 12 rounds)
  • Rainbow tables ใช้ไม่ได้
  • GPU brute-force ยากมาก

Implementation — bcrypt

// ═══════════════════════════════
// Node.js — bcrypt
// ═══════════════════════════════
import bcrypt from 'bcrypt';

// Registration — Hash password
const SALT_ROUNDS = 12;  // Cost factor (10-12 for production)

async function registerUser(email: string, password: string) {
  // Validate password strength
  if (password.length < 8) throw new Error('Password too short');
  if (!/[A-Z]/.test(password)) throw new Error('Need uppercase');
  if (!/[0-9]/.test(password)) throw new Error('Need number');

  // Hash with bcrypt (auto-generates salt)
  const passwordHash = await bcrypt.hash(password, SALT_ROUNDS);
  // → "$2b$12$LJ3m4ys3Rz0KZ1u8Hn5YHe..."

  // Store ONLY the hash
  await db.query(
    'INSERT INTO users (email, password_hash) VALUES ($1, $2)',
    [email, passwordHash]
  );
}

// Login — Verify password
async function loginUser(email: string, password: string) {
  const user = await db.query(
    'SELECT * FROM users WHERE email = $1', [email]
  );

  if (!user) throw new Error('Invalid credentials');

  // bcrypt.compare handles salt extraction automatically
  const isValid = await bcrypt.compare(password, user.password_hash);

  if (!isValid) throw new Error('Invalid credentials');
  // ⚠️ Same error message! Don't reveal if email exists

  return user;
}
// ═══════════════════════════════
// Python — bcrypt / passlib
// ═══════════════════════════════
from passlib.hash import bcrypt

# Hash
password_hash = bcrypt.hash("MySecret123")
# → "$2b$12$LJ3m4ys3Rz..."

# Verify
is_valid = bcrypt.verify("MySecret123", password_hash)
# → True

// ═══════════════════════════════
// Python — Argon2 (recommended for new projects)
// ═══════════════════════════════
from argon2 import PasswordHasher

ph = PasswordHasher(
    time_cost=3,       # iterations
    memory_cost=65536,  # 64 MB
    parallelism=4       # threads
)

# Hash
hash = ph.hash("MySecret123")
# → "$argon2id$v=19$m=65536,t=3,p=4$..."

# Verify
try:
    ph.verify(hash, "MySecret123")  # → True
except VerifyMismatchError:
    print("Invalid password")

อธิบาย SALT_ROUNDS: ค่านี้กำหนดว่าจะทำ hashing กี่รอบ — ยิ่งเยอะยิ่งช้า ยิ่งปลอดภัย แต่ใช้ CPU มากขึ้น สำหรับ production แนะนำ 10-12 rounds (ประมาณ 100-300ms ต่อครั้ง) ห้ามใช้ต่ำกว่า 10!

ทำไม error message ต้องเหมือนกัน: ไม่ว่า email จะไม่มีในระบบ หรือ password ผิด ต้อง return error เดียวกัน ("Invalid credentials") เพื่อไม่ให้ attacker รู้ว่า email ไหนมีในระบบบ้าง (Username Enumeration Attack)

Password Hashing Algorithms Comparison

การเลือก password hashing algorithm ต้องพิจารณา 3 ปัจจัย: ความปลอดภัย, ความเร็ว, และ การใช้ memory Algorithms เก่าอย่าง MD5/SHA ถูกออกแบบมาให้เร็ว จึงไม่เหมาะสำหรับ password ส่วน Algorithms ใหม่ถูกออกแบบมาให้ช้า เพื่อทำให้การ brute force ยากขึ้น

Algorithm Speed Memory Security Use Case
MD5 ⚡ Fast Low ❌ Broken Never for pwd
SHA-256 ⚡ Fast Low ❌ Weak Checksums only
bcrypt 🐢 Slow Low ✅ Good Most apps
scrypt 🐢 Slow 🔴 High ✅ Better High-security
Argon2id 🐢 Slow 🔴 High ✅ Best New projects
💡 Recommendation 2026:
├── New projects → Argon2id
├── Existing projects → bcrypt (still very good)
└── Avoid → MD5, SHA-*, PBKDF2 (for passwords)

🎟️ Session vs Token — เก็บ state ยังไง?

หลังจาก Authentication สำเร็จแล้ว ระบบต้องจำได้ว่าผู้ใช้คนนี้ล็อกอินแล้ว ไม่งั้นจะต้องล็อกอินใหม่ทุก request มีวิธีเก็บ state นี้ 2 แบบหลัก: Session-based (เก็บข้อมูลฝั่ง server) และ Token-based (เก็บข้อมูลฝั่ง client)

Session-based Authentication

Session-based authentication คือวิธีแบบดั้งเดิมที่เก็บข้อมูล user ไว้ฝั่ง server (ใน database หรือ Redis) แล้วส่ง Session ID กลับไปให้ client ใน Cookie เมื่อ client ส่ง request ครั้งถัดไป server จะใช้ Session ID นี้ค้นหาข้อมูล user จาก database

Cookies สำคัญยังไง: httpOnly flag ทำให้ JavaScript อ่าน cookie ไม่ได้ (ป้องกัน XSS), secure flag ทำให้ส่งผ่าน HTTPS เท่านั้น, sameSite=strict ป้องกัน CSRF attacks การตั้งค่าเหล่านี้จึงเป็นเรื่องสำคัญมาก

// ═══════════════════════════════
// Express.js — Session-based
// ═══════════════════════════════
import session from 'express-session';
import RedisStore from 'connect-redis';
import { createClient } from 'redis';

const redisClient = createClient({ url: 'redis://localhost:6379' });
await redisClient.connect();

app.use(session({
  store: new RedisStore({ client: redisClient }),
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: {
    secure: true,        // HTTPS only
    httpOnly: true,      // ป้องกัน XSS อ่าน cookie
    sameSite: 'strict',  // ป้องกัน CSRF
    maxAge: 24 * 60 * 60 * 1000,  // 24 hours
  }
}));

// Login
app.post('/login', async (req, res) => {
  const { email, password } = req.body;
  const user = await verifyCredentials(email, password);

  // Store user info in session
  req.session.userId = user.id;
  req.session.role = user.role;

  res.json({ message: 'Logged in' });
});

// Protected route
app.get('/api/profile', (req, res) => {
  if (!req.session.userId) {
    return res.status(401).json({ error: 'Not authenticated' });
  }
  // ... return profile
});

// Logout
app.post('/logout', (req, res) => {
  req.session.destroy();
  res.clearCookie('connect.sid');
  res.json({ message: 'Logged out' });
});
Session-based Authentication Flow
1
Login
Client ส่ง POST /login { email, password } → Server verify password → สร้าง session → เก็บใน DB/Redis
2
Set Cookie
Server ส่ง Set-Cookie: sessionId=abc123 กลับไป
3
Subsequent Requests
Client ส่ง Cookie: sessionId=abc123 → Server lookup session ใน DB/Redis → return user data
4
Logout
Client ส่ง POST /logout → Server ลบ session จาก DB/Redis → Clear cookie

Token-based Authentication (JWT)

Token-based authentication ใช้ JSON Web Tokens (JWT) ซึ่งเป็นวิธีการ stateless — หมายความว่า server ไม่ต้องเก็บข้อมูล session ใดๆ เลย เพราะข้อมูลทั้งหมดอยู่ใน token ที่ client ถือไว้

ทำไมต้องมี Access + Refresh Token: Access token มีอายุสั้น (15 นาที) เพื่อลดความเสี่ยงหากโดนขโมย ส่วน Refresh token มีอายุยาว (7 วัน) แต่เก็บใน httpOnly cookie และใช้แค่สำหรับขอ access token ใหม่เท่านั้น

ข้อดีคือ server ไม่ต้อง query database ทุกครั้ง (เพราะข้อมูลอยู่ใน JWT แล้ว) ทำให้ scale ง่าย และใช้ได้กับ microservices แต่ข้อเสียคือยาก revoke token ก่อนหมดอายุ

Token-based (JWT) Authentication Flow
1
Login
Client ส่ง POST /login { email, password } → Server verify password → Generate JWT (signed with secret)
2
Return Tokens
Server ส่ง { accessToken, refreshToken } กลับไป
3
Subsequent Requests
Client ส่ง Authorization: Bearer eyJhb... → Server verify JWT signature → decode payload → NO DB lookup!
4
Token Refresh
Client ส่ง POST /refresh { refreshToken } → Server verify refresh token → Issue new access token

JWT Structure — มีอะไรอยู่ข้างใน?

JWT Structure
eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxMjN9.SflKxw...
Header
{ "alg": "HS256", "typ": "JWT" }
Algorithm และ type
Payload
{ "user_id": 123, "email": "[email protected]", "role": "admin", "iat": 1709827200, "exp": 1709830800 }
ข้อมูล user + timestamps
Signature
HMACSHA256( base64(header) + "." + base64(payload), SECRET_KEY )
พิสูจน์ว่าไม่ถูกแก้ไข

⚠️ Payload is NOT encrypted! Anyone can decode it. Only signature proves it's authentic.
❌ NEVER put in JWT: password, credit card, secrets
✅ OK in JWT: user_id, role, permissions

เข้าใจ JWT Structure: Header บอกว่าใช้ algorithm ไหนใน signing, Payload เก็บข้อมูล user และ timestamps, ส่วน Signature เป็นการรับรองว่าข้อมูลไม่ถูกแก้ไข

ข้อระวัง! คนไหนก็สามารถ decode Base64 ใน Payload ได้ (ลองดูได้ที่ jwt.io) ดังนั้นห้ามใส่ข้อมูลลับ เช่น password, credit card number, social security number ใน JWT โดยเด็ดขาด!

JWT Implementation

// ═══════════════════════════════
// Node.js — JWT with Access + Refresh Tokens
// ═══════════════════════════════
import jwt from 'jsonwebtoken';

const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET;   // ใช้ env variable!
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET;
const ACCESS_EXPIRY = '15m';     // 15 minutes
const REFRESH_EXPIRY = '7d';     // 7 days

// ═══════════════════════════════
// Generate Tokens
// ═══════════════════════════════
function generateTokens(user) {
  const accessToken = jwt.sign(
    {
      userId: user.id,
      email: user.email,
      role: user.role,
    },
    ACCESS_SECRET,
    { expiresIn: ACCESS_EXPIRY }
  );

  const refreshToken = jwt.sign(
    { userId: user.id },
    REFRESH_SECRET,
    { expiresIn: REFRESH_EXPIRY }
  );

  return { accessToken, refreshToken };
}

// ═══════════════════════════════
// Login Endpoint
// ═══════════════════════════════
app.post('/api/auth/login', async (req, res) => {
  const { email, password } = req.body;
  const user = await verifyCredentials(email, password);

  const { accessToken, refreshToken } = generateTokens(user);

  // Store refresh token hash in DB (for revocation)
  const refreshHash = await bcrypt.hash(refreshToken, 10);
  await db.query(
    'INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)',
    [user.id, refreshHash, new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)]
  );

  // Set refresh token as httpOnly cookie
  res.cookie('refreshToken', refreshToken, {
    httpOnly: true,
    secure: true,
    sameSite: 'strict',
    maxAge: 7 * 24 * 60 * 60 * 1000,
    path: '/api/auth/refresh',  // Only sent to refresh endpoint
  });

  res.json({ accessToken });
});

// ═══════════════════════════════
// Auth Middleware
// ═══════════════════════════════
function authenticate(req, res, next) {
  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'No token provided' });
  }

  const token = authHeader.split(' ')[1];

  try {
    const decoded = jwt.verify(token, ACCESS_SECRET);
    req.user = decoded;  // { userId, email, role }
    next();
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
    }
    return res.status(401).json({ error: 'Invalid token' });
  }
}

// ═══════════════════════════════
// Refresh Token Endpoint
// ═══════════════════════════════
app.post('/api/auth/refresh', async (req, res) => {
  const refreshToken = req.cookies.refreshToken;
  if (!refreshToken) return res.status(401).json({ error: 'No refresh token' });

  try {
    const decoded = jwt.verify(refreshToken, REFRESH_SECRET);

    // Check refresh token exists in DB (not revoked)
    const stored = await db.query(
      'SELECT * FROM refresh_tokens WHERE user_id = $1 AND expires_at > NOW()',
      [decoded.userId]
    );

    if (!stored.rows.length) {
      return res.status(401).json({ error: 'Refresh token revoked' });
    }

    // Verify against stored hash
    const isValid = await bcrypt.compare(refreshToken, stored.rows[0].token_hash);
    if (!isValid) return res.status(401).json({ error: 'Invalid refresh token' });

    // Get fresh user data
    const user = await db.query('SELECT * FROM users WHERE id = $1', [decoded.userId]);

    // Issue new access token
    const accessToken = jwt.sign(
      { userId: user.id, email: user.email, role: user.role },
      ACCESS_SECRET,
      { expiresIn: ACCESS_EXPIRY }
    );

    res.json({ accessToken });
  } catch (err) {
    return res.status(401).json({ error: 'Invalid refresh token' });
  }
});

// ═══════════════════════════════
// Logout — Revoke refresh token
// ═══════════════════════════════
app.post('/api/auth/logout', authenticate, async (req, res) => {
  // Delete all refresh tokens for this user
  await db.query('DELETE FROM refresh_tokens WHERE user_id = $1', [req.user.userId]);

  res.clearCookie('refreshToken', { path: '/api/auth/refresh' });
  res.json({ message: 'Logged out' });
});

// ═══════════════════════════════
// Protected Route Example
// ═══════════════════════════════
app.get('/api/profile', authenticate, async (req, res) => {
  const user = await db.query('SELECT * FROM users WHERE id = $1', [req.user.userId]);
  res.json({ user });
});

Session vs Token — เลือกยังไง?

Session-based Token-based (JWT)
State stored Server (DB/Redis) Client (token)
Scalability ⚠️ Needs shared session store ✅ Stateless (any server)
Revocation ✅ Easy (delete from DB) ⚠️ Hard (wait for expiry)
Mobile support ⚠️ Cookie issues ✅ Header-based
CSRF risk ⚠️ Yes (cookie) ✅ No
XSS risk ✅ httpOnly ⚠️ localStorage
Performance ⚠️ DB lookup ✅ No DB lookup
Cross-domain ⚠️ Complex ✅ Easy
Best for Traditional web (SSR) SPAs, APIs, mobile, microservices
💡 Recommendation:
├── SPA + API → JWT (access 15min + refresh 7d)
├── Traditional web (SSR) → Session (Redis-backed)
├── Mobile app → JWT
└── Both → JWT for API, Session for admin panel

🌐 OAuth 2.0 — "Login with Google/GitHub"

OAuth 2.0 เป็นมาตรฐานที่ให้ผู้ใช้ล็อกอินด้วยบัญชีจากผู้ให้บริการอื่น (เช่น Google, GitHub, Facebook) โดยไม่ต้องให้ password กับเรา วิธีนี้ปลอดภัยกว่าเพราะเราไม่ต้องจัดการ password และผู้ใช้ไม่ต้องจำ password ใหม่

ทำไม OAuth ถึงปลอดภัย: ระหว่างกระบวนการ user ไม่เคยให้ password กับเรา แต่ให้กับ Google/GitHub โดยตรง เราได้รับแค่ Authorization Code ซึ่งใช้แลกกับ Access Token เพื่อเข้าถึงข้อมูลพื้นฐาน (email, name) เท่านั้น

หลัก OAuth 2.0 Authorization Code Flow เป็น flow ที่ปลอดภัยที่สุด เหมาะสำหรับ web applications แต่ต้องมี backend server เพื่อเก็บ client secret (ห้าม expose ฝั่ง frontend!) และแลก authorization code กับ access token

OAuth 2.0 Authorization Code Flow
1
User clicks "Login with Google"
User กดปุ่ม → Your app redirect ไป Google login page
2
User logs in to Google
User ใส่ Google username/password + approve permissions
3
Google redirects back with auth CODE
Google redirect กลับมา your app พร้อม authorization code
4
Exchange code for ACCESS TOKEN
Your server แลก code กับ Google → ได้ access_token
5
Get user info with access token
Your server เรียก Google API ด้วย access_token → ได้ { name, email }
6
Create/login user in YOUR database
Your server สร้าง/update user → issue YOUR JWT → ส่งกลับ client

OAuth Database Schema: เพื่อรองรับ multiple OAuth providers ต้องออกแบบ database ให้ user หนึ่งคนสามารถมีหลาย auth methods ได้ — เช่น ล็อกอินได้ทั้งด้วย email+password, Google OAuth, และ GitHub OAuth ในบัญชีเดียวกัน

วิธีที่ดีคือมี user_auth_providers table แยกต่างหาก เก็บ mapping ระหว่าง user กับ provider ต่างๆ พร้อมทั้ง provider_id และ access/refresh tokens (ที่ต้อง encrypt ก่อนเก็บ!) วิธีนี้ยืดหยุ่นกว่าการเพิ่ม column `google_id`, `facebook_id` ใน users table

OAuth Implementation with Passport.js

// ═══════════════════════════════
// Google OAuth 2.0 — Passport.js
// ═══════════════════════════════
import passport from 'passport';
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';

passport.use(new GoogleStrategy(
  {
    clientID: process.env.GOOGLE_CLIENT_ID,
    clientSecret: process.env.GOOGLE_CLIENT_SECRET,
    callbackURL: '/api/auth/google/callback',
  },
  async (accessToken, refreshToken, profile, done) => {
    try {
      // Find or create user
      let user = await db.query(
        'SELECT * FROM users WHERE google_id = $1 OR email = $2',
        [profile.id, profile.emails[0].value]
      );

      if (!user.rows.length) {
        // Create new user
        user = await db.query(
          `INSERT INTO users (google_id, email, name, avatar_url, email_verified) 
           VALUES ($1, $2, $3, $4, true) RETURNING *`,
          [profile.id, profile.emails[0].value, profile.displayName, profile.photos[0]?.value]
        );
      } else if (!user.rows[0].google_id) {
        // Link Google account to existing user
        await db.query(
          'UPDATE users SET google_id = $1, avatar_url = COALESCE(avatar_url, $2) WHERE id = $3',
          [profile.id, profile.photos[0]?.value, user.rows[0].id]
        );
      }

      done(null, user.rows[0]);
    } catch (err) {
      done(err, null);
    }
  }
));

// Routes
app.get('/api/auth/google', 
  passport.authenticate('google', { 
    scope: ['profile', 'email'] 
  })
);

app.get('/api/auth/google/callback',
  passport.authenticate('google', { session: false }),
  (req, res) => {
    // Generate YOUR JWT
    const { accessToken, refreshToken } = generateTokens(req.user);

    // Redirect to frontend with token
    res.redirect(`${FRONTEND_URL}/auth/callback?token=${accessToken}`);
  }
);

// ═══════════════════════════════
// GitHub OAuth — Passport.js
// ═══════════════════════════════
import { Strategy as GitHubStrategy } from 'passport-github2';

passport.use(new GitHubStrategy(
  {
    clientID: process.env.GITHUB_CLIENT_ID,
    clientSecret: process.env.GITHUB_CLIENT_SECRET,
    callbackURL: '/api/auth/github/callback',
    scope: ['user:email'],
  },
  async (accessToken, refreshToken, profile, done) => {
    const email = profile.emails?.[0]?.value;
    
    let user = await findOrCreateUser({
      github_id: profile.id,
      email: email,
      name: profile.displayName || profile.username,
      avatar_url: profile.photos[0]?.value,
    });

    done(null, user);
  }
));

app.get('/api/auth/github',
  passport.authenticate('github', { scope: ['user:email'] })
);

app.get('/api/auth/github/callback',
  passport.authenticate('github', { session: false }),
  (req, res) => {
    const { accessToken } = generateTokens(req.user);
    res.redirect(`${FRONTEND_URL}/auth/callback?token=${accessToken}`);
  }
);

Database Schema for OAuth

CREATE TABLE users (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  email VARCHAR(255) UNIQUE NOT NULL,
  name VARCHAR(100),
  avatar_url VARCHAR(500),
  password_hash VARCHAR(255),       -- NULL ถ้า login ผ่าน OAuth only
  email_verified BOOLEAN DEFAULT false,
  
  -- OAuth provider IDs
  google_id VARCHAR(255) UNIQUE,
  github_id VARCHAR(255) UNIQUE,
  facebook_id VARCHAR(255) UNIQUE,
  
  role VARCHAR(20) DEFAULT 'member',
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- Allow multiple auth methods per user
CREATE TABLE user_auth_providers (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  provider VARCHAR(50) NOT NULL,     -- 'google', 'github', 'email'
  provider_id VARCHAR(255) NOT NULL, -- Provider's user ID
  access_token TEXT,                  -- Encrypted!
  refresh_token TEXT,                 -- Encrypted!
  token_expires_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  
  UNIQUE(provider, provider_id)
);

🔒 Multi-Factor Authentication (MFA/2FA)

Multi-Factor Authentication (MFA) หรือ 2FA เป็นชั้นป้องกันเพิ่มเติมที่ต้องใช้ "สิ่งที่คุณรู้" (password) + "สิ่งที่คุณมี" (โทรศัพท์) แม้ว่า password จะรั่วไหล attacker ก็ยังเข้าระบบไม่ได้เพราะไม่มีโทรศัพท์ของคุณ

TOTP (Time-based One-Time Password) ทำงานโดยใช้ shared secret key ร่วมกับ current timestamp ในการสร้างรหัส 6 หลัก ที่เปลี่ยนทุก 30 วินาที ระบบนี้ทำงานแม้ไม่มีอินเทอร์เน็ต เพราะทั้ง server และ authenticator app ใช้นาฬิกาเดียวกัน

MFA with TOTP (Time-based One-Time Password)
Setup:
1
Server generates QR Code
Server สร้าง secret key → แสดงเป็น QR code
2
User scans with Google Authenticator
User scan QR → Authenticator app เก็บ secret
Login:
3
User enters email + password ✅
ผ่าน step 1: Authentication ด้วย password
4
Server asks for TOTP code
Server: "Please enter your 2FA code"
5
User opens Authenticator app
User เปิด app → เห็น: 847 293 (เปลี่ยนทุก 30 วินาที)
6
Server verifies: HMAC(secret, time) == code? ✅
Server คำนวณ TOTP จาก secret + current time → ตรวจสอบว่าตรงกับ code ที่ user ใส่

⚠️ Even if password is stolen: Attacker ยังต้องการ physical phone เพื่อเอา code!

QR Code Setup: เมื่อ user เปิด MFA ครั้งแรก server จะสร้าง secret key และแสดงเป็น QR code ที่ใส่ข้อมูล: service name, user email, และ secret key User scan QR code นี้ด้วย Google Authenticator หรือ Authy เพื่อบันทึก secret

Backup Codes สำคัญมาก! ต้องสร้าง backup codes (เช่น 10 codes แบบใช้ครั้งเดียว) ให้ user เก็บไว้ในที่ปลอดภัย เผื่อโทรศัพท์หาย หรือ authenticator app เสีย โดย backup codes เหล่านี้ต้อง hash ก่อนเก็บใน database เหมือน password

TOTP Implementation

// ═══════════════════════════════
// TOTP Implementation — speakeasy
// ═══════════════════════════════
import speakeasy from 'speakeasy';
import qrcode from 'qrcode';

// 1. Generate secret for user
app.post('/api/auth/mfa/setup', authenticate, async (req, res) => {
  const secret = speakeasy.generateSecret({
    name: `MyApp (${req.user.email})`,
    issuer: 'MyApp',
    length: 32,
  });

  // Store secret temporarily (verify before enabling)
  await db.query(
    'UPDATE users SET mfa_secret_temp = $1 WHERE id = $2',
    [secret.base32, req.user.userId]
  );

  // Generate QR code
  const qrCodeUrl = await qrcode.toDataURL(secret.otpauth_url);

  res.json({
    qrCode: qrCodeUrl,        // Display to user
    manualKey: secret.base32,  // Manual entry fallback
  });
});

// 2. Verify and enable MFA
app.post('/api/auth/mfa/verify', authenticate, async (req, res) => {
  const { code } = req.body;
  
  const user = await db.query(
    'SELECT mfa_secret_temp FROM users WHERE id = $1',
    [req.user.userId]
  );

  const isValid = speakeasy.totp.verify({
    secret: user.rows[0].mfa_secret_temp,
    encoding: 'base32',
    token: code,
    window: 1,  // Allow 1 step before/after (±30 sec)
  });

  if (!isValid) {
    return res.status(400).json({ error: 'Invalid code' });
  }

  // Generate backup codes
  const backupCodes = Array.from({ length: 10 }, () =>
    Math.random().toString(36).substring(2, 10).toUpperCase()
  );

  // Enable MFA
  await db.query(
    `UPDATE users SET 
       mfa_enabled = true, 
       mfa_secret = mfa_secret_temp, 
       mfa_secret_temp = NULL,
       mfa_backup_codes = $1
     WHERE id = $2`,
    [JSON.stringify(backupCodes.map(c => bcrypt.hashSync(c, 10))), req.user.userId]
  );

  res.json({ 
    message: 'MFA enabled!',
    backupCodes,  // Show ONCE, user must save them
  });
});

// 3. Login with MFA
app.post('/api/auth/login', async (req, res) => {
  const { email, password, mfaCode } = req.body;
  
  // Step 1: Verify password
  const user = await verifyCredentials(email, password);
  
  // Step 2: Check if MFA enabled
  if (user.mfa_enabled) {
    if (!mfaCode) {
      return res.status(200).json({ 
        requiresMfa: true,
        message: 'Please enter your 2FA code' 
      });
    }

    // Verify TOTP code
    const isValid = speakeasy.totp.verify({
      secret: user.mfa_secret,
      encoding: 'base32',
      token: mfaCode,
      window: 1,
    });

    if (!isValid) {
      // Check backup codes
      const isBackup = await verifyBackupCode(user.id, mfaCode);
      if (!isBackup) {
        return res.status(401).json({ error: 'Invalid 2FA code' });
      }
    }
  }

  // Step 3: Issue tokens
  const tokens = generateTokens(user);
  res.json(tokens);
});

🛡️ Authorization — RBAC, ABAC, Permissions

หลังจาก Authentication สำเร็จแล้ว ขั้นตอนถัดไปคือ Authorization — การกำหนดว่าผู้ใช้คนนี้ทำอะไรได้บ้าง RBAC (Role-Based Access Control) เป็นรูปแบบที่ใช้กันมากที่สุด โดยกำหนด Roles (บทบาท) แต่ละ Role มี Permissions (สิทธิ์) ต่างๆ

RBAC ทำงานยังไง: แทนที่จะกำหนดสิทธิ์ให้ user โดยตรง เรากำหนด Role ให้ user แล้ว Role นั้นจะมี permissions ชุดหนึ่ง วิธีนี้จัดการง่ายกว่า — เช่น เมื่อต้องการให้ user เป็น editor แค่เปลี่ยน role เดียว ไม่ต้องไปแก้ permissions ทีละตัว

Principle of Least Privilege: ให้สิทธิ์น้อยที่สุดที่จำเป็นต่อการทำงาน — member ไม่ควรลบ post ของคนอื่น, editor ไม่ควรจัดการ user accounts มี middleware ตรวจสอบทั้ง permission และ ownership (เจ้าของ resource)

Role-Based Access Control (RBAC)

RBAC System
admin
users.*
posts.*
admin.*
editor
posts.create
posts.edit
posts.delete (own only)
member
posts.read
profile.edit
guest
posts.read (public only)

RBAC Database Schema ที่ยืดหยุ่น: การออกแบบ database สำหรับ RBAC ต้องคิดถึงอนาคต — user อาจมีหลาย roles, role อาจมี permissions ที่ซับซ้อน ดีที่สุดคือแยก tables: `roles`, `permissions`, `role_permissions` (many-to-many), `user_roles` (many-to-many)

Middleware Pattern: ใน Express.js สร้าง middleware functions เช่น `requirePermission('posts:update')` และ `requireOwnership('user_id')` เพื่อตรวจสอบสิทธิ์ก่อนเข้าถึง route ทำให้โค้ดอ่านง่าย และมั่นใจว่าทุก endpoint ตรวจสอบสิทธิ์

RBAC Implementation

// ═══════════════════════════════
// Permission-based RBAC
// ═══════════════════════════════

const PERMISSIONS = {
  admin: [
    'users:read', 'users:create', 'users:update', 'users:delete',
    'posts:read', 'posts:create', 'posts:update', 'posts:delete',
    'settings:read', 'settings:update',
    'analytics:read',
  ],
  editor: [
    'posts:read', 'posts:create', 'posts:update', 'posts:delete',
    'analytics:read',
  ],
  member: [
    'posts:read', 'posts:create',  // Can create own posts
  ],
  guest: [
    'posts:read',                   // Read-only
  ],
};

// Middleware: Check permission
function requirePermission(...requiredPermissions) {
  return (req, res, next) => {
    const userRole = req.user.role;
    const userPermissions = PERMISSIONS[userRole] || [];

    const hasPermission = requiredPermissions.every(
      perm => userPermissions.includes(perm)
    );

    if (!hasPermission) {
      return res.status(403).json({ 
        error: 'Forbidden',
        message: `Required: ${requiredPermissions.join(', ')}`,
        yourRole: userRole,
      });
    }

    next();
  };
}

// Middleware: Check resource ownership
function requireOwnership(resourceField = 'user_id') {
  return async (req, res, next) => {
    // Admins bypass ownership check
    if (req.user.role === 'admin') return next();

    const resource = await db.query(
      `SELECT ${resourceField} FROM ${req.resourceTable} WHERE id = $1`,
      [req.params.id]
    );

    if (resource.rows[0]?.[resourceField] !== req.user.userId) {
      return res.status(403).json({ error: 'Not your resource' });
    }

    next();
  };
}

// ═══════════════════════════════
// Routes with RBAC
// ═══════════════════════════════

// Anyone can read posts
app.get('/api/posts', 
  authenticate, 
  requirePermission('posts:read'), 
  getPosts
);

// Members+ can create posts
app.post('/api/posts', 
  authenticate, 
  requirePermission('posts:create'), 
  createPost
);

// Editors+ can update any post, members only own posts
app.put('/api/posts/:id', 
  authenticate, 
  requirePermission('posts:update'), 
  requireOwnership('user_id'),  // Editors bypass, members checked
  updatePost
);

// Admin-only routes
app.get('/api/admin/users', 
  authenticate, 
  requirePermission('users:read'), 
  listUsers
);

app.delete('/api/admin/users/:id', 
  authenticate, 
  requirePermission('users:delete'), 
  deleteUser
);

Database Schema for RBAC (Advanced)

-- ═══════════════════════════════
-- Flexible RBAC with DB
-- ═══════════════════════════════

CREATE TABLE roles (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  name VARCHAR(50) UNIQUE NOT NULL,  -- admin, editor, member
  description TEXT,
  is_default BOOLEAN DEFAULT false,   -- Default role for new users
  created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE permissions (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  name VARCHAR(100) UNIQUE NOT NULL,  -- posts:create, users:delete
  description TEXT,
  resource VARCHAR(50) NOT NULL,       -- posts, users, settings
  action VARCHAR(50) NOT NULL,         -- create, read, update, delete
  created_at TIMESTAMPTZ DEFAULT NOW(),
  
  UNIQUE(resource, action)
);

CREATE TABLE role_permissions (
  role_id UUID REFERENCES roles(id) ON DELETE CASCADE,
  permission_id UUID REFERENCES permissions(id) ON DELETE CASCADE,
  PRIMARY KEY (role_id, permission_id)
);

CREATE TABLE user_roles (
  user_id UUID REFERENCES users(id) ON DELETE CASCADE,
  role_id UUID REFERENCES roles(id) ON DELETE CASCADE,
  PRIMARY KEY (user_id, role_id)
);

-- ═══════════════════════════════
-- Query: Get user permissions
-- ═══════════════════════════════
SELECT DISTINCT p.name as permission
FROM users u
JOIN user_roles ur ON u.id = ur.user_id
JOIN roles r ON ur.role_id = r.id
JOIN role_permissions rp ON r.id = rp.role_id
JOIN permissions p ON rp.permission_id = p.id
WHERE u.id = $1;

🔐 Security Best Practices — Checklist

ระบบ Authentication/Authorization มี attack vectors เยอะมาก การป้องกันต้องครอบคลุมทุกด้าน: ป้องกัน brute force, SQL injection, XSS, CSRF, token theft ขาดจุดไหนจุดหนึ่ง ความปลอดภัยทั้งหมดจะพัง

Common Attacks & Prevention

Rate Limiting: endpoint ต่างๆ ต้องมี rate limit ที่เหมาะสม — login endpoint เข้มงวดที่สุด (5 ครั้ง/15นาที) เพราะเป็นเป้าหมายของ brute force, API endpoints ทั่วไปใช้ 100 requests/15นาที, password reset ต้องจำกัดเพื่อป้องกัน DoS

Input Validation: validate ทุก input ทั้ง type, length, format ใช้ whitelist แทน blacklist — เช่น email ต้องตรง regex pattern, password ต้องมี uppercase/lowercase/number/special char

🔴 Brute Force
Rate limiting (5 attempts per minute)
Account lockout after 10 failures
CAPTCHA after 3 failures
Progressive delays (1s, 2s, 4s, 8s...)
🔴 SQL Injection
Parameterized queries ALWAYS
ORM (auto-parameterized)
Input validation (whitelist)
Least-privilege DB accounts
🔴 XSS (Cross-Site Scripting)
Content Security Policy (CSP) headers
httpOnly cookies (JS can't read)
Input sanitization
Output encoding
🔴 CSRF (Cross-Site Request Forgery)
SameSite=Strict cookies
CSRF tokens in forms
Check Origin/Referer headers
JWT in Authorization header (immune)
🔴 Token Theft
Short-lived access tokens (15 min)
httpOnly cookies for refresh tokens
Token rotation on refresh
IP/device binding (optional)

Security Headers ด้วย Helmet: Helmet.js ตั้งค่า security headers ที่สำคัญ — Content Security Policy (CSP) ป้องกัน XSS โดยจำกัดแหล่ง script ที่ browser จะรัน, HSTS บังคับใช้ HTTPS, X-Frame-Options ป้องกัน clickjacking

CORS Configuration: ตั้งค่า CORS ให้เข้มงวด — ระบุ origin ที่อนุญาตชัดเจน (ไม่ใช้ wildcard *), กำหนด allowed headers, credentials: true สำหรับ cookies แต่ต้องระวัง preflight cache

Rate Limiting

// ═══════════════════════════════
// Rate Limiting — express-rate-limit
// ═══════════════════════════════
import rateLimit from 'express-rate-limit';

// General API rate limit
const apiLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,  // 15 minutes
  max: 100,                   // 100 requests per window
  message: { error: 'Too many requests, try again later' },
  standardHeaders: true,
  legacyHeaders: false,
});
app.use('/api/', apiLimiter);

// Strict login rate limit
const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,  // 15 minutes
  max: 5,                     // 5 login attempts
  skipSuccessfulRequests: true,
  message: { error: 'Too many login attempts. Try again in 15 minutes.' },
});
app.use('/api/auth/login', loginLimiter);

// Password reset rate limit
const resetLimiter = rateLimit({
  windowMs: 60 * 60 * 1000,  // 1 hour
  max: 3,                     // 3 reset requests per hour
});
app.use('/api/auth/reset-password', resetLimiter);

Security Headers

// ═══════════════════════════════
// Helmet.js — Security Headers
// ═══════════════════════════════
import helmet from 'helmet';

app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'"],
      styleSrc: ["'self'", "'unsafe-inline'"],
      imgSrc: ["'self'", "data:", "https:"],
      connectSrc: ["'self'", "https://api.myapp.com"],
    },
  },
  hsts: {
    maxAge: 31536000,      // 1 year
    includeSubDomains: true,
    preload: true,
  },
  referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
}));

// CORS configuration
import cors from 'cors';

app.use(cors({
  origin: ['https://myapp.com', 'https://admin.myapp.com'],
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  credentials: true,
  maxAge: 86400,  // Preflight cache 24h
}));

🏢 Single Sign-On (SSO) — Enterprise

SSO = เข้าสู่ระบบ 1 ครั้ง → ใช้ได้ทุก app ในองค์กร

Single Sign-On เป็นมาตรฐานสำหรับองค์กรขนาดใหญ่ เพื่อให้พนักงานไม่ต้องจำ password หลายตัว และ IT team จัดการ user accounts ได้จากจุดเดียว เมื่อพนักงานลาออก ก็ปิดบัญชีครั้งเดียวแล้วเข้าระบบอะไรไม่ได้ทั้งหมด

SAML vs OIDC: SAML 2.0 เป็นมาตรฐานเก่าที่ใช้ XML ซับซ้อนแต่รองรับ enterprise features ครบ ส่วน OpenID Connect (OIDC) เป็นมาตรฐานใหม่ที่สร้างบน OAuth 2.0 ใช้ JSON/JWT อ่านง่ายกว่า เหมาะสำหรับ modern applications

SSO Ecosystem
App A (HR)
App B (Email)
App C (CRM)
SSO Provider
(Okta/Azure/Google)

Login once → All apps authenticated

Protocol Use Case Format
SAML 2.0 Enterprise (legacy) XML-based
OIDC (OpenID Connect) Modern SSO JSON/JWT
OAuth 2.0 API authorization Not SSO per se

Popular Providers: Okta, Auth0 (by Okta), Azure AD (Microsoft Entra ID), Google Workspace, Keycloak (open-source), AWS Cognito


📊 Complete Authentication Architecture — มาดูภาพรวม

ระบบ Authentication/Authorization ที่สมบูรณ์ประกอบด้วยหลายชั้น: Frontend (จัดการ UX + store tokens), API Gateway/Backend (verify tokens + enforce permissions), Database (เก็บ users, roles, sessions)

Frontend Layer: แสดง login form, จัดการ OAuth redirects, เก็บ access token ใน memory (ไม่ใช่ localStorage เพื่อป้องกัน XSS), ใช้ Axios interceptors เพิ่ม Bearer token ทุก request, handle 401 errors ด้วย automatic token refresh

API Gateway Layer: Rate limiting มาก่อน, followed by CORS + security headers, auth middleware ตรวจ JWT signature, RBAC middleware ตรวจ permissions, ถึงจะไปถึง business logic

Database Layer: users table เก็บ basic info + password hash + MFA settings, refresh_tokens table เก็บ valid tokens (for revocation), roles/permissions tables สำหรับ RBAC, login_attempts table สำหรับ track brute force


🔑 Key Takeaways

  1. 1
    Authentication ≠ Authorization → AuthN = "คุณคือใคร?" (ตัวตน), AuthZ = "คุณทำอะไรได้?" (สิทธิ์) — ต้องทำทั้ง 2 อย่าง ขาดอันไหนระบบไม่สมบูรณ์
  2. 2
    bcrypt/Argon2 เท่านั้น → ห้ามเก็บ plaintext เด็ดขาด, ห้าม MD5/SHA (vulnerable to rainbow tables) — Argon2id ดีที่สุดในปี 2026, bcrypt ยังใช้ได้
  3. 3
    JWT = Access (15min) + Refresh (7d) → Access token สั้นเพื่อลดความเสี่ยงหากโดนขโมย, refresh token เก็บ httpOnly cookie เพื่อป้องกัน XSS
  4. 4
    Session vs Token → Traditional web (SSR) ใช้ session + Redis, SPA/API/mobile ใช้ JWT — เลือกตาม architecture และ requirements
  5. 5
    OAuth 2.0 → "Login with Google/GitHub" — ปลอดภัยกว่าเพราะไม่ต้องจัดการ password เอง, ใช้ Authorization Code flow, ต้องมี backend เก็บ client secret
  6. 6
    MFA/2FA → TOTP (Google Authenticator) + backup codes — ป้องกัน password theft, แม้ password รั่วก็ยังเข้าไม่ได้เพราะไม่มีโทรศัพท์
  7. 7
    RBAC → Role → Permissions → Middleware pattern — ตรวจสอบสิทธิ์ทุก endpoint, principle of least privilege, แยก ownership checks
  8. 8
    Rate limiting → 5 login attempts per 15 min — ป้องกัน brute force, endpoint ต่างๆ ใช้ limit ต่างกัน (login < API < reset password)
  9. 9
    Security headers → Helmet.js (CSP, HSTS, X-Frame), CORS configuration — ป้องกัน XSS, CSRF, clickjacking ตั้งค่าให้ครบถ้วน
  10. 10
    Never trust the client → Validate ทุกอย่างฝั่ง server — client-side validation แค่ UX, real validation + authorization ต้องทำฝั่ง backend เสมอ
บทความจากซีรีส์ DevOps & Vibe Coding 2026
← Previous
Database Design & SQL — ออกแบบฐานข้อมูลที่ดีและเขียน SQL อย่างมืออาชีพ
Next →
Web Architecture & Design Patterns — ออกแบบระบบให้ Scale ได้และดูแลง่าย