สัปดาห์ที่ 9

Supabase Auth: Login, Register, Session & Protected Routes

CLO3 CLO7
📖 ทฤษฎี

1. Authentication vs Authorization — ความแตกต่างที่ต้องรู้

Authentication (AuthN) คือกระบวนการ "พิสูจน์ตัวตน" — ตรวจสอบว่าผู้ใช้เป็นใคร เช่น การ Login ด้วย Email และ Password, ใช้ Google OAuth, หรือสแกน Face ID ผลลัพธ์คือระบบรู้ว่า "ผู้ใช้คนนี้คือ user@example.com"

Authorization (AuthZ) คือกระบวนการ "ตรวจสอบสิทธิ์" — หลังจากรู้แล้วว่าเป็นใคร ระบบจะตัดสินว่าผู้ใช้คนนั้น "ทำอะไรได้บ้าง" เช่น User ทั่วไปดูข้อมูลได้แต่แก้ไขไม่ได้, Admin แก้ไขและลบได้, เจ้าของข้อมูลเข้าถึงได้เฉพาะข้อมูลของตัวเอง

ทั้งสองต้องทำงานร่วมกันเสมอ: ต้องพิสูจน์ตัวตน (AuthN) ก่อน จึงจะตรวจสอบสิทธิ์ (AuthZ) ได้ ในหลักสูตรนี้ใช้ Supabase Auth สำหรับ AuthN และ Row Level Security (RLS) สำหรับ AuthZ

  Authentication (AuthN)               Authorization (AuthZ)
  ────────────────────────             ─────────────────────────────
  "คุณเป็นใคร?"                        "คุณทำอะไรได้บ้าง?"

  • Login ด้วย Email + Password        • ดูได้เฉพาะ bookings ของตัวเอง
  • OAuth (Google, GitHub)             • Admin เท่านั้นที่ลบ service ได้
  • Magic Link (ลิงก์ทาง Email)        • RLS policy บน Supabase Table

  ผลลัพธ์: JWT token + session         ผลลัพธ์: allow / deny operation
        

2. Supabase Auth — Email/Password, Magic Link, OAuth

Supabase Auth เป็น Authentication Service ที่มาพร้อม Supabase โดยไม่ต้องสร้างระบบ Auth เอง รองรับหลายวิธีการ Login:

  • Email + Password — วิธีคลาสสิก ผู้ใช้สมัครด้วย Email และรหัสผ่าน Supabase จะ Hash รหัสผ่านให้อัตโนมัติและเก็บใน auth.users table ที่ซ่อนอยู่
  • Magic Link — ส่งลิงก์ Login ทาง Email โดยไม่ต้องใช้รหัสผ่าน ผู้ใช้คลิกลิงก์ → ระบบ Login อัตโนมัติ ปลอดภัยและใช้งานง่าย
  • OAuth (Social Login) — Login ผ่าน Provider ภายนอก เช่น Google, GitHub, Facebook ผู้ใช้ไม่ต้องสร้าง account ใหม่ ใช้ account ที่มีอยู่แล้วได้เลย

ข้อมูลผู้ใช้ทั้งหมดเก็บอยู่ใน Schema พิเศษชื่อ auth ใน Supabase ซึ่งไม่สามารถเข้าถึงได้โดยตรงจาก frontend เพื่อความปลอดภัย Supabase มี auth.uid() function ให้ใช้ใน RLS Policy เพื่อดึง ID ของผู้ใช้ปัจจุบัน

เคล็ดลับ — เลือก Auth Method อย่างไร

สำหรับแอปที่ผู้ใช้คุ้นเคยกับ Email/Password ให้ใช้แบบแรก ถ้าต้องการลดขั้นตอนให้ผู้ใช้ ใช้ Magic Link (ไม่ต้องจำรหัสผ่าน) สำหรับแอปที่กลุ่มผู้ใช้มี Google Account ทุกคน OAuth จะสะดวกที่สุด

