โหมดมืด
บทที่ 2 — Auth Flow End-to-End (Login → JWT → Protected Route)
บทนี้ทำ 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) ไม่ลง implementationRecap คร่าว ๆ:
JwtService.generate(user)→ คืน access token (string) ลงนามด้วย secret ของ serverPasswordEncoder(BCrypt impl) →encode()ตอน signup,matches()ตอน loginAuthenticationManager.authenticate(token)→ เช็ค email+password กับ DB ถ้าไม่ผ่าน throwAuthenticationExceptionCustomUserDetails implements UserDetails→ wrapper รอบUserentity ที่เพิ่ม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,@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):
- Server สุ่ม token ด้วย
SecureRandom256-bit → base64url encode → ส่ง plaintext ให้ client ครั้งเดียว- เก็บแค่
HMAC-SHA256(token, server_pepper)ใน DB- ตอน 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" ก่อน
persistmiddleware = บันทึก 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 เก็บที่ไหน | localStorage | Memory (Zustand) | Memory |
| Refresh token เก็บที่ไหน | localStorage ⚠️ | HttpOnly Cookie | ไม่เก็บ — re-auth ผ่าน SSO |
| อ่านจาก JS ได้ไหม | ✅ ได้ (= เสี่ยง) | ❌ refresh อ่านไม่ได้ | — |
| ทน XSS | ❌ token หายทั้งคู่ | ✅ refresh ทน, access เสี่ยงระหว่าง session | ✅✅ ดีสุด |
| ทน CSRF | ✅ (ต้องแนบ header เอง) | ⚠️ ต้องใส่ SameSite=Strict + CSRF token | ✅ |
| ความยาก | ง่ายสุด | server + cookie config | ต้องมี IdP/SSO |
| Reload แล้ว stay logged in | ✅ | ✅ (refresh cookie) | ✅ (silent SSO) |
| เหมาะกับ | dev / MVP | SPA production | enterprise / 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 ต้อง:
- เช็ค user ใน store (มีจากครั้งก่อน?)
- ถ้ามี → เรียก
/auth/refresh(refresh cookie จะติดไปอัตโนมัติใน production) เพื่อขอ access token ใหม่ - ถ้า 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);
}(3) Backend ปัจจุบันไม่รองรับ HttpOnly Cookie
@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 — บางคนเผลอ trustalgใน 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 ใน localStorage | HttpOnly + Secure + SameSite=Strict cookie |
UUID.randomUUID() สำหรับ security token | SecureRandom(32 bytes) + base64url |
| SHA-256 ตรง ๆ สำหรับ token hash | HMAC-SHA256 + server pepper |
| BCrypt cost 10 | cost 12+ (OWASP 2023+) ตั้งเป้า 250–500 ms |
| catch แค่ BadCredentialsException | catch AuthenticationException ครอบ Disabled/Locked/Expired |
| logout by email | logout by user.id (stable) |
| SameSite=Lax สำหรับ refresh cookie | SameSite=Strict + path แคบสุด |
| ลืม clear state ตอน logout | clear Zustand + queryClient.clear() |
| 401 ทุก endpoint → infinite redirect | retry 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