ข้ามไปยังเนื้อหา

Auth Pages

frontend/src/pages/login.astro กับ register.astro — สองหน้าแรกที่ห่อตัวเองด้วย Base แต่ละหน้าเป็นฟอร์ม HTML ธรรมดา (login: email + password; register: display name + email + password) พร้อม <script> แบบ inline ที่ตอน submit จะเรียก POST /auth/login หรือ POST /auth/register ผ่าน apiFetch wrapper แบบมี type เก็บ token ที่ได้กลับมา แล้ว redirect ไปที่ /

wrapper นั้น — frontend/src/lib/api.ts — จริง ๆ แล้วเป็นหัวข้อของบทถัดไป api-client แต่สองฟอร์มนี้เรียก backend ไม่ได้เลยถ้าไม่มีไฟล์นั้น บทนี้จึงสร้างไฟล์นี้เต็มรูปแบบตั้งแต่ตอนนี้ ตรงกับ contract เป๊ะ ๆ ที่ api-client จะไล่อธิบายทีละ function ทีหลัง ถือว่าถูก introduce ที่นี่ แล้วอธิบายที่นั่น

ฟอร์ม login หรือ register ต้องการ state แค่ชิ้นเดียวตอน submit: สำเร็จหรือล้มเหลว new FormData(form) อ่านค่าปัจจุบันของทุก field ได้อยู่แล้ว โดยไม่ต้องมี onChange handler ทุกครั้งที่พิมพ์ หรือ component state ให้ต่อสาย — เป็นอีกเหตุผลที่ Pros & cons ของ shell-layout เลือก Astro ธรรมดาแทน island สำหรับหน้าแบบนี้

การ submit คือ event.preventDefault() ตามด้วยการเรียก fetch ไม่ใช่ form POST แบบ HTML ธรรมดาตรงไปที่ backend เพราะการ submit form จริง ๆ คือการ navigate ทั้งหน้า ไม่มีที่ให้ดัก JSON response body ได้เลย POST /auth/login กับ POST /auth/register ทั้งคู่คืน {token, user} (handlers สร้าง response shape นี้ไว้) token ต้องมาอยู่ในมือของ JavaScript เพื่อให้ setToken เก็บลง storage ก่อนที่อย่างอื่นจะเกิดขึ้น

ลำดับสำคัญในฝั่งที่สำเร็จ: setToken(result.token) รันก่อน แล้วค่อย window.location.href = '/' boards-list หน้าที่ redirect ไปลง เช็คหา token ทันทีที่โหลด — ถ้า redirect เกิดก่อน token จะยังไม่อยู่ใน localStorage ตอนที่เช็คนั้นรัน แล้วหน้าใหม่ก็จะเด้งกลับไปที่ /login ทันที

เก็บ token ใน localStorage (ตัวที่เราใช้) เทียบกับ httpOnly cookie

  • ข้อดี: ฝั่ง backend ไม่ต้องแก้อะไรเลยเพื่อรองรับ — handlers คืน {token, user} เป็น JSON body ธรรมดาอยู่แล้ว และ JSON body คือสิ่งเดียวที่ static frontend จัดการได้ง่าย ๆ apiFetch (บทถัดไป) อ่าน token ตรง ๆ จาก localStorage แล้วแนบ Authorization: Bearer <token> ให้ทุก request ด้วยโค้ดบรรทัดเดียว ไม่ต้องมี credentials: 'include' ไม่ต้องปรับ SameSite และไม่ต้องยุ่งกับความซับซ้อนของ cookie ข้าม origin ซึ่งสำคัญตรงนี้เพราะ static frontend กับ Rust API เป็นสอง deployment แยกกัน ไม่ใช่ origin เดียวที่เสิร์ฟทั้งคู่
  • ข้อเสีย: JavaScript อะไรก็ตามที่รันบนหน้านั้นอ่าน localStorage.getItem('token') ได้ — รวมถึง XSS injection ที่สำเร็จจาก dependency ที่ถูกแฮ็ก หรือ stored-XSS bug ที่ไหนก็ได้บน origin เดียวกัน httpOnly cookie มองไม่เห็นจาก JavaScript เลย — แม้แต่ XSS payload เต็มรูปแบบที่รันอยู่บนหน้านั้นก็อ่านค่า cookie ไม่ได้ ทำได้แค่อาศัย request ที่ browser แนบ cookie ให้อัตโนมัติอยู่แล้วเท่านั้น
  • trade-off ที่ตรงไปตรงมา: คอร์สนี้เลือก localStorage เพราะ frontend เป็น static site ที่ไม่มี server-side request ไหนตั้งค่า Set-Cookie ข้าม origin ได้โดยไม่ต้องจัดการ SameSite=None; Secure กับ CORS credentials อย่างระมัดระวัง — ไม่ใช่เพราะ localStorage “โอเค” เฉย ๆ ระบบ production ที่ปกป้องอะไรที่ sensitive กว่าบอร์ด Kanban ในคอร์สนี้ควรใช้ httpOnly cookie เป็นค่าเริ่มต้น แล้วมอง token ใน localStorage เป็นข้อยกเว้นที่ต้องมีเหตุผลรองรับ ไม่ใช่กลับกัน
  • แนวทางบรรเทาความเสี่ยง ถ้าแอปจริงยังใช้ localStorage ต่อ: Content-Security-Policy ที่เข้มงวด (script-src 'self' ไม่มี unsafe-inline) ที่บล็อก script ที่ไม่ได้รับอนุญาตไม่ให้รันได้เลย ปิดช่องทางที่พบบ่อยที่สุดที่ attacker จะทำให้ JavaScript รันบนหน้าได้ตั้งแต่ต้น; token TTL สั้น ๆ เพื่อให้ token ที่หลุดมีหน้าต่างที่ใช้ประโยชน์ได้แคบ — jwt ตั้ง exp ของ TaskFlow ไว้ 24 ชั่วโมงเพราะการสร้าง refresh flow อยู่นอกขอบเขตของบทนั้นโดยเฉพาะ ไม่ใช่เพราะ 24 ชั่วโมงคือตัวเลขที่ถูกต้องสำหรับระบบ production; และ refresh-token flow — token ตัวที่สองที่เก็บใน httpOnly cookie ใช้แค่ผลิต access token อายุสั้นใหม่เท่านั้น เพื่อให้แม้แต่ access token ที่ถูกขโมยก็หมดอายุเร็ว และ credential ที่สำคัญจริง ๆ ในระยะยาวไม่มีทาง JavaScript อ่านได้เลย ไม่มีอะไรในนี้ถูกสร้างในคอร์สนี้ แต่นั่นคือทิศทางที่แอปจริงเติบโตต่อจากนี้