3. JWT Token และ Session Management

เมื่อ Login สำเร็จ Supabase จะออก JWT (JSON Web Token) ให้ JWT คือ String ที่เข้ารหัสข้อมูลผู้ใช้ (เช่น user_id, email, role) และลงลายเซ็น (sign) ด้วย Secret Key ของ Supabase ฝั่ง Server สามารถตรวจสอบความถูกต้องของ JWT ได้โดยไม่ต้องถามฐานข้อมูล

Supabase JS Client จัดการ Session ให้อัตโนมัติ: เก็บ JWT ใน localStorage, ส่ง Token ในทุก Request ไปยัง Supabase API, และ refresh Token อัตโนมัติก่อนหมดอายุ นักพัฒนาไม่ต้องเขียน logic เหล่านี้เอง

supabase.auth.onAuthStateChange() เป็น Event Listener ที่แจ้งเตือนเมื่อ Auth state เปลี่ยน เช่น เมื่อ Login, Logout, หรือ Token refresh ใช้สร้าง AuthContext เพื่อให้ทุก Component ในแอปรับรู้สถานะ Auth โดยอัตโนมัติ

  Login สำเร็จ
       │
       ▼
  Supabase ออก JWT (access_token + refresh_token)
       │
       ▼
  Supabase JS Client เก็บ JWT ใน localStorage
       │
       ├── onAuthStateChange('SIGNED_IN', session) ถูกเรียก
       │        │
       │        ▼
       │   AuthContext อัปเดต user state → UI เปลี่ยน (แสดงชื่อผู้ใช้, logout button)
       │
       └── ทุก Supabase API call ส่ง JWT ใน Authorization header อัตโนมัติ
               │
               ▼
          RLS ตรวจสอบ auth.uid() → อนุญาตเฉพาะข้อมูลของตัวเอง
        

4. supabase.auth API — signUp, signInWithPassword, signOut, getSession

Supabase JS Client มี API หลักสำหรับ Authentication ดังนี้:

  • supabase.auth.signUp({ email, password, options }) — สมัครสมาชิกใหม่ ถ้าเปิดใช้ Email Confirmation ผู้ใช้ต้องยืนยันอีเมลก่อน Login ได้ options.data ใช้เก็บข้อมูลเพิ่มเติมเช่น full_name
  • supabase.auth.signInWithPassword({ email, password }) — Login ด้วย Email และรหัสผ่าน คืนค่า { data: { user, session }, error }
  • supabase.auth.signOut() — ออกจากระบบ ล้าง Session ใน localStorage และ trigger onAuthStateChange('SIGNED_OUT')
  • supabase.auth.getSession() — ดู session ปัจจุบัน ใช้ตรวจสอบว่า Login อยู่หรือไม่ เมื่อแอปเริ่มต้น ต้องเรียก getSession ก่อนเพื่อกู้ session จาก localStorage

ทุก API คืน { data, error } pattern เสมอ ต้องตรวจสอบ error ก่อนใช้ data ถ้า error ไม่ใช่ null แสดงว่าเกิดข้อผิดพลาด เช่น รหัสผ่านไม่ถูกต้อง, Email ซ้ำ, หรือ Email ไม่ได้ยืนยัน

5. Protected Route Pattern ใน TanStack Router

Protected Route คือ Route ที่เข้าถึงได้เฉพาะผู้ใช้ที่ Login แล้ว ถ้ายังไม่ Login ให้ redirect ไปหน้า Login โดยอัตโนมัติ เป็น pattern สำคัญในทุกแอปที่มีระบบสมาชิก

วิธีทำใน React + TanStack Router คือสร้าง Component ชื่อ ProtectedRoute ที่ตรวจสอบ user จาก useAuth() hook: ถ้า loading แสดง spinner, ถ้าไม่มี user ให้ navigate ไป /login, ถ้ามี user ให้ render children ตามปกติ

