Skip to content

บทที่ 2 — Auth Flow End-to-End (Login → JWT → Protected Route)

← บทที่ 1 | สารบัญ | บทที่ 3 →

บทนี้ทำ auth ครบ stack — เชื่อมโยง:

  • Spring Boot 03 (JWT)
  • React 05 (protected route)
  • บทที่ 1 (API layer)

หลังจบบท คุณจะมี:

  • Login + Signup form ที่ทำงานจริง
  • JWT ถูกเก็บ + แนบทุก request อัตโนมัติ
  • Protected route ที่ redirect ไป /login
  • Refresh token (เก็บ session ยาว)
  • Logout ที่ปลอดภัย

1. Auth Flow Overview

คำศัพท์ในแผนภาพนี้ (เจอครั้งแรก):

  • JWT (เจ-ดับเบิลยู-ที = JSON Web Token) = บัตรผ่านดิจิทัลที่เซิร์ฟเวอร์ออกให้หลัง login ใช้พิสูจน์ว่าเราคือใครในทุก request ถัดไป
  • BCrypt (บี-คริปต์) = วิธี hash password (แปลงรหัสผ่านเป็นข้อความรหัสที่ย้อนกลับไม่ได้ เก็บลง DB แทนรหัสจริง — ตอน login เอารหัสที่กรอกมา hash แล้วเทียบ ไม่เคยเก็บรหัสจริง)
  • Refresh Token = โทเคนอายุยาวไว้ขอ JWT ใบใหม่เมื่อใบเก่าหมดอายุ โดยไม่ต้อง login ซ้ำ
  • Zustand (ซู-ชตันด์ — เยอรมันแปลว่า "สถานะ") = ไลบรารีเก็บ state ส่วนกลางของ React (ในที่นี้ใช้เก็บข้อมูล user + access token ใน memory)

Part 1: Backend (Spring Boot)

สำเนาจาก Spring Boot บทที่ 03 — เน้นจุดที่ frontend ต้องรู้
ถ้ายังไม่เคยอ่าน Spring Boot 03 → ในนี้จะเจอชื่อ JwtService, PasswordEncoder, AuthenticationManager, CustomUserDetails ที่อธิบายเต็มในบทนั้น — ที่นี่ใช้แบบเรียกใช้ (ดู signature + behavior) ไม่ลง implementation

Recap คร่าว ๆ:

  • JwtService.generate(user) → คืน access token (string) ลงนามด้วย secret ของ server
  • PasswordEncoder (BCrypt impl) → encode() ตอน signup, matches() ตอน login
  • AuthenticationManager.authenticate(token) → เช็ค email+password กับ DB ถ้าไม่ผ่าน throw AuthenticationException
  • CustomUserDetails implements UserDetails → wrapper รอบ User entity ที่เพิ่ม getId() (ทำให้ controller รู้ user id ไม่ใช่แค่ email)

2. Auth Endpoints

เริ่มสร้าง auth flow ที่ฝั่ง backend — controller ที่มี endpoint ครบวงจร: signup, login, refresh, logout, me จุดที่ต้องเข้าใจคือ login/refresh คืนทั้ง access token (อายุสั้น) และ refresh token (อายุยาว) ตาม pattern ที่จะใช้ตลอดบท:

java
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
    
    private final AuthService authService;
    
    @PostMapping("/signup")
    public ResponseEntity<AuthResponse> signup(@RequestBody @Valid SignupRequest req) {
        return ResponseEntity.status(HttpStatus.CREATED).body(authService.signup(req));
    }
    
    @PostMapping("/login")
    public ResponseEntity<AuthResponse> login(@RequestBody @Valid LoginRequest req) {
        return ResponseEntity.ok(authService.login(req));
    }
    
    @PostMapping("/refresh")
    public ResponseEntity<AuthResponse> refresh(@RequestBody RefreshRequest req) {
        return ResponseEntity.ok(authService.refresh(req.refreshToken()));
    }
    
    @PostMapping("/logout")
    public ResponseEntity<Void> logout(@AuthenticationPrincipal CustomUserDetails user) {
        // ⭐ ใช้ user.getId() (stable Long) — ไม่ใช่ email
        //    เผื่ออนาคต user เปลี่ยน email แล้ว session เก่าจะยัง revoke ตรงตัวได้
        authService.logout(user.getId());
        return ResponseEntity.noContent().build();
    }
    
    @GetMapping("/me")
    public ResponseEntity<UserResponse> me(@AuthenticationPrincipal CustomUserDetails user) {
        return ResponseEntity.ok(authService.getCurrentUser(user.getId()));
    }
}

@RequestBody @Valid = บอก Spring ให้รัน Bean Validation (เช็ค @NotBlank, @Email, @Size) จากตัว annotation ใน DTO ก่อนเข้า method ถ้าข้อมูลผิด → คืน 400 พร้อมรายการ error อัตโนมัติ (ไม่ต้องเขียน if เช็คเอง)


3. DTOs

กำหนด DTO (Data Transfer Object = อ็อบเจกต์ที่ใช้รับ-ส่งข้อมูลผ่าน API โดยเฉพาะ แยกจาก entity ในฐานข้อมูล) สำหรับ request/response ของ auth — แยก SignupRequest/LoginRequest (รับ input พร้อม validation = ตรวจความถูกต้องของ input) จาก AuthResponse (คืน token + user) การแยก DTO ออกจาก entity กัน over-posting (user แอบส่ง field ที่ไม่ควรแก้มาด้วย เช่นแอบยัด role=ADMIN มาตอน signup เพื่อยกระดับตัวเองเป็นแอดมิน) และทำให้ API contract (ข้อตกลงหน้าตา input/output ของ API) ชัดเจน:

java
public record SignupRequest(
    @NotBlank @Email String email,
    @NotBlank @Size(min = 8) String password,
    @NotBlank String name
) {}

public record LoginRequest(
    @NotBlank @Email String email,
    @NotBlank String password
) {}

public record RefreshRequest(@NotBlank String refreshToken) {}

public record AuthResponse(
    String accessToken,
    String refreshToken,
    long expiresIn,                     // seconds
    UserResponse user
) {}

public record UserResponse(
    Long id,
    String email,
    String name,
    String role
) {}

4. AuthService

หัวใจของ auth ฝั่ง backend อยู่ใน AuthService — logic ของ signup (hash password + ออก token), login (verify + ออก token), refresh (rotate refresh token) และ logout ส่วนนี้คือที่ที่ business logic ทั้งหมดรวมกัน แยกจาก controller ที่แค่รับ-ส่ง:

java
@Service
@RequiredArgsConstructor
@Transactional
public class AuthService {
    
    private final UserRepository userRepo;
    private final RefreshTokenRepository refreshRepo;
    private final PasswordEncoder passwordEncoder;
    private final JwtService jwtService;
    private final AuthenticationManager authManager;
    
    public AuthResponse signup(SignupRequest req) {
        if (userRepo.existsByEmail(req.email())) {
            throw new EmailExistsException("Email already in use");
        }
        
        User user = new User();
        user.setEmail(req.email());
        user.setPassword(passwordEncoder.encode(req.password()));
        user.setName(req.name());
        user.setRole("USER");
        userRepo.save(user);
        
        return createAuthResponse(user);
    }
    
    public AuthResponse login(LoginRequest req) {
        try {
            authManager.authenticate(
                new UsernamePasswordAuthenticationToken(req.email(), req.password())
            );
        } catch (AuthenticationException e) {
            // ⭐ catch base class — ครอบคลุม:
            //    - BadCredentialsException (password ผิด)
            //    - DisabledException (account ถูกปิด)
            //    - LockedException (account ถูก lock จาก brute-force)
            //    - AccountExpiredException
            //    - CredentialsExpiredException
            // หมายเหตุ: Spring Security 5.x+ ตั้ง `hideUserNotFoundExceptions=true` เป็น default
            //          → UsernameNotFoundException จะถูกแปลงเป็น BadCredentialsException อัตโนมัติ
            //          กัน user enumeration (อี-นู-เม-เร-ชั่น = การไล่เดารายชื่อผู้ใช้)
            //          ผู้ร้ายดู error/timing แล้วเดาว่า email มีในระบบหรือไม่
            // ⚠️ ตอบ message เดียวกันทุก case เพื่อไม่ให้ผู้ร้ายแยกออกว่าผิดที่อะไร
            throw new InvalidCredentialsException("Email or password incorrect");
        }

        // ถึงตรงนี้ = password ผ่านแล้ว user ต้องมีจริง
        User user = userRepo.findByEmail(req.email())
            .orElseThrow(() -> new InvalidCredentialsException("Email or password incorrect"));
        return createAuthResponse(user);
    }
    
    public AuthResponse refresh(String refreshToken) {
        // hash ค่าที่ client ส่งมา (HMAC-SHA256 + server secret) แล้วค่อยค้น DB
        RefreshToken stored = refreshRepo.findByTokenHash(hmacSha256(refreshToken))
            .orElseThrow(() -> new InvalidTokenException("Invalid refresh token"));

        if (stored.getExpiresAt().isBefore(Instant.now())) {
            refreshRepo.delete(stored);
            throw new InvalidTokenException("Refresh token expired");
        }

        // 🛡️ Replay detection (rotation = โร-เท-ชั่น = หมุนเวียน/เปลี่ยนใหม่)
        // ถ้า token ที่ถูก rotate ไปแล้วถูกใช้ซ้ำ (replay = รี-เพลย์ = เล่นซ้ำ/โจมตีซ้ำด้วยข้อมูลเก่า)
        // → แปลว่ามีคนขโมย token → revoke "ทั้ง family" ของ user คนนี้
        // อ้างอิง: OAuth 2.0 Security Best Current Practice (RFC 6749 BCP / draft-ietf-oauth-security-topics)
        if (stored.isUsed()) {
            // token ใช้แล้วถูกใช้ซ้ำ = ถูกขโมย → revoke ทั้ง family
            refreshRepo.revokeAllByFamilyId(stored.getFamilyId());
            throw new InvalidTokenException("Refresh token replay detected — all sessions revoked");
        }

        // mark old token as used (ไม่ลบทันที — เก็บไว้เพื่อ detect replay)
        stored.setUsed(true);
        refreshRepo.save(stored);

        // ออก token ใหม่ใน family เดียวกัน
        return createAuthResponse(stored.getUser(), stored.getFamilyId());
    }

    public void logout(Long userId) {
        // ⚠️ ลบ refresh token ของ user ทั้งหมด = revoke (รี-โว้ก = ยกเลิกสิทธิ์/บัตรผ่าน) ทุก device
        // ใช้ user id (stable Long) — ไม่ใช่ email ที่อาจเปลี่ยนได้
        // ถ้าต้องการแยก per-device ต้องส่ง refresh token ของ device นั้นมาด้วย แล้ว delete by token เฉพาะตัว
        // (ดู §"Logout per-device" ด้านล่าง)
        refreshRepo.deleteByUserId(userId);
    }
    
    public UserResponse getCurrentUser(Long id) {
        User user = userRepo.findById(id).orElseThrow();
        return new UserResponse(user.getId(), user.getEmail(), user.getName(), user.getRole());
    }
    
    // login/signup เริ่มสร้าง family ใหม่
    private AuthResponse createAuthResponse(User user) {
        return createAuthResponse(user, java.util.UUID.randomUUID().toString());
    }

    // refresh สืบ family เดิม
    private AuthResponse createAuthResponse(User user, String familyId) {
        String accessToken = jwtService.generate(user);
        String refreshToken = createRefreshToken(user, familyId);
        return new AuthResponse(
            accessToken,
            refreshToken,
            900,                          // 15 minutes
            new UserResponse(user.getId(), user.getEmail(), user.getName(), user.getRole())
        );
    }
    
    private String createRefreshToken(User user, String familyId) {
        // 🔑 ใช้ SecureRandom 256-bit + base64url encoding (RFC 4648)
        // ห้ามใช้ UUID.randomUUID() สำหรับ security token —
        //   - UUID v4 มี entropy แค่ 122-bit (เพดานทางทฤษฎี)
        //   - JVM ไม่รับประกันว่าใช้ CSPRNG (cryptographically secure PRNG) เสมอ
        //   - SecureRandom รับประกันแหล่งสุ่มที่เหมาะกับงาน security
        byte[] bytes = new byte[32];                                  // 32 byte = 256 bit
        new java.security.SecureRandom().nextBytes(bytes);
        String plain = java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);

        // ⭐ เก็บแค่ "hash" ของ token ลง DB — ไม่เก็บ plaintext
        // ใช้ HMAC-SHA256 (ไม่ใช่ SHA-256 ตรง ๆ) — server มี secret ที่ผู้ร้ายไม่รู้
        // → DB หลุดอย่างเดียว ผู้ร้ายคำนวณ hash ไม่ได้ (ไม่มี secret)
        RefreshToken token = new RefreshToken();
        token.setTokenHash(hmacSha256(plain));
        token.setUser(user);
        token.setFamilyId(familyId);                                  // ⭐ token family สำหรับ replay detection
        token.setUsed(false);
        token.setExpiresAt(Instant.now().plus(7, ChronoUnit.DAYS));
        refreshRepo.save(token);
        return plain;
    }

    // 🔐 HMAC-SHA256 = SHA-256 ที่ผสม "server secret" เข้าไปด้วย
    // ต่างจาก SHA-256 ตรง ๆ ตรงที่: ใครได้ DB ไปอย่างเดียวคำนวณ hash ของ token ตัวอื่นไม่ได้
    // (กัน offline rainbow attack แม้ token มี entropy 256 bit อยู่แล้วก็ defense-in-depth)
    private String hmacSha256(String input) {
        try {
            var mac = javax.crypto.Mac.getInstance("HmacSHA256");
            var key = new javax.crypto.spec.SecretKeySpec(
                refreshTokenPepper.getBytes(java.nio.charset.StandardCharsets.UTF_8),
                "HmacSHA256"
            );
            mac.init(key);
            byte[] hash = mac.doFinal(input.getBytes(java.nio.charset.StandardCharsets.UTF_8));
            return java.util.HexFormat.of().formatHex(hash);
        } catch (java.security.GeneralSecurityException e) {
            throw new IllegalStateException(e);
        }
    }

    // โหลดจาก env / application.yml — ห้าม hardcode
    @Value("${app.refresh-token.pepper}")
    private String refreshTokenPepper;
}

🔴 ทำไมต้อง HMAC refresh token ใน DB (ไม่ใช่แค่ SHA-256)

ปัญหา: Refresh token ที่ valid อยู่ = "บัตรผ่านที่ใช้ login ต่อได้ทันที"

ภัยแยกเป็นกล่อง ๆ:

🔴 SQL injection — แฮกเกอร์รัน SELECT token_hash FROM refresh_tokens ดึง hash ออกมาทั้งหมด

🔴 Backup leak — backup DB หลุดออก S3 bucket ที่ตั้ง public โดยไม่ตั้งใจ

🔴 Insider — DBA / DevOps ที่ลาออกแล้วยังจำ credential ได้ → dump table ออก

🔴 hijack = ยึด session ของ user เพื่อแอบใช้สิทธิ์ตัวเอง

ถ้าเก็บเป็น plaintext → ทุก session ของทุกผู้ใช้ที่ยัง active โดน hijack ทันที

Best practice (จาก OWASP):

  1. Server สุ่ม token ด้วย SecureRandom 256-bit → base64url encode → ส่ง plaintext ให้ client ครั้งเดียว
  2. เก็บแค่ HMAC-SHA256(token, server_pepper) ใน DB
  3. ตอน validate → hash ค่าที่ client ส่งมาแล้วเทียบ ถ้าตรงกัน = valid

ทำไม HMAC ไม่ใช่ SHA-256 ตรง ๆ?

  • SHA-256 ตรง ๆ: ใครได้ DB ไป + เดา token format ออก → ลอง hash candidate ทีละตัวเทียบกับ DB ได้ (offline attack)
  • HMAC + server pepper: ผู้ร้ายต้องได้ทั้ง DB และ server secret → ยากขึ้นมาก (defense-in-depth)

ทำไมไม่ใช้ BCrypt/Argon2id เลย?

  • Token มี entropy 256 bit (เดา brute-force ไม่ได้) ไม่ใช่ password ที่คนเดาง่าย
  • BCrypt/Argon2id ช้ามาก (100–500 ms) — refresh ทุกครั้งจะกลายเป็น bottleneck
  • HMAC เร็ว (< 1 ms) แต่ปลอดภัยพอสำหรับ token ที่มี entropy สูงอยู่แล้ว

Part 2: Frontend Auth Store

5. Zustand Auth Store

Zustand (ซู-ชตันด์) = ไลบรารีเก็บ state ส่วนกลางของ React แบบเบา (≈ 1 KB) แทนการส่ง props ลงไปทีละชั้น
👉 ถ้ายังไม่รู้จัก Zustand → อ่านบท React 06 "Global State" ก่อน

persist middleware = บันทึก state ลง storage อัตโนมัติ — แต่ ต้องเลือกว่าเก็บอะไรลง (ดู partialize ด้านล่าง)

🔴 อย่าเก็บ refresh token ลง localStorage

ตัวอย่างต้นฉบับเคยเก็บ refreshToken ลง localStorage ด้วย — ผิด
เหตุผลเดียวกับใน §"Token Storage" ด้านล่าง: XSS (เอ็กซ์-เอส-เอส) ขโมยจาก localStorage ได้ + refresh token อายุ 7 วัน = account takeover ถาวร

แนวทางที่ถูก:

  • Production: refresh token อยู่ใน HttpOnly cookie (server ตั้งให้ JS อ่านไม่ได้) — store ไม่เก็บเลย
  • MVP / dev: refresh token เก็บใน memory เท่านั้น (ใช้ partialize ตัดออกจาก persist) — ถ้า reload หาย ก็ให้ user login ใหม่ ยอมแลกความสะดวกกับความปลอดภัย

ถ้าจะ "เก็บ refresh ใน localStorage จริง ๆ" → ต้องเขียน comment ตัวใหญ่ว่ารู้ว่าเสี่ยง และมี plan ย้าย

ts
// src/stores/auth.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { queryClient } from '@/api/queryClient';

export interface User {
    id: number;
    email: string;
    name: string;
    role: 'USER' | 'ADMIN';
    avatarUrl?: string;
}

interface AuthState {
    user: User | null;
    accessToken: string | null;           // memory only
    refreshToken: string | null;          // memory only (production = HttpOnly cookie)
    
    setAuth: (data: { user: User; accessToken: string; refreshToken: string }) => void;
    setAccessToken: (token: string) => void;
    logout: () => void;
}

export const useAuthStore = create<AuthState>()(
    persist(
        (set) => ({
            user: null,
            accessToken: null,
            refreshToken: null,
            
            setAuth: ({ user, accessToken, refreshToken }) =>
                set({ user, accessToken, refreshToken }),
            
            setAccessToken: (accessToken) => set({ accessToken }),
            
            logout: () => {
                set({ user: null, accessToken: null, refreshToken: null });
                queryClient.clear();      // ⭐ clear cache ของ previous user (ดู §16)
            },
        }),
        {
            name: 'auth-storage',
            // ⭐ partialize = เลือกว่า "เก็บอะไรลง localStorage บ้าง"
            //    เก็บแค่ user (สำหรับ UI hint ก่อน bootstrap) — token ไม่เก็บ
            //    token = หาใหม่จาก refresh cookie ตอน bootstrap
            partialize: (state) => ({ user: state.user }),
        }
    )
);

💡 Token Storage — 3 ทางเลือก

ศัพท์ความปลอดภัยในตารางนี้ (อ่านก่อน — ไม่งั้นตารางจะงง):

  • XSS (เอ็กซ์-เอส-เอส) — Cross-Site Scripting (ครอส-ไซต์ สคริป-ติ้ง) = ช่องโหว่ที่โค้ดร้าย (JavaScript ของคนอื่น) แอบรันในหน้าเว็บเรา แล้วขโมยของที่ JS เข้าถึงได้ เช่น token ที่เก็บใน localStorage
  • HttpOnly cookie (เอช-ที-ที-พี-โอนลี่ คุ้ก-กี้) = cookie ชนิดที่ตั้งค่าให้ JavaScript อ่านไม่ได้ (อ่านได้แค่เซิร์ฟเวอร์) → โค้ดร้ายจาก XSS เลยขโมย token ในนี้ไม่ได้
  • CSRF (ซี-เอส-อาร์-เอฟ) — Cross-Site Request Forgery = การหลอกให้เบราว์เซอร์เรายิง request ไปเว็บที่เรา login ไว้ โดยอาศัย cookie ที่ติดไปอัตโนมัติ (ดูตัวอย่างเงินหายใน บทที่ 1 §3)
  • CSP (ซี-เอส-พี) — Content Security Policy (คอน-เทนต์ ซิ-คิว-ริ-ตี้ พอล-ลิ-ซี่) = header ที่บอกเบราว์เซอร์ว่า "อนุญาตให้รันสคริปต์จากที่ไหนได้บ้าง" ช่วยลดโอกาสโดน XSS
  • MVP (เอ็ม-วี-พี) — Minimum Viable (ไว-เอ-เบิล = ใช้ได้จริง) Product = เวอร์ชันแรกสุดที่ทำงานได้พอใช้ เน้นออกเร็ว ยอมลดความสมบูรณ์บางอย่างไปก่อน
localStorage (MVP)Memory only + HttpOnly Cookie (Prod)Memory only + SSO re-auth
Access token เก็บที่ไหนlocalStorageMemory (Zustand)Memory
Refresh token เก็บที่ไหนlocalStorage ⚠️HttpOnly Cookieไม่เก็บ — re-auth ผ่าน SSO
อ่านจาก JS ได้ไหม✅ ได้ (= เสี่ยง)❌ refresh อ่านไม่ได้
ทน XSStoken หายทั้งคู่✅ refresh ทน, access เสี่ยงระหว่าง session✅✅ ดีสุด
ทน CSRF✅ (ต้องแนบ header เอง)⚠️ ต้องใส่ SameSite=Strict + CSRF token
ความยากง่ายสุดserver + cookie configต้องมี IdP/SSO
Reload แล้ว stay logged in✅ (refresh cookie)✅ (silent SSO)
เหมาะกับdev / MVPSPA productionenterprise / multi-app

ดีที่สุด (สำหรับ production SPA ทั่วไป):

  • Refresh token → HttpOnly + Secure + SameSite=Strict Cookie, path = /api/auth/refresh เท่านั้น
  • Access token → memory (Zustand, ไม่ persist) — เปิดแอปแล้ว /auth/refresh คืน access ใหม่ทันที

ดีที่สุด (enterprise ที่มี SSO):

  • ไม่ต้องเก็บ refresh token เลย — เปิดแอปแล้ว silent SSO ขอ access token ใหม่จาก IdP

MVP / Dev: ใช้ localStorage ได้ — แต่ต้องเขียน TODO ว่าจะย้ายไป HttpOnly cookie ก่อน production

🔴 อ่านก่อนเลือก localStorage — XSS ไม่ได้ "ปิด" ด้วย CSP

CSP ลดโอกาส XSS เท่านั้น ไม่ใช่ "ปิด" — ถ้า:

  • dependency ตัวใดตัวหนึ่งใน node_modules มี vulnerability (เคยเกิดกับ event-stream, ua-parser-js, ฯลฯ)
  • โค้ดเรา render user-controlled HTML ผ่าน dangerouslySetInnerHTML หรือ markdown ที่ไม่ sanitize
  • third-party script (analytics, ads) ถูกแฮก

XSS ก็เกิดได้แม้มี CSP ครบ