/// <reference types="astro/client" />
interface ImportMetaEnv {
readonly PUBLIC_API_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

ไฟล์นี้ทำให้ import.meta.env.PUBLIC_API_URL มี type จริง — string — แทนที่จะตกไปใช้สิ่งที่ ImportMetaEnv ในตัวของ Astro ประกาศไว้สำหรับ key ที่ไม่รู้จัก api-client จะอธิบาย PUBLIC_API_URL เองอย่างละเอียดอีกที ไฟล์นี้แค่ต้องมีอยู่ก่อนที่อะไรจะ import เข้าไป

PUBLIC_API_URL=http://localhost:8080

มีแค่ตัวแปรที่ขึ้นต้นด้วย PUBLIC_ เท่านั้นที่จะ bundle เข้าไปในโค้ดฝั่ง client — นี่เป็นกฎของ Vite และ Astro รับมาใช้ตรง ๆ ค่า PUBLIC_API_URL ไม่ใช่ความลับ เพราะอ่านได้จาก JavaScript bundle ที่ส่งออกไปอยู่ดี เรื่องนี้จึงเป็นเรื่อง configuration ล้วน ๆ — เป็นบรรทัดเดียวที่เปลี่ยนระหว่าง backend local ที่ :8080 กับ backend ที่ deploy จริง

export class ApiError extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
}
}
const TOKEN_KEY = 'token';
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
export function setToken(token: string): void {
localStorage.setItem(TOKEN_KEY, token);
}
export function clearToken(): void {
localStorage.removeItem(TOKEN_KEY);
}
export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
const headers = new Headers(init.headers);
headers.set('Content-Type', 'application/json');
const token = getToken();
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
const res = await fetch(`${import.meta.env.PUBLIC_API_URL}${path}`, {
...init,
headers,
});
if (!res.ok) {
let message = res.statusText;
try {
const body = (await res.json()) as { message?: string };
if (body?.message) {
message = body.message;
}
} catch {
// no JSON body to read a message from — statusText is the fallback
}
throw new ApiError(res.status, message);
}
if (res.status === 204 || res.headers.get('content-length') === '0') {
return undefined as T;
}
return (await res.json()) as T;
}
export interface Board {
id: string;
owner_id: string;
title: string;
created_at: string;
}

api-client คือบทที่ทุกส่วนของไฟล์นี้จะได้รับการอธิบายเต็ม ๆ — ApiError, logic ของ header, การจัดการ 204/empty-body ตอนนี้มีแค่สาม export ที่สองฟอร์มนี้ต้องใช้: apiFetch, setToken, และ ApiError