จากนั้นห่อ Component ที่ต้องการปกป้องด้วย <ProtectedRoute> เช่น <ProtectedRoute><BookingPage /></ProtectedRoute> วิธีนี้แยก logic การตรวจสอบ Auth ออกจาก Business Logic ของหน้านั้น ๆ ทำให้โค้ดสะอาดและ reuse ได้

หมายเหตุ — ความปลอดภัยที่แท้จริงอยู่ที่ Backend

Protected Route ใน Frontend เป็นแค่ UX — ป้องกันผู้ใช้ทั่วไปไม่ให้เห็นหน้าที่ไม่ควรเห็น แต่ไม่ได้ป้องกัน API ถ้าใครเปิด DevTools แล้วเรียก API โดยตรงก็ยังได้ ความปลอดภัยที่แท้จริงต้องมาจาก RLS บน Supabase ซึ่งทำงานฝั่ง Database

6. Row Level Security (RLS) — Authorization ระดับแถวข้อมูล

Row Level Security (RLS) คือฟีเจอร์ของ PostgreSQL (ฐานข้อมูลที่ Supabase ใช้) ที่กำหนดว่าผู้ใช้แต่ละคน SELECT/INSERT/UPDATE/DELETE แถวข้อมูลไหนได้บ้าง ทำงานโดยอัตโนมัติในทุก Query ไม่ต้องเขียน WHERE clause เพิ่มเองในโค้ด

เมื่อเปิด RLS บน Table ค่า default คือ "ไม่มีใครเข้าถึงได้เลย" จนกว่าจะสร้าง Policy Policy คือกฎที่ระบุเงื่อนไขว่าแถวข้อมูลนั้นควรแสดงให้ผู้ใช้คนใดเห็น ตัวอย่างเช่น: "แสดงเฉพาะแถวที่ user_id = auth.uid()" ซึ่งหมายความว่าผู้ใช้จะเห็นเฉพาะข้อมูลของตัวเองเท่านั้น

การสร้าง RLS Policy ทำได้ใน Supabase Dashboard (Table Editor → RLS → New Policy) หรือใช้ SQL: CREATE POLICY "policy_name" ON table_name FOR SELECT USING (auth.uid() = user_id); auth.uid() คือ function พิเศษที่คืน UUID ของผู้ใช้ปัจจุบันจาก JWT

  ไม่มี RLS:                        มี RLS + Policy:
  ─────────────────────             ──────────────────────────────────
  SELECT * FROM bookings            SELECT * FROM bookings
  → ได้ทุกแถวของทุกคน              → ได้เฉพาะแถวที่ user_id = auth.uid()

  User A เห็น booking               User A เห็น booking ของ A เท่านั้น
  ของ User B, C, D ด้วย             User B เห็น booking ของ B เท่านั้น

  ⚠️ ความเสี่ยงสูงมาก              ✓ ปลอดภัยระดับ Database
        
ข้อควรระวัง — เปิด RLS ทันทีเมื่อสร้าง Table

Table ใหม่ใน Supabase มี RLS ปิดอยู่ by default ซึ่งหมายความว่าใครก็สามารถดึงข้อมูลได้ ควร Enable RLS พร้อมกับสร้าง Policy ทันทีหลังสร้าง Table ก่อนที่จะมีข้อมูลจริงเข้ามา

💻 โค้ดตัวอย่าง
src/lib/supabase.js — Auth API หลัก JavaScript
// snippet 1: Supabase Auth operations
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(SUPABASE_URL, SUPABASE_KEY);

// สมัครสมาชิก
const { data, error } = await supabase.auth.signUp({
  email: 'user@example.com',
  password: 'password123',
  options: { data: { full_name: 'สมชาย ใจดี' } },
});

// เข้าสู่ระบบ
const { data: { user, session }, error } = await supabase.auth.signInWithPassword({
  email: 'user@example.com',
  password: 'password123',
});

// ออกจากระบบ
await supabase.auth.signOut();

// ดู session ปัจจุบัน
const { data: { session } } = await supabase.auth.getSession();
console.log(session?.user?.email);

// ฟัง auth state changes
supabase.auth.onAuthStateChange((event, session) => {
  if (event === 'SIGNED_IN') console.log('เข้าสู่ระบบแล้ว:', session.user.email);
  if (event === 'SIGNED_OUT') console.log('ออกจากระบบแล้ว');
});
src/contexts/AuthContext.jsx — AuthProvider & useAuth JSX
// snippet 2: Auth Context + useAuth hook
import { createContext, useContext, useEffect, useState } from 'react';
import { supabase } from '../lib/supabase';

const AuthContext = createContext(null);

export function AuthProvider({ children }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    supabase.auth.getSession().then(({ data: { session } }) => {
      setUser(session?.user ?? null);
      setLoading(false);
    });

    const { data: { subscription } } = supabase.auth.onAuthStateChange(
      (_, session) => setUser(session?.user ?? null)
    );
    return () => subscription.unsubscribe();
  }, []);

  return (
    <AuthContext.Provider value={{ user, loading }}>
      {children}
    </AuthContext.Provider>
  );
}

export const useAuth = () => useContext(AuthContext);
ProtectedRoute.jsx + LoginPage.jsx JSX
// snippet 3: Protected Route + Login Form
import { useAuth } from '../contexts/AuthContext';
import { useNavigate } from '@tanstack/react-router';

// Protected Route wrapper
function ProtectedRoute({ children }) {
  const { user, loading } = useAuth();
  const navigate = useNavigate();

  if (loading) return <p>กำลังตรวจสอบ...</p>;
  if (!user) {
    navigate({ to: '/login' });
    return null;
  }
  return children;
}

// Login Form
function LoginPage() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  const navigate = useNavigate();

  async function handleSubmit(e) {
    e.preventDefault();
    const { error } = await supabase.auth.signInWithPassword({ email, password });
    if (error) { setError(error.message); return; }
    navigate({ to: '/' });
  }

  return (
    <form onSubmit={handleSubmit}>
      <input type="email" value={email} onChange={e => setEmail(e.target.value)} placeholder="อีเมล" required />
      <input type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder="รหัสผ่าน" required />
      {error && <p className="error">{error}</p>}
      <button type="submit">เข้าสู่ระบบ</button>
    </form>
  );
}

🧪 ปฏิบัติการ Lab 8 — BookEasy: ระบบ Login/Register

เพิ่ม Authentication ให้ BookEasy ด้วย Supabase Auth ตั้งแต่สร้าง AuthContext, หน้า Login/Register ไปจนถึงปกป้อง route /booking ด้วย ProtectedRoute และตั้ง RLS บน Supabase

ต่อยอดจาก Lab 7

ต่อยอดจาก Lab 7 — เพิ่ม Authentication เพื่อให้ผู้ใช้ต้อง Login ก่อนจอง

1
สร้าง AuthContext และ Supabase Client ฝั่ง Frontend

ติดตั้ง Supabase JS Client และสร้าง AuthContext เพื่อให้ทุก Component เข้าถึงสถานะ Auth ได้

  • รัน npm install @supabase/supabase-js ใน folder bookeasy (frontend)
  • สร้างไฟล์ src/lib/supabase.js — สร้าง Supabase client ด้วย Public URL และ anon key จาก Supabase Dashboard (Settings → API)
  • สร้างไฟล์ src/contexts/AuthContext.jsx ตาม snippet 2 ด้านบน (AuthProvider + useAuth hook)
  • เปิด src/main.jsx แล้วห่อ <RouterProvider /> ด้วย <AuthProvider>
src/lib/supabase.js JavaScript
import { createClient } from '@supabase/supabase-js';

const SUPABASE_URL = 'https://xxxxxxxxxxxx.supabase.co';
const SUPABASE_ANON_KEY = 'eyJ...'; // anon public key

export const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
เกณฑ์การผ่าน

@supabase/supabase-js อยู่ใน package.json ไฟล์ src/lib/supabase.js และ src/contexts/AuthContext.jsx มีอยู่ main.jsx ห่อ RouterProvider ด้วย AuthProvider แล้ว แอปยังรันได้โดยไม่มี Error ใน console

คำแนะนำ

อย่าเก็บ API key ใน repository สาธารณะ ใช้ .env file แทน: สร้าง .env ที่ root ของ bookeasy ใส่ VITE_SUPABASE_URL และ VITE_SUPABASE_ANON_KEY แล้วอ่านใน supabase.js ด้วย import.meta.env.VITE_SUPABASE_URL

2
สร้างหน้า Login และ Register

สร้างหน้า Login และ Register พร้อมเพิ่ม route ใน router

  • สร้าง src/pages/LoginPage.jsx — Form มี Email + Password + ปุ่ม "เข้าสู่ระบบ" เรียก supabase.auth.signInWithPassword() และแสดง error ถ้าล้มเหลว Login สำเร็จให้ navigate ไป /
  • สร้าง src/pages/RegisterPage.jsx — Form มี Email + Password + Full Name เรียก supabase.auth.signUp() และแสดงข้อความสำเร็จหรือ error
  • เพิ่ม routes /login และ /register ใน src/router.js
src/pages/RegisterPage.jsx JSX
import { useState } from 'react';
import { supabase } from '../lib/supabase';
import { useNavigate } from '@tanstack/react-router';

export function RegisterPage() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [fullName, setFullName] = useState('');
  const [message, setMessage] = useState('');
  const navigate = useNavigate();

  async function handleSubmit(e) {
    e.preventDefault();
    const { error } = await supabase.auth.signUp({
      email,
      password,
      options: { data: { full_name: fullName } },
    });
    if (error) { setMessage(error.message); return; }
    setMessage('สมัครสมาชิกสำเร็จ! กรุณาตรวจสอบอีเมลเพื่อยืนยัน');
  }

  return (
    <form onSubmit={handleSubmit}>
      <input type="text" value={fullName} onChange={e => setFullName(e.target.value)} placeholder="ชื่อ-นามสกุล" required />
      <input type="email" value={email} onChange={e => setEmail(e.target.value)} placeholder="อีเมล" required />
      <input type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder="รหัสผ่าน (≥6 ตัว)" required minLength={6} />
      {message && <p>{message}</p>}
      <button type="submit">สมัครสมาชิก</button>
      <a href="/login">มีบัญชีอยู่แล้ว? เข้าสู่ระบบ</a>
    </form>
  );
}
เกณฑ์การผ่าน

เปิด /register → กรอกข้อมูล → กด Submit → แสดงข้อความสำเร็จ เปิด Supabase Dashboard → Authentication → Users → เห็น user ใหม่ เปิด /login → Login ด้วย account ที่สมัคร → navigate ไปหน้าแรก Login ผิด → แสดง error message

คำแนะนำ

ถ้า Supabase Project เปิด "Email Confirmation" ไว้ ผู้ใช้ต้องคลิก confirm link ในอีเมลก่อน Login ได้ ระหว่างพัฒนาสามารถปิดฟีเจอร์นี้ได้ที่ Authentication → Settings → Confirm email

3
ปกป้อง Route /booking ด้วย ProtectedRoute

ห่อ BookingForm ด้วย ProtectedRoute และเพิ่มปุ่ม Login/Logout ใน Header

  • สร้าง src/components/ProtectedRoute.jsx ตาม snippet 3 ด้านบน ตรวจสอบ user จาก useAuth() — redirect ไป /login ถ้าไม่มี user
  • ใน src/router.js หรือไฟล์ที่กำหนด route /booking ห่อ Component ด้วย <ProtectedRoute>
  • เปิด src/components/Header.jsx (หรือ Navbar): ใช้ useAuth() ตรวจสอบ user ถ้ามี user แสดง "สวัสดี [ชื่อ]" + ปุ่ม "ออกจากระบบ" ที่เรียก supabase.auth.signOut() ถ้าไม่มี user แสดงปุ่ม "เข้าสู่ระบบ" ที่ navigate ไป /login
src/components/ProtectedRoute.jsx JSX
import { useAuth } from '../contexts/AuthContext';
import { useNavigate } from '@tanstack/react-router';
import { useEffect } from 'react';

export function ProtectedRoute({ children }) {
  const { user, loading } = useAuth();
  const navigate = useNavigate();

  useEffect(() => {
    if (!loading && !user) {
      navigate({ to: '/login' });
    }
  }, [user, loading, navigate]);

  if (loading) return <p>กำลังตรวจสอบ...</p>;
  if (!user) return null;
  return children;
}
เกณฑ์การผ่าน

เปิด /booking โดยไม่ Login → redirect ไป /login ทันที Login แล้วเปิด /booking → เห็นหน้า BookingForm ตามปกติ Header แสดงปุ่ม "เข้าสู่ระบบ" เมื่อยังไม่ Login และแสดงชื่อผู้ใช้ + ปุ่ม "ออกจากระบบ" เมื่อ Login แล้ว กด "ออกจากระบบ" → กลับสู่สถานะไม่ Login

คำแนะนำ

ใช้ useEffect สำหรับ navigate เพราะการเรียก navigate() ระหว่าง render โดยตรงอาจทำให้เกิด React warning ตรวจสอบ loading ก่อนเสมอ ไม่เช่นนั้นอาจ redirect ก่อนที่ session จะโหลดเสร็จ

4
เปิด RLS และตั้ง Policy บน Supabase

เปิด Row Level Security บน bookings table และสร้าง Policy ให้ผู้ใช้เห็นเฉพาะข้อมูลของตัวเอง

  • เปิด Supabase Dashboard → Table Editor → เลือก bookings table
  • คลิก "RLS disabled" เพื่อ Enable RLS → ยืนยัน
  • คลิก "New Policy" → เลือก template "Enable read access for users based on user_id"
  • ตรวจสอบว่า Policy ใช้ auth.uid() = user_id เป็น USING expression
  • สร้าง Policy แยกสำหรับ INSERT ที่กำหนด user_id อัตโนมัติเป็น auth.uid()
SQL — RLS Policies สำหรับ bookings SQL
-- รัน SQL นี้ใน Supabase SQL Editor

-- เปิด RLS
ALTER TABLE bookings ENABLE ROW LEVEL SECURITY;

-- Policy: ดูได้เฉพาะ bookings ของตัวเอง
CREATE POLICY "users can view own bookings"
ON bookings FOR SELECT
USING (auth.uid() = user_id);

-- Policy: จองได้เฉพาะในนามตัวเอง
CREATE POLICY "users can insert own bookings"
ON bookings FOR INSERT
WITH CHECK (auth.uid() = user_id);
เกณฑ์การผ่าน

Supabase Dashboard แสดงว่า RLS เปิดอยู่บน bookings table Login ด้วย User A → จอง → เห็นเฉพาะ bookings ของ User A เท่านั้น Login ด้วย User B → เห็นเฉพาะ bookings ของ User B ไม่เห็นของ User A ทดสอบด้วย Supabase SQL Editor: SELECT * FROM bookings (ไม่มี session) → ได้ 0 แถว

คำแนะนำ

ต้องเพิ่ม column user_id UUID REFERENCES auth.users(id) ใน bookings table ถ้ายังไม่มี และใน BookingForm ส่ง user_id: user.id ไปด้วยเมื่อ INSERT ดึง user จาก useAuth() hook ภายใน BookingForm