และเมื่อ XSS เกิดบน localStorage approach:

  • refresh token ใน localStorage = ผู้ร้ายขโมยได้ → ใช้ refresh ออก access token ใหม่ได้เรื่อย ๆ
  • refresh token อายุ 7 วัน → account takeover ถาวร (จนกว่า user จะ logout ทุก device หรือเรา rotate secret)

ส่วน HttpOnly Cookie:

  • XSS อ่าน refresh token ไม่ได้ (JS เข้าไม่ถึง cookie)
  • ผู้ร้ายทำได้แค่ "ยิง request ตอน user ยัง online" → กระทบเฉพาะช่วงเซสชันนั้น ไม่ใช่ takeover ถาวร

MVP เลือก localStorage ได้ แต่ต้องรู้ว่ายอมรับ residual risk ระดับนี้อยู่ — และมีแผนย้ายไป HttpOnly Cookie ก่อนรับ user จริง/รับเงิน ดูตัวอย่างการแก้ backend ให้รองรับ HttpOnly Cookie ด้านล่าง


6. Auth API

ฝั่ง frontend สร้าง endpoint module สำหรับ auth — รวมการเรียก signup/login/refresh/logout/me ไว้ที่เดียวแบบ type-safe ตาม API layer pattern จากบทก่อน เพื่อให้ component และ store เรียกใช้ได้สะดวก:

ts
// src/api/auth.ts
import { http } from './client';
import type { User } from '@/stores/auth';

interface AuthResponse {
    accessToken: string;
    refreshToken: string;
    expiresIn: number;
    user: User;
}

export const authApi = {
    signup: (data: { email: string; password: string; name: string }) =>
        http.post<AuthResponse>('/auth/signup', data),
    
    login: (data: { email: string; password: string }) =>
        http.post<AuthResponse>('/auth/login', data),
    
    refresh: (refreshToken: string) =>
        http.post<AuthResponse>('/auth/refresh', { refreshToken }),
    
    logout: () => http.post<void>('/auth/logout'),
    
    me: () => http.get<User>('/auth/me'),
};

Part 3: HTTP Client — Auto Refresh

7. Client with Refresh Logic

หัวใจที่ทำให้ access token อายุสั้นใช้งานได้จริงคือ "auto refresh" — เมื่อ API คืน 401 (token หมดอายุ) client จะเรียก refresh เงียบ ๆ เพื่อขอ token ใหม่แล้ว retry (ลองยิงซ้ำ) request เดิม ผู้ใช้ไม่รู้ตัวว่า token หมด จุดที่ต้องระวังคือกัน refresh ซ้อนหลายครั้งพร้อมกัน:

🚀 โซนขั้นสูง — ข้ามได้ ส่วน refreshPromise ที่กันไม่ให้ refresh ซ้อนกัน (ถ้าหลาย request เจอ 401 พร้อมกันให้รอ refresh อันเดียวกัน) เป็นรายละเอียดที่ค่อนข้างลึก — รอบแรกเข้าใจแค่ภาพรวม "401 → refresh → retry" ก็พอ ค่อยกลับมาอ่านโค้ดละเอียดทีหลังได้

ts
// src/api/client.ts
import { useAuthStore } from '@/stores/auth';
import { authApi } from './auth';

const API_BASE = '/api';

export class ApiError extends Error {
    constructor(
        public status: number,
        public code: string,
        message: string,
        public details?: unknown
    ) {
        super(message);
    }
}

// 🚪 callback ที่ component (เช่น <App>) hook เข้าไปเชื่อมกับ react-router navigate
// ห้ามใช้ window.location.href = '/login' ตรง ๆ — ทำ full page reload + ลบ form state
let onSessionExpired: (() => void) | null = null;
export function setOnSessionExpired(fn: () => void) { onSessionExpired = fn; }

let refreshPromise: Promise<string> | null = null;

async function refreshAccessToken(): Promise<string> {
    // ถ้ามี refresh ในมือ → รออันเดียวกัน (ไม่ทำซ้ำ)
    if (refreshPromise) return refreshPromise;
    
    const refreshToken = useAuthStore.getState().refreshToken;
    if (!refreshToken) throw new Error('No refresh token');
    
    refreshPromise = authApi.refresh(refreshToken)
        .then((res) => {
            useAuthStore.getState().setAuth({
                user: res.user,
                accessToken: res.accessToken,
                refreshToken: res.refreshToken,
            });
            return res.accessToken;
        })
        .catch((err) => {
            useAuthStore.getState().logout();
            onSessionExpired?.();                 // ⭐ ให้ router นำไป /login ไม่ใช่ full reload
            throw err;
        })
        .finally(() => {
            refreshPromise = null;
        });
    
    return refreshPromise;
}

async function request<T>(
    path: string,
    options: RequestInit = {},
    retry = true
): Promise<T> {
    const token = useAuthStore.getState().accessToken;
    
    const res = await fetch(`${API_BASE}${path}`, {
        ...options,
        headers: {
            'Content-Type': 'application/json',
            ...(token && { Authorization: `Bearer ${token}` }),
            ...options.headers,
        },
    });
    
    // 🛡️ 401 → ลอง refresh แล้ว retry
    // เงื่อนไข retry ครบทั้ง 3 ข้อ ป้องกัน infinite loop:
    //   1. retry === true (caller รอบแรกเท่านั้น)
    //   2. มี refreshToken
    //   3. path !== '/auth/refresh' (กัน refresh เองคืน 401 แล้ว retry refresh อีกรอบ)
    if (
        res.status === 401
        && retry
        && useAuthStore.getState().refreshToken
        && path !== '/auth/refresh'
    ) {
        try {
            await refreshAccessToken();
            return request<T>(path, options, false);    // retry 1 ครั้ง
        } catch (err) {
            // refresh fail → refreshAccessToken() เคลียร์ store + แจ้ง router แล้ว
            // ⭐ ต้อง throw ออกตรงนี้ — ไม่งั้น code จะตกลง throw ApiError(401) ข้างล่าง
            //    ซึ่งทำให้ caller เห็น 401 แทนที่จะรู้ว่าโดน logout ไปแล้ว
            throw err instanceof Error
                ? err
                : new ApiError(401, 'SESSION_EXPIRED', 'Session expired, please login again');
        }
    }
    
    if (!res.ok) {
        let body: any = null;
        try { body = await res.json(); } catch { /* ignore */ }
        throw new ApiError(
            res.status,
            body?.error?.code ?? 'UNKNOWN',
            body?.error?.message ?? res.statusText,
            body?.error?.details
        );
    }
    
    if (res.status === 204) return undefined as T;
    return res.json();
}

export const http = {
    get: <T>(path: string) => request<T>(path),
    post: <T>(path: string, body?: unknown) => 
        request<T>(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined }),
    put: <T>(path: string, body?: unknown) => 
        request<T>(path, { method: 'PUT', body: JSON.stringify(body) }),
    patch: <T>(path: string, body?: unknown) => 
        request<T>(path, { method: 'PATCH', body: JSON.stringify(body) }),
    delete: <T>(path: string) => 
        request<T>(path, { method: 'DELETE' }),
};

Part 4: Login + Signup Forms

8. Login Form (React Hook Form + Zod)

มาทำ login form ที่มีคุณภาพระดับ production — โดยใช้ 3 ตัวช่วยร่วมกัน:

  • React Hook Form (มักย่อ RHF) = ไลบรารีจัดการฟอร์มใน React เก็บค่าที่กรอก, เช็ค error, จัดการ submit ให้
  • Zod (ซอด) = ไลบรารีตรวจความถูกต้องของข้อมูลแบบ type-safe เขียน schema ครั้งเดียว ได้ทั้งการ validate ตอนรัน + TypeScript type ตอน compile
  • mutation = การยิงคำสั่งที่เปลี่ยนแปลงข้อมูล (เช่น login, create) ผ่าน TanStack Query — ดูแล loading/error state ให้

👉 ถ้ายังไม่รู้จัก RHF + Zod → อ่านบท React 04 "Forms" ก่อน ในนี้จะเน้นการต่อ login กับ API