---
import Base from '../layouts/Base.astro';
---
<Base title="Log in">
<h1>Log in</h1>
<form id="login-form">
<label>
Email
<input type="email" name="email" required autocomplete="email" />
</label>
<label>
Password
<input type="password" name="password" required autocomplete="current-password" />
</label>
<p id="login-error" role="alert" hidden></p>
<button type="submit">Log in</button>
</form>
<p>No account yet? <a href="/register">Register</a></p>
</Base>
<script>
import { apiFetch, setToken, ApiError } from '../lib/api';
interface LoginResponse {
token: string;
user: { id: string; email: string; display_name: string };
}
const form = document.getElementById('login-form') as HTMLFormElement;
const errorEl = document.getElementById('login-error') as HTMLParagraphElement;
form.addEventListener('submit', async (event) => {
event.preventDefault();
errorEl.hidden = true;
const data = new FormData(form);
const email = data.get('email') as string;
const password = data.get('password') as string;
try {
const result = await apiFetch<LoginResponse>('/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
setToken(result.token);
window.location.href = '/';
} catch (err) {
errorEl.textContent = err instanceof ApiError ? err.message : 'Something went wrong. Try again.';
errorEl.hidden = false;
}
});
</script>
---
import Base from '../layouts/Base.astro';
---
<Base title="Register">
<h1>Register</h1>
<form id="register-form">
<label>
Display name
<input type="text" name="display_name" required autocomplete="name" />
</label>
<label>
Email
<input type="email" name="email" required autocomplete="email" />
</label>
<label>
Password
<input type="password" name="password" required autocomplete="new-password" minlength="8" />
</label>
<p id="register-error" role="alert" hidden></p>
<button type="submit">Register</button>
</form>
<p>Already have an account? <a href="/login">Log in</a></p>
</Base>
<script>
import { apiFetch, setToken, ApiError } from '../lib/api';
interface RegisterResponse {
token: string;
user: { id: string; email: string; display_name: string };
}
const form = document.getElementById('register-form') as HTMLFormElement;
const errorEl = document.getElementById('register-error') as HTMLParagraphElement;
form.addEventListener('submit', async (event) => {
event.preventDefault();
errorEl.hidden = true;
const data = new FormData(form);
const display_name = data.get('display_name') as string;
const email = data.get('email') as string;
const password = data.get('password') as string;
try {
const result = await apiFetch<RegisterResponse>('/auth/register', {
method: 'POST',
body: JSON.stringify({ email, password, display_name }),
});
setToken(result.token);
window.location.href = '/';
} catch (err) {
errorEl.textContent = err instanceof ApiError ? err.message : 'Something went wrong. Try again.';
errorEl.hidden = false;
}
});
</script>

ต่อท้ายไฟล์ที่ shell-layout สร้างไว้:

form {
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 360px;
}
label {
display: flex;
flex-direction: column;
gap: 0.35rem;
font-size: 0.9rem;
}
input {
font: inherit;
padding: 0.5rem 0.65rem;
border: 1px solid #33333355;
border-radius: 6px;
}
[role='alert'] {
color: #c0392b;
}
Terminal window
cd frontend
npm run dev

เปิด backend ให้รันอยู่ (cargo run -p api จาก handlers) เปิดหน้า /register กรอกฟอร์ม แล้ว submit คุณควรจะไปลงที่ / โดยที่ nav bar (จาก shell-layout) โชว์ “Boards / Log out” อยู่แล้ว เปิด devtools เช็ค localStorage.getItem('token') แล้วยืนยันว่าเก็บ JWT ที่ backend ออกให้ไว้จริง log out ไปที่ /login แล้ว log in กลับด้วย credential เดิม — ผลลัพธ์เดียวกัน ลอง submit ฟอร์มไหนก็ได้ด้วยรหัสผ่านผิด แล้วยืนยันว่าย่อหน้า error โชว์ข้อความจริงจาก backend (Unauthorized จาก error mapping ของ handlers) แทนที่จะเป็นข้อความ fail แบบทั่วไป

คุณสร้าง login.astro กับ register.astro — สองฟอร์มที่ submit handler เรียก backend ผ่าน apiFetch เก็บ token ที่ได้กลับมาด้วย setToken แล้ว redirect ไปที่ / ระหว่างทาง frontend/src/lib/api.ts ก็เกิดขึ้นก่อนเวลาโดยความจำเป็น และคุณเทียบ token storage แบบ localStorage กับ httpOnly cookie อย่างตรงไปตรงมา — localStorage คือทางเลือกที่ pragmatic สำหรับ static frontend ที่คุยกับ API แยกต่างหาก ไม่ใช่ทางเลือกที่ปลอดภัยแบบไม่มีเงื่อนไข และแอปจริงจะเสริม CSP, TTL สั้น ๆ, กับ refresh flow ทับเข้าไปแทนที่จะมอง localStorage เป็นเส้นชัย ต่อไป api-client จะย้อนกลับไปไล่ api.ts ทีละ function — deep dive ที่บทนี้เลื่อนไปทำทีหลัง