tsx
// src/pages/Login.tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useMutation } from '@tanstack/react-query';
import { useNavigate, useLocation, Link } from 'react-router-dom';
import { authApi } from '@/api/auth';
import { useAuthStore } from '@/stores/auth';
import { ApiError } from '@/api/client';

const schema = z.object({
    email: z.string().email('Invalid email'),
    // ⭐ ใช้ min(8) ตรงกับ signup — กัน user กดยิง request ที่ผิดแน่ ๆ เปลือง bandwidth
    password: z.string().min(8, 'Password too short'),
});

type FormData = z.infer<typeof schema>;

export function Login() {
    const navigate = useNavigate();
    const location = useLocation();
    const from = (location.state as { from?: { pathname: string } })?.from?.pathname ?? '/dashboard';
    const setAuth = useAuthStore((s) => s.setAuth);
    
    const form = useForm<FormData>({
        resolver: zodResolver(schema),
    });
    
    const loginMutation = useMutation({
        mutationFn: authApi.login,
        onSuccess: (data) => {
            setAuth({
                user: data.user,
                accessToken: data.accessToken,
                refreshToken: data.refreshToken,
            });
            navigate(from, { replace: true });
        },
    });
    
    // ⭐ ใช้ mutateAsync + await — กัน race condition (navigate ทำงานหลัง onSuccess set state เสร็จจริง)
    const onSubmit = form.handleSubmit(async (data) => {
        await loginMutation.mutateAsync(data).catch(() => { /* error แสดงผ่าน loginMutation.error */ });
    });
    
    return (
        <div className="max-w-sm mx-auto mt-20 p-6 bg-white rounded shadow">
            <h1 className="text-2xl font-bold mb-6">Login</h1>
            
            <form onSubmit={onSubmit} className="space-y-4">
                <div>
                    <label className="block text-sm font-medium mb-1">Email</label>
                    <input
                        type="email"
                        {...form.register('email')}
                        className="w-full border rounded px-3 py-2"
                        autoComplete="email"
                    />
                    {form.formState.errors.email && (
                        <p className="text-red-600 text-sm mt-1">
                            {form.formState.errors.email.message}
                        </p>
                    )}
                </div>
                
                <div>
                    <label className="block text-sm font-medium mb-1">Password</label>
                    <input
                        type="password"
                        {...form.register('password')}
                        className="w-full border rounded px-3 py-2"
                        autoComplete="current-password"
                    />
                    {form.formState.errors.password && (
                        <p className="text-red-600 text-sm mt-1">
                            {form.formState.errors.password.message}
                        </p>
                    )}
                </div>
                
                {loginMutation.error instanceof ApiError && (
                    <div className="bg-red-50 border border-red-200 rounded p-2 text-red-800 text-sm">
                        {loginMutation.error.message}
                    </div>
                )}
                
                <button
                    type="submit"
                    disabled={loginMutation.isPending}
                    className="w-full bg-blue-500 hover:bg-blue-600 disabled:opacity-50 text-white py-2 rounded"
                >
                    {loginMutation.isPending ? 'Logging in...' : 'Login'}
                </button>
                
                <p className="text-sm text-center text-gray-600">
                    Don't have an account? <Link to="/signup" className="text-blue-500">Sign up</Link>
                </p>
            </form>
        </div>
    );
}

9. Signup Form

signup form คล้าย login แต่ validation ซับซ้อนกว่า — Zod ทำ password strength rule และ .refine() เช็คว่า password กับ confirm ตรงกัน (cross-field validation ที่ data annotation ธรรมดาทำไม่ได้):

tsx
// src/pages/Signup.tsx
const signupSchema = z.object({
    email: z.string().email(),
    name: z.string().min(2),
    // 📘 NIST 800-63B (2024) แนะนำ: เน้น "ยาว" มากกว่า "ผสมประเภท"
    //    - composition rules (ต้องมี A-Z, a-z, ตัวเลข, สัญลักษณ์) ทำให้คนตั้ง 'Password1!'
    //    - 12+ ตัวอักษรพร้อมเช็ค breached password list (เช่น HaveIBeenPwned API) ปลอดภัยกว่ามาก
    password: z.string()
        .min(12, 'อย่างน้อย 12 ตัวอักษร — passphrase ดีที่สุด เช่น "ฉันชอบกาแฟกับแมว2025"')
        .max(128, 'ยาวเกินไป'),
    confirmPassword: z.string(),
}).refine(data => data.password === data.confirmPassword, {
    path: ['confirmPassword'],
    message: 'Passwords do not match',
});

// ⭐ z.infer = สร้าง TypeScript type จาก Zod schema อัตโนมัติ
//    เปลี่ยน schema แล้ว type เปลี่ยนตามเอง ไม่ต้องเขียน 2 ที่
type SignupData = z.infer<typeof signupSchema>;
// → { email: string; name: string; password: string; confirmPassword: string }

export function Signup() {
    const navigate = useNavigate();
    const setAuth = useAuthStore((s) => s.setAuth);
    
    const form = useForm<SignupData>({ resolver: zodResolver(signupSchema) });
    
    const signupMutation = useMutation({
        mutationFn: (data: SignupData) => authApi.signup({
            email: data.email,
            name: data.name,
            password: data.password,
        }),
        onSuccess: (data) => {
            setAuth({
                user: data.user,
                accessToken: data.accessToken,
                refreshToken: data.refreshToken,
            });
            navigate('/dashboard');
        },
    });
    
    return (
        <form onSubmit={form.handleSubmit((d) => signupMutation.mutate(d))}>
            {/* ... fields like Login + confirmPassword */}
        </form>
    );
}

Part 5: Protected Route

10. ProtectedRoute Component

หน้าที่ต้อง login ก่อนเข้าต้องมีตัวกั้น — ProtectedRoute เช็คว่ามี user ไหม ถ้าไม่มีก็ redirect ไป login (พร้อมจำหน้าเดิมไว้กลับมาหลัง login) และ AdminRoute เพิ่มการเช็ค role ส่วนนี้คือ pattern มาตรฐานของ route guard ใน React Router:

<Outlet /> = ช่องที่ React Router จะใส่ "ลูก route" ลงไป — เปรียบเหมือนช่อง {children} ของ layout
ใน router setup ด้านล่าง: <Route element={<ProtectedRoute />}><Route element={<Layout />}>...</Route></Route><Outlet /> ของ ProtectedRoute จะกลายเป็น <Layout /> ซึ่งใน Layout ก็มี <Outlet /> ของตัวเองที่กลายเป็น page (Dashboard/Users/...)
👉 ดูเพิ่มในบท React Router

tsx
// src/routes/ProtectedRoute.tsx
import { Navigate, Outlet, useLocation } from 'react-router-dom';
import { useAuthStore } from '@/stores/auth';

export function ProtectedRoute() {
    const user = useAuthStore((s) => s.user);
    const location = useLocation();
    
    if (!user) {
        return <Navigate to="/login" state={{ from: location }} replace />;
    }
    
    return <Outlet />;
}

// สำหรับ role
export function AdminRoute() {
    const user = useAuthStore((s) => s.user);
    const location = useLocation();
    
    if (!user) {
        return <Navigate to="/login" state={{ from: location }} replace />;
    }
    
    if (user.role !== 'ADMIN') {
        return <Navigate to="/403" replace />;
    }
    
    return <Outlet />;
}

11. Router Setup

มาประกอบ route ทั้งหมดเข้าด้วยกัน — แยกเป็น public (login/signup), protected (ห่อด้วย ProtectedRoute + Layout) และ admin-only สังเกตการซ้อน Route ที่ทำให้ route group ใช้ guard + layout ร่วมกันได้โดยไม่ต้องเขียนซ้ำทุกหน้า:

tsx
// src/App.tsx
import { Routes, Route } from 'react-router-dom';
import { ProtectedRoute, AdminRoute } from './routes/ProtectedRoute';
import { Layout } from './layouts/Layout';

export function App() {
    return (
        <Routes>
            {/* Public */}
            <Route path="/login" element={<Login />} />
            <Route path="/signup" element={<Signup />} />
            
            {/* Protected */}
            <Route element={<ProtectedRoute />}>
                <Route element={<Layout />}>
                    <Route path="/" element={<Home />} />
                    <Route path="/dashboard" element={<Dashboard />} />
                    <Route path="/users" element={<UserList />} />
                    <Route path="/users/:id" element={<UserDetail />} />
                    <Route path="/settings" element={<Settings />} />
                </Route>
            </Route>
            
            {/* Admin only */}
            <Route element={<AdminRoute />}>
                <Route element={<Layout />}>
                    <Route path="/admin/users" element={<AdminUsers />} />
                </Route>
            </Route>
            
            <Route path="/403" element={<Forbidden />} />
            <Route path="*" element={<NotFound />} />
        </Routes>
    );
}

Part 6: Layout + Logout

12. Layout with User Menu

Layout คือโครงร่วมของหน้า protected ทั้งหมด — มี nav bar, user menu และปุ่ม logout ที่เรียก API + เคลียร์ state ส่วนนี้แสดงการประกอบ layout ที่ใช้ข้อมูล user จาก store มาแสดงและจัดการ logout:

tsx
// src/layouts/Layout.tsx
import { Outlet, Link, useNavigate } from 'react-router-dom';
import { useMutation } from '@tanstack/react-query';
import { authApi } from '@/api/auth';
import { useAuthStore } from '@/stores/auth';

export function Layout() {
    const navigate = useNavigate();
    const user = useAuthStore((s) => s.user);
    const logout = useAuthStore((s) => s.logout);
    
    const logoutMutation = useMutation({
        mutationFn: authApi.logout,
        // ⚠️ Trade-off: onSettled รันทั้ง success และ fail
        //    - success: server ลบ refresh token แล้ว → ปลอดภัยจริง
        //    - fail (network down): local clear แต่ refresh token บน server ยังอยู่
        //      → ผู้ร้ายที่ขโมย refresh token ใช้ต่อได้จนหมดอายุ
        //
        // ทางเลือก: queue retry ผ่าน background job + warn user ว่า "logout ไม่สมบูรณ์"
        // ที่นี่ใช้ "fail-open" — UX สำคัญกว่าใน MVP
        onSettled: () => {
            logout();                  // store.logout() เคลียร์ queryClient.clear() ให้แล้ว (§5)
            navigate('/login');
        },
    });
    
    return (
        <div className="min-h-screen flex flex-col">
            <header className="bg-white border-b">
                <div className="max-w-6xl mx-auto px-4 py-3 flex items-center justify-between">
                    <Link to="/" className="font-bold text-lg">My App</Link>
                    
                    <nav className="flex items-center gap-4">
                        <Link to="/dashboard">Dashboard</Link>
                        <Link to="/users">Users</Link>
                        
                        <div className="flex items-center gap-2 ml-4">
                            <span className="text-sm">{user?.name}</span>
                            <button
                                onClick={() => logoutMutation.mutate()}
                                className="text-sm text-red-500 hover:underline"
                            >
                                Logout
                            </button>
                        </div>
                    </nav>
                </div>
            </header>
            
            <main className="flex-1 max-w-6xl mx-auto px-4 py-6 w-full">
                <Outlet />
            </main>
        </div>
    );
}

Part 7: App Bootstrap — รู้ว่า logged in หรือเปล่า

13. ปัญหา — Reload แล้ว Token หาย?

ใน §5 เราตั้ง partialize ให้ persist เก็บแค่ user (ไม่เก็บ token) → reload แล้ว token หายไปแน่นอน
ดังนั้น bootstrap ต้อง:

  1. เช็ค user ใน store (มีจากครั้งก่อน?)
  2. ถ้ามี → เรียก /auth/refresh (refresh cookie จะติดไปอัตโนมัติใน production) เพื่อขอ access token ใหม่
  3. ถ้า refresh fail → logout

MVP version (ถ้ายังเก็บ token ใน localStorage): token อยู่หลัง reload แต่ก็อาจ หมดอายุ ระหว่างที่ tab ปิดอยู่ → ต้องเช็คผ่าน /me ตามตัวอย่างด้านล่าง

tsx
// src/App.tsx
import { useEffect, useRef, useState } from 'react';
import { authApi } from '@/api/auth';
import { useAuthStore } from '@/stores/auth';

export function App() {
    const [bootstrapping, setBootstrapping] = useState(true);
    const accessToken = useAuthStore((s) => s.accessToken);
    const setAuth = useAuthStore((s) => s.setAuth);
    const logout = useAuthStore((s) => s.logout);
    
    // ⚠️ React 18+ StrictMode (เฉพาะ dev) เรียก effect 2 ครั้งโดยตั้งใจ
    //    เพื่อช่วยจับ bug จาก effect ที่ไม่ idempotent (ไม่ปลอดภัยถ้าเรียกซ้ำ)
    //    → ใช้ ref guard กัน /me ถูกยิง 2 ครั้ง (เปลือง network + อาจสะดุด rate limit)
    //    Production จะเรียกครั้งเดียวอยู่แล้ว — guard นี้ไม่กระทบ
    const didBootstrap = useRef(false);

    useEffect(() => {
        if (didBootstrap.current) return;
        didBootstrap.current = true;

        async function bootstrap() {
            // อ่านค่าล่าสุดจาก store ตอนรัน ไม่ใช่จาก closure (กัน stale value)
            const { accessToken: token, refreshToken } = useAuthStore.getState();
            if (!token) {
                setBootstrapping(false);
                return;
            }

            try {
                const user = await authApi.me();        // เรียก /me เพื่อตรวจว่า token ยังใช้ได้
                // ⭐ guard refreshToken ไม่ใช่ non-null assertion — กัน runtime error
                //    ถ้า refresh token หาย (rotation race / store glitch) ให้ logout ดีกว่าระเบิด
                if (!refreshToken) { logout(); return; }
                setAuth({ user, accessToken: token, refreshToken });
            } catch {
                logout();
            } finally {
                setBootstrapping(false);
            }
        }

        bootstrap();
        // dependency ว่างโดยตั้งใจ — bootstrap ครั้งเดียวตอน app start
        // eslint-disable-next-line react-hooks/exhaustive-deps
    }, []);
    
    if (bootstrapping) return <Spinner />;
    
    return <Routes>{/* ... */}</Routes>;
}

Part 8: Security Best Practices

13.5 ⚠️ Trade-off ที่ตัวอย่างในบทนี้ "ยังไม่ได้แก้" — อ่านก่อนเอาขึ้น production

ตัวอย่างทั้งบทเลือก approach แบบ MVP เพื่อความเข้าใจง่าย แต่ Junior อ่านแล้ว copy ไป production ตรง ๆ จะติด 3 ปัญหานี้:

(1) Logout ไม่ใช่ "instant" — JWT ยังใช้ได้จนหมดอายุ

java
public void logout(String email) {
    refreshRepo.deleteByUserEmail(email);
    // ⚠️ access token (JWT) ที่ออกไปแล้วยังใช้ได้จนถึง expiresAt (15 นาที)
}

JWT คือ "บัตรผ่านที่เซิร์ฟเวอร์เซ็นแล้วปล่อยออกไป" — เซิร์ฟเวอร์ verify ด้วย signature ไม่ได้ถาม DB ทุกครั้ง ดังนั้น revoke (รี-โว้ก = ยกเลิกสิทธิ์/บัตรผ่าน) ทันทีไม่ได้ ตามธรรมชาติของ JWT

ผลกระทบ: user กด logout บนเครื่องสาธารณะ → คนถัดไปยังใช้ access token ที่ค้างใน memory/devtools ของเบราว์เซอร์ได้สูงสุด 15 นาที

ตัวเลือกใน production:

  • เลือก access token ให้สั้น (5–15 นาที) — ลด window ของปัญหา (วิธีที่บทนี้ใช้)
  • Denylist (ดี-นาย-ลิสต์ = รายชื่อต้องห้าม เคยเรียก blacklist) ใน Redis — เก็บ jti (JWT id) ที่ revoke แล้ว, JwtFilter เช็คทุก request — แลกด้วย latency + Redis dependency
  • Session-based แทน JWT — เก็บ session id ใน DB/Redis ค่อย ๆ ทำ revoke ทันทีได้ แต่ scale ออกยากกว่า

(2) Logout = revoke ทุก device ของ user

refreshRepo.deleteByUserEmail(email) ลบ refresh token ของ user ทั้งหมด → user login จากมือถือ + laptop → logout ที่ laptop → มือถือก็โดน revoke ตามไปด้วย

แก้ในแบบ production: เพิ่ม column session_id หรือ device_label ใน refresh_tokens แล้ว delete by token ตัวเดียวที่เพิ่งใช้:

java
public void logout(String refreshToken) {
    refreshRepo.findByTokenHash(sha256(refreshToken)).ifPresent(refreshRepo::delete);
}

@PostMapping("/refresh") รับ RefreshRequest จาก request body — ใช้ HttpOnly cookie ไม่ได้ (JS อ่าน cookie ไม่ได้ ส่งใน body ไม่ได้) ถ้าจะย้ายไป HttpOnly Cookie ต้องแก้:

java
// Spring API ที่ใช้ในนี้:
//  - @CookieValue   = ดึงค่าจาก HTTP cookie ที่ browser ส่งมา
//  - HttpServletResponse = object สำหรับเซ็ต header/cookie ของ response (inject ได้ใน method param)
//  - ResponseCookie.from(...) = builder สร้าง cookie แบบ fluent (Spring 5+)
// (ดูบท Spring Boot "Cookies & Sessions" สำหรับรายละเอียด)
@PostMapping("/refresh")
public ResponseEntity<AuthResponse> refresh(
        @CookieValue(name = "refresh_token", required = false) String refreshToken,
        HttpServletResponse response) {
    if (refreshToken == null) throw new InvalidTokenException("Missing refresh token");

    AuthResponse auth = authService.refresh(refreshToken);

    // set cookie ใหม่ (rotation)
    ResponseCookie cookie = ResponseCookie.from("refresh_token", auth.refreshToken())
        .httpOnly(true)
        .secure(true)                 // production = ต้อง HTTPS
        // ⭐ Strict ดีกว่า Lax สำหรับ refresh-only path —
        //    Lax อนุญาตให้ cookie ติดไปกับ top-level navigation (click link จากเว็บอื่น)
        //    Strict ไม่ส่งใน cross-site navigation เลย — เหมาะกับ refresh ที่ไม่มี link จากภายนอก
        .sameSite("Strict")
        .path("/api/auth/refresh")    // ⭐ จำกัด path ให้แคบสุด (ไม่ใช่ /api/auth ทั้งหมด)
        .maxAge(java.time.Duration.ofDays(7))
        .build();
    response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());

    // ส่ง refreshToken เป็น null ใน response body (cookie จัดการแทน)
    return ResponseEntity.ok(new AuthResponse(auth.accessToken(), null, auth.expiresIn(), auth.user()));
}

Checklist ข้อ "HttpOnly cookie สำหรับ refresh token (ถ้าทำได้)" ด้านล่าง = หมายถึงการ migrate ตามแนวนี้ ไม่ใช่ toggle config ที่แก้บรรทัดเดียวได้


13.6 Passkeys / WebAuthn — มาตรฐาน passwordless 2026

ตั้งแต่ Spring Security 6.4 (พ.ย. 2024) มี first-class support สำหรับ passkey (พาส-คีย์) ผ่าน WebAuthn 2.0 (เว็บ-ออ-เธน) แล้ว — เป็นทิศทางใหม่ของ authentication ที่ผู้ผลิตหลัก (Apple, Google, Microsoft) ผลักดันแทน password

Passkey คืออะไร:

  • เป็น public-key cryptography — device ผู้ใช้เก็บ private key, server เก็บ public key
  • ผู้ใช้ unlock ด้วย biometric (Face ID, ลายนิ้วมือ) หรือ PIN ของ device
  • กัน phishing โดยธรรมชาติ — passkey ผูกกับ origin (domain) ผู้ร้ายสร้างเว็บปลอมก็ใช้ไม่ได้
  • ไม่มี shared secret ที่ server ขโมยได้ → DB หลุดก็ไม่กระทบ login

กรณีใช้:

  • รองรับเป็น option ควบคู่กับ password ก่อน — user เลือกเอง
  • ระบบใหม่ทั้งใบ (ไม่มี legacy) → เริ่มที่ passkey เป็นหลัก, password เป็น fallback

Spring config (สรุปสั้น):

java
@Bean
SecurityFilterChain security(HttpSecurity http) throws Exception {
    http
        .formLogin(withDefaults())
        .webAuthn(webAuthn -> webAuthn       // ⭐ Spring Security 6.4+
            .rpName("My App")
            .rpId("myapp.example")
            .allowedOrigins("https://myapp.example")
        );
    return http.build();
}

ดูรายละเอียดเต็มใน Spring Security Reference: "Passkeys / WebAuthn"


13.7 OAuth 2.1 + PKCE — สำหรับ SSO / social login

ถ้าใช้ SSO หรือ social login (Google, GitHub, ฯลฯ) — มาตรฐานปัจจุบันคือ OAuth 2.1 (โอ-ออธ ทู-พ้อยต์-วัน) ที่รวม best practices ของ OAuth 2.0 ไว้ใน RFC 9700 (BCP, ม.ค. 2024)

สิ่งที่เปลี่ยนจาก OAuth 2.0:

  • PKCE (พิก-ซี = Proof Key for Code Exchange) บังคับใช้กับทุก client (ไม่ใช่แค่ public client) — กัน authorization code interception
  • Implicit grant (ออก token ตรงผ่าน URL fragment) ถูก deprecated — ใช้ Authorization Code + PKCE แทน
  • Password grant (ส่ง username/password ตรงไป auth server) ถูก deprecated — ใช้ redirect flow แทน
  • Refresh token rotation บังคับ สำหรับ public client

ใน Spring Boot: ใช้ spring-boot-starter-oauth2-client — PKCE เปิด default ตั้งแต่ Spring Security 5.7+

yaml
spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: ${GOOGLE_CLIENT_ID}
            client-secret: ${GOOGLE_CLIENT_SECRET}
            scope: openid, profile, email
            # PKCE เปิดอัตโนมัติเมื่อ provider รองรับ

OIDC (โอ-ไอ-ดี-ซี = OpenID Connect) = ชั้นบนสุดของ OAuth 2.0/2.1 ที่เพิ่ม id_token (JWT บอกข้อมูล user) — ใช้ใน enterprise SSO เป็นหลัก


14. Production Checklist (2026)

Password / Hashing

  • [ ] BCrypt cost ≥ 12 (บี-คริปต์ cost = ระดับความหนักในการคำนวณ ยิ่งเลขสูงยิ่งคำนวณนาน → คนร้าย brute-force ยากขึ้น แต่ login ช้าลง)
    • OWASP 2023+ แนะนำ cost 12 ขึ้นไป — cost 10 เก่าแล้ว
    • Target timing 250–500 ms ต่อ hash บน CPU ปกติของ server ปี 2026 → benchmark แล้วปรับให้เข้าเป้า
    • ตัวอย่าง: new BCryptPasswordEncoder(12) (ค่า default ของ Spring Security คือ 10 ต้อง override)
  • [ ] พิจารณา Argon2id เป็นทางเลือก — OWASP 2024 จัดเป็น "primary recommendation" สำหรับการเก็บ password
    • Argon2id (อาร์-กอน-ทู-ไอ-ดี) ทน GPU/ASIC ดีกว่า BCrypt
    • Spring Security มี Argon2PasswordEncoder — ใช้ได้ทันที
    • BCrypt ยังโอเค ถ้าระบบรันอยู่แล้ว — ไม่ต้องรีบย้าย

JWT

  • [ ] JWT signed with strong secret (env, ≥256 bits) สำหรับ HS256 (monolithic app)
  • [ ] หรือ RS256 / ES256 (asymmetric — public/private key คู่) สำหรับ multi-service:
    • Service อื่นใช้ public key verify ได้โดยไม่ต้องรู้ private key (= ขโมยจาก service เดียวก็ไม่ปลอม token ได้)
  • [ ] 🔴 JWT algorithm confusion attack — ป้องกัน:
    • ตั้งค่า library ให้ accept เฉพาะ algorithm ที่เรากำหนด (whitelist)
    • ห้ามใช้ jwt.decode(token) แบบไม่ verify — บางคนเผลอ trust alg ใน header ของ token (เช่น ผู้ร้ายส่ง alg: "none" มา หรือเปลี่ยน RS256 → HS256 แล้วใช้ public key เป็น HMAC secret)
  • [ ] Access token อายุ ≤ 15 นาที
  • [ ] Refresh token อายุ ≤ 7 วัน + rotation + token family revocation (replay detection ตาม RFC 6749 BCP)

Auth flow

  • [ ] Rate limit /auth/login (5 req/min/IP) — ทำที่ filter หรือ nginx (ดู บทที่ 5 §22)
  • [ ] Lock account หลัง fail 5 ครั้ง — ต้องเก็บ counter ใน DB/Redis (บทนี้ยังไม่ implement)
  • [ ] Email verification — ยังไม่ implement ในบทนี้ ดูแนวทางใน Spring Boot Mail
  • [ ] OAuth 2.1 + PKCE สำหรับ SSO / social login (RFC 9700 BCP, 2024) — implicit + password grants deprecated แล้ว
  • [ ] Support Passkeys (WebAuthn 2.0) เป็นทางเลือก passwordless (Spring Security 6.4+, พ.ย. 2024)

Transport

  • [ ] HTTPS only — TLS (ที-แอล-เอส) 1.2+ — Transport Layer Security (ทรานส-ปอร์ต เล-เยอร์ ซี-คิว-ริ-ตี้) คือโพรโทคอลเข้ารหัสข้อมูลระหว่างเบราว์เซอร์กับเซิร์ฟเวอร์ ชื่อเก่าคือ SSL; HTTPS = HTTP ที่เข้ารหัสด้วย TLS
  • [ ] CORS allowed origins ระบุชัด — ไม่ใช้ *

Frontend

  • [ ] Content Security Policy (CSP) header (CSP = กฎที่บอกเบราว์เซอร์ว่าอนุญาตให้รันสคริปต์จากแหล่งไหนได้ ลดโอกาสโดน XSS)
  • [ ] Trusted Types policy — บังคับให้ DOM sinks (innerHTML, ฯลฯ) รับเฉพาะค่าที่ผ่าน sanitizer ที่ขึ้นทะเบียนไว้
  • [ ] Permissions-Policy header — ปิด feature ที่ไม่ใช้ (camera, microphone, geolocation, ฯลฯ)
  • [ ] Subresource Integrity (SRI) สำหรับ third-party scripts — ใส่ integrity="sha384-..." กัน CDN ถูกเปลี่ยนแอบ
  • [ ] อย่า dangerouslySetInnerHTML กับ user input
  • [ ] Sanitize markdown ก่อน render (DOMPurify, sanitize-html)
  • [ ] HttpOnly + Secure + SameSite=Strict cookie สำหรับ refresh token
  • [ ] Logout = clear ทุก local state + cookie + queryClient

15. ⚠️ Common Pitfalls

Pitfallแก้
Token อายุ 1 ปีสั้น (15 นาที) + refresh
Refresh token ไม่ rotateออกใหม่ทุก refresh + token family revocation
Refresh token ใน localStorageHttpOnly + Secure + SameSite=Strict cookie
UUID.randomUUID() สำหรับ security tokenSecureRandom(32 bytes) + base64url
SHA-256 ตรง ๆ สำหรับ token hashHMAC-SHA256 + server pepper
BCrypt cost 10cost 12+ (OWASP 2023+) ตั้งเป้า 250–500 ms
catch แค่ BadCredentialsExceptioncatch AuthenticationException ครอบ Disabled/Locked/Expired
logout by emaillogout by user.id (stable)
SameSite=Lax สำหรับ refresh cookieSameSite=Strict + path แคบสุด
ลืม clear state ตอน logoutclear Zustand + queryClient.clear()
401 ทุก endpoint → infinite redirectretry 1 ครั้ง + skip /auth/refresh
JWT alg confusion (alg=none, RS256↔HS256)whitelist algorithm ใน library config
Password ใน URL (?password=...)POST body เท่านั้น
ส่ง token ไป third-party APIอย่า — token แค่สำหรับ backend ของเรา
Console.log tokenอย่า — ไปใน analytics/Sentry ได้

16. Clear React Query Cache ตอน Logout

จุดที่มักลืมตอน logout — ไม่ใช่แค่เคลียร์ token แต่ต้อง queryClient.clear() ด้วย ไม่งั้นข้อมูลของ user เก่ายังค้างใน cache แล้วโผล่ให้ user ใหม่เห็น (data leak ระหว่าง account)

💡 ใน §5 เราใส่ queryClient.clear() ไว้ใน logout ของ store แล้ว ที่นี่อธิบายเหตุผลซ้ำสั้น ๆ เผื่อคนข้ามมา

ts
// store/auth.ts (ดู §5 — code เต็ม)
import { queryClient } from '@/api/queryClient';

logout: () => {
    set({ user: null, accessToken: null, refreshToken: null });
    queryClient.clear();        // ⭐ clear cache ของ previous user
}

ไม่งั้น user ถัดไป login → เห็น data ของ user เดิม!


17. Checkpoint

🛠️ Checkpoint 2.1 — Signup + Login
ทำ flow signup → login → ดู /dashboard ที่แสดงชื่อ + email

  • ทดสอบ wrong password → error
  • ทดสอบ duplicate email → error
  • ทดสอบ password ไม่ตรง confirm → frontend validation

🛠️ Checkpoint 2.2 — Protected Route

  • /users = protected
  • ไม่ login → /login (state.from = /users)
  • หลัง login → redirect กลับ /users

🛠️ Checkpoint 2.3 — Auto Refresh

  • ตั้ง access token อายุ 30 วินาที (สำหรับ test)
  • ทำ request หลัง 31 วินาที → ต้องไม่ error (auto refresh)
  • ลบ refresh token จาก localStorage → request → ต้อง redirect /login

🛠️ Checkpoint 2.4 — Role-Based

  • สร้าง user ADMIN ใน DB
  • หน้า /admin/users ใช้ได้แค่ ADMIN
  • USER เข้า → /403

18. สรุปบท

✅ Auth flow: signup/login → JWT + Refresh → save → แนบทุก request → 401 → refresh → retry
✅ Backend: @PostMapping("/auth/login") + JwtService + RefreshTokenRepository (HMAC + family)
✅ Frontend: Zustand store + HTTP client ที่ auto refresh + ProtectedRoute
✅ Token storage: localStorage = MVP, HttpOnly + Strict cookie = production
✅ Access token สั้น (15 นาที), refresh token ยาว (7 วัน) + rotation + replay detection
ProtectedRoute + AdminRoute ด้วย <Outlet>
✅ App bootstrap → /auth/me verify token ก่อน render
✅ Logout → clear store + queryClient.clear() + redirect (by user.id ไม่ใช่ email)
✅ Production 2026: HTTPS/TLS 1.2+, BCrypt cost 12+ (หรือ Argon2id), rate limit, account lockout, JWT alg whitelist, OAuth 2.1 + PKCE, Passkeys/WebAuthn


← บทที่ 1 | บทที่ 3 → CRUD + Optimistic UI


Glossary: ../glossary.md · Style guide: ../CONTRIBUTING.md last_verified: 2026-06-03 · review report: ../REVIEW-2026-06-03.md