Middleware & Sessions
Middleware: ประตูหน้าทุก request
หัวข้อที่มีชื่อว่า “Middleware: ประตูหน้าทุก request”Middleware คือ function ตัวเดียวที่รันก่อนทุก requestจะถูกจัดการ มันอยู่ใน src/middleware.ts และ export onRequest(context, next) — คุณทำงานอะไรบางอย่าง แล้วเรียก next() เพื่อไปต่อยัง page หรือ endpoint (หรือตัด flow ด้วย Response ของตัวเอง)
import { defineMiddleware } from 'astro:middleware';
// Stand-in for your real session lookup — replace with your auth layer.async function getUser(token: string) { return { id: '1', name: 'Ava' } as { id: string; name: string } | null; }
export const onRequest = defineMiddleware(async (context, next) => { // Runs for every request, before the route. const token = context.cookies.get('session')?.value; context.locals.user = token ? await getUser(token) : null; // share data downstream
// Guard a section of the site. if (context.url.pathname.startsWith('/admin') && !context.locals.user) { return context.redirect('/login'); // short-circuit — never reach the page }
return next(); // continue to the route});sequenceDiagram
participant B as Browser
participant M as onRequest middleware
participant R as Route (page/endpoint)
B->>M: request
M->>M: read cookies, set context.locals
alt not authorized
M-->>B: redirect / 403 (short-circuit)
else authorized
M->>R: next()
R-->>B: rendered response
endรวมหลาย middleware ด้วย sequence()
หัวข้อที่มีชื่อว่า “รวมหลาย middleware ด้วย sequence()”โปรเจกต์จริงแทบไม่เคยมี concern เดียวให้ middleware จัดการ — auth, request logging, middleware ของ integration เอง, กฎ rewrite เขียนทุกอย่างลงใน onRequest ก้อนเดียวทำให้แต่ละ concern test หรือสลับลำดับยาก sequence() รวมหลาย middleware function เป็นตัวเดียว รันตามลำดับที่ส่งเข้าไป:
import { defineMiddleware, sequence } from 'astro:middleware';
// Stand-in for your real session lookup — replace with your auth layer.async function getUser(token: string) { return { id: '1', name: 'Ava' } as { id: string; name: string } | null; }
const requestId = defineMiddleware(async (context, next) => { context.locals.requestId = crypto.randomUUID(); return next();});
const auth = defineMiddleware(async (context, next) => { const token = context.cookies.get('session')?.value; context.locals.user = token ? await getUser(token) : null; return next();});
export const onRequest = sequence(requestId, auth);ลำดับนั้นไม่ใช่แค่ตกแต่ง — พิสูจน์ด้วยการให้แต่ละ middleware ต่อชื่อตัวเองใส่ header ตอนขากลับ:
$ curl -sD - -o /dev/null http://127.0.0.1:4351/ | grep -i x-mw-orderx-mw-order: first,secondx-mw-order header ข้างบนถูก middleware ตัวที่สองใน chain sequence(first, second, guard) set โดยอ่าน list ที่ middleware ทั้งสองต่อชื่อตัวเองใส่ไว้ — first รันจริง แล้วส่งต่อให้ second ตามลำดับที่ส่งเข้า sequence() ไม่ใช่กลับกัน
context.locals: type ด้วย App.Locals
หัวข้อที่มีชื่อว่า “context.locals: type ด้วย App.Locals”context.locals คือ object ที่มีชีวิตอยู่ตลอด request เดียว อะไรก็ตามที่คุณ set ไว้ (user ปัจจุบัน, request id, feature flag) อ่านได้ในทุก page, endpoint และ action ของ request นั้นผ่าน Astro.locals / context.locals ของ action ถ้าไม่ type ไว้ locals จะเป็น any โดย implicit ทุกที่ที่อ่าน — declare shape มันครั้งเดียวใน src/env.d.ts แล้วทุกไฟล์จะได้ autocomplete และ type error แทน:
type User = { id: string; name: string };
declare namespace App { interface Locals { user: User | null; requestId: string; }}---// In any on-demand page — read what middleware put there, fully typed.const user = Astro.locals.user;---{user ? <p>Hi, {user.name}</p> : <a href="/login">Sign in</a>}Rewrite ใน chain: context.rewrite และ next(path)
หัวข้อที่มีชื่อว่า “Rewrite ใน chain: context.rewrite และ next(path)”มีสองเครื่องมือที่เปลี่ยนว่า route ไหนจัดการ request จริง ๆ และพฤติกรรมต่างกันเมื่ออยู่ใน sequence() chain:
next(path)rewrite request แล้วไปต่อ chain — middleware ตัวถัดไปใน sequence เห็น URL ใหม่และยังทำงานกับมันได้context.rewrite(path)(primitive เดียวกับที่Astro.rewrite()ใช้ใน page) สลับไป route อื่นทันที โดยไม่ผ่าน middleware ที่เหลือใน chain
พิสูจน์ด้วย middleware ที่เรียก next('/api/rewrite-demo') เมื่อเห็น /old-path ก่อนถึง middleware ตัวที่สองใน sequence() เดียวกัน:
$ curl -s http://127.0.0.1:4351/old-path{"pathname":"/api/rewrite-demo"}server log จาก request เดียวกันนั้นแสดงว่า middleware ตัวที่สองเห็น URL ที่ถูก rewrite แล้ว ไม่ใช่ตัวเดิม:
[mw] first ran, order so far: first[mw] first: rewriting /old-path -> /api/rewrite-demo[mw] second sees pathname: /api/rewrite-demo[mw] second ran, order so far: first,secondใช้ next(path) เมื่อ middleware ที่ตามมายังต้องรันกับ route ใหม่ (auth, logging) ใช้ context.rewrite(path) เมื่อต้องการส่งต่อไปยัง page คนละหน้าไปเลยและข้าม chain ที่เหลือ อีกจุดที่ควรจำ: การ rewrite สร้าง Request ใหม่ ดังนั้นการอ่าน Request.body (เช่นอ่าน POST body แบบที่ Action ทำ) ก่อนหรือหลัง rewrite จะ throw ตอน runtime — Astro.rewrite() ระดับ page คือทางแก้ที่เอกสารแนะนำสำหรับ route ที่เป็น form/action
Middleware กับหน้า prerendered: build-time เท่านั้น
หัวข้อที่มีชื่อว่า “Middleware กับหน้า prerendered: build-time เท่านั้น”Middleware ยังรันกับ page ที่มี export const prerender = true — แต่รันแค่ครั้งเดียว ตอน build เมื่อ Astro generate HTML แบบ static ของหน้านั้น หลังจากนั้นไม่มี request ให้ intercept อีก — ไฟล์ถูก serve ตามที่มันเป็น พิสูจน์ด้วยการเพิ่มหน้า prerendered แล้วดู log middleware ตัวเดียวกันยิงตอน build เอง ไม่ใช่ตอน browser ขอหน้านั้นทีหลัง:
$ npx astro build prerendering static routes ├─ /static-about/index.html[mw] first ran, order so far: first[mw] second sees pathname: /static-about/ ├─ /static-about/index.html (+21ms)บรรทัด [mw] ... เหล่านั้นคือ console.log จาก sequence() chain ตัวเดียวกันข้างบน — print ครั้งเดียวตอน phase prerendering static routes ของ astro build ไม่มีอีกเลยเมื่อ server รันและ serve ไฟล์นั้นจาก disk อะไรก็ตามใน middleware ที่พึ่งพา request ปัจจุบัน (cookies, IP, header ของ client จริง) ไม่มีความหมายที่นี่: middleware เห็น request context ที่ build process สร้างขึ้นเอง ไม่ใช่ของผู้เข้าชมจริง นี่คือเหตุผลที่การอ่าน Astro.request.headers บนหน้า prerendered ขึ้น warning จริงตอน runtime — พิสูจน์ตรง ๆ ใน build เดียวกันนี้:
[WARN] `Astro.request.headers` was used when rendering the route `src/pages/static-about.astro'`. `Astro.request.headers` is not available on prerendered pages.Session: state ต่อ user ข้าม request
หัวข้อที่มีชื่อว่า “Session: state ต่อ user ข้าม request”context.locals อยู่แค่ request เดียว การจำ user ข้าม request — ตะกร้าสินค้า, การ login — ใช้ session Astro เก็บ session data ฝั่ง server คีย์ด้วย cookie backed โดย driver ที่ config ไว้ Node adapter เปิดให้อัตโนมัติถ้าคุณไม่ config เอง — พิสูจน์ด้วย build log จริง:
[@astrojs/node] Enabling sessions with filesystem storage---// Read/write session data in a component script:const cart = (await Astro.session?.get('cart')) ?? [];---<p>{cart.length} items in your cart</p>// In an action handler, context.session is the same session:handler: async (input, context) => { const cart = (await context.session?.get('cart')) ?? []; cart.push(input.productId); await context.session?.set('cart', cart); return cart;}Session มี get, set และ destroy ตอน logout เคลียร์ทุกอย่างด้วย Astro.session?.destroy() ซึ่งลบทั้ง session cookie และข้อมูลฝั่ง server ต่างจาก cookie ที่คุณ set เอง session data อยู่บน server — cookie เก็บแค่ id ที่ไม่มีความหมาย (opaque) — จึงปลอดภัยสำหรับ state ที่อ่อนไหว จะเลือก storage backend เอง (Redis บน production เป็นต้น) ให้ config session.driver ด้วย helper sessionDrivers แทนรูปแบบ string ที่ deprecate แล้ว:
import { defineConfig, sessionDrivers } from 'astro/config';import node from '@astrojs/node';
export default defineConfig({ adapter: node({ mode: 'standalone' }), session: { driver: sessionDrivers.redis({ url: process.env.REDIS_URL }), },});session.regenerate(): ปิดช่องโหว่ session fixation
หัวข้อที่มีชื่อว่า “session.regenerate(): ปิดช่องโหว่ session fixation”การใช้ session id เดิมซ้ำข้ามการ login เป็นช่องโหว่จริง (session fixation): ถ้า attacker ตั้ง session cookie ของเหยื่อไว้ได้ก่อนเขา login id เดิมนั้นยัง valid หลัง login — id ที่ attacker ปลูกไว้กลายเป็น session ที่ authenticate แล้ว session.regenerate() หมุน session ไป id ใหม่โดยเก็บข้อมูลไว้ ตำแหน่งที่ถูกต้องในการเรียกคือตอนที่ session ได้สิทธิ์เพิ่ม (login, privilege escalation):
import type { APIRoute } from 'astro';
export const prerender = false;
export const POST: APIRoute = async ({ session }) => { await session?.set('user', { id: 'u1' }); await session?.regenerate(); // rotate the id now that this session is authenticated return Response.json({ success: true });};พิสูจน์ครบวงจร: login ด้วย session id ที่ตั้งไว้ล่วงหน้าและเดาได้ (แบบ attacker) แล้วยืนยันว่า server ส่ง id อื่น กลับมา:
$ curl -s -b "astro-session=preexisting123" -H "Origin: http://127.0.0.1:4351" \ http://127.0.0.1:4351/api/login -X POST{"before":"preexisting123","after":"79d10f95-77ac-4d8f-9ed1-8e266b210507","changed":true}id ที่ปลูกไว้ล่วงหน้า (preexisting123) ใช้ไม่ได้อีกแล้วหลัง login — session ที่ถูก hijack ก่อนการ authenticate จะพ่วงต่อหลังมันไม่ได้
Auth pattern
หัวข้อที่มีชื่อว่า “Auth pattern”ชิ้นส่วนเหล่านี้ประกอบกันเป็น auth flow มาตรฐาน: middleware อ่าน session cookie แล้วใส่ user ลง context.locals; page อ่าน Astro.locals.user เพื่อ render UI แบบ login/ไม่ login; guard ใน middleware redirect request ที่ไม่ authenticate ออกจาก route ที่ป้องกันไว้; และ login เรียก session.regenerate() ทันทีหลังตั้ง session
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”เมื่อ review middleware หรือ session code ที่ generate มา ให้เช็ค:
onRequestเป็น function ก้อนเดียวจัดการ auth, logging และ rewrite รวมกัน หรือ compose ด้วยsequence(...)ให้แต่ละ concern test/สลับลำดับได้อิสระ — พิสูจน์แล้วข้างบนว่าลำดับสำคัญและพิสูจน์ได้จาก headercontext.localsถูกใช้ที่ไหนโดยไม่มี interfaceApp.Localsในsrc/env.d.tsคู่กัน — เป็นanyโดย implicit ทุกที่ที่อ่านหรือเปล่า?- หลัง login code เคยเรียก
session.regenerate()ไหม — หรือ session id ก่อน login กลายเป็น id ที่ authenticate แล้วตรง ๆ เปิดช่อง session fixation ทิ้งไว้? - middleware สมมติว่า “รันทุก request” โดยไม่คิดถึงหน้า prerendered ที่ code เดียวกันรันแค่ครั้งเดียวตอน build กับ request data สังเคราะห์ — พิสูจน์แล้วข้างบนด้วย build log?
- middleware เรียก
next(path)โดยคาดว่า chain จะหยุด ทั้งที่มีแค่context.rewrite(path)เท่านั้นที่ข้าม chain ที่เหลือจริง ๆ?
🐛 หา bug ในโค้ดนี้
import { defineMiddleware } from 'astro:middleware';
export const onRequest = defineMiddleware(async (context, next) => {if (context.url.pathname.startsWith('/admin')) { const token = context.cookies.get('session')?.value; if (!token) { context.redirect('/login'); }}});เฉลย
middleware นี้ไม่เคยเรียก (หรือ return) next() เลย — เรียกแค่ context.redirect(...) และแม้แต่ return value ของมันก็ถูกทิ้งไม่ return ออกไป ทุก request ไม่ว่าจะป้องกันไว้หรือไม่ เจอ error จริงตรง ๆ ตอน build/runtime:
[MiddlewareNoDataOrNextCalled] Make sure your middleware returns a `Response` object,either directly or by returning the `Response` from calling the `next` function.วิธีแก้มีสองส่วน: return context.redirect('/login') เมื่อไม่มี token (ให้ redirect มีผลจริงเพราะถูก return) และ return next() ในทุก path ที่เหลือของ function รวมถึง case ที่ pathname ไม่ได้ขึ้นต้นด้วย /admin เลยด้วย
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”Middleware ไม่ใช่ “request hook” แบบนามธรรม — มันคือ code ที่รันครั้งเดียวต่อการ render หนึ่งครั้ง และหน้า prerendered ถูก render แค่ครั้งเดียวตอน build ตลอดไป context.locals คือพื้นที่ชั่วคราวสำหรับการ render เดียว ส่วน session คือสิ่งที่รอดข้ามการ renderต่าง ๆ และรอดได้เพราะข้อมูลอยู่ฝั่ง server หลัง cookie id ที่ไม่มีความหมาย session.regenerate() มีอยู่เพราะ “login แล้ว” คือ privilege boundary และ id ไหนก็ตามที่ข้าม boundary นั้นมาโดยไม่เปลี่ยนคือช่องทาง hijack ตามที่พิสูจน์แล้วข้างบน
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ได้อะไร | ประโยชน์ | ต้นทุน |
|---|---|---|
sequence(a, b, c) |
แต่ละ concern test/สลับลำดับได้อิสระ พิสูจน์ลำดับได้ (ตามข้างบน) | import เพิ่มหนึ่งตัว เพิ่ม indirection ให้อ่านอีกชั้น |
next(path) กลาง chain |
middleware ที่ตามมายังรันกับ route ใหม่ได้ | การอ่าน Request.body รอบ ๆ rewrite จะ throw — gotcha ที่มีเอกสารรองรับกับ Action |
context.rewrite(path) |
ข้าม chain ที่เหลือไปเลย — ง่ายกว่าเมื่อจะสลับแบบเด็ดขาด | middleware ที่เหลือไม่มีโอกาสรันเลย |
| session driver default ของ Node adapter | ไม่ต้อง config อะไรก็ใช้ session ได้บนเครื่อง (พิสูจน์แล้วข้างบน) | filesystem storage ไม่รอดข้าม container ใหม่ — ต้อง config driver จริงสำหรับ production |
session.regenerate() ตอน login |
ปิดช่อง fixation (พิสูจน์แล้วข้างบน) | ลืมง่าย — ไม่มีอะไร fail ดัง ๆ ถ้าข้ามไป |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “middleware รันทุก request ไม่มีข้อยกเว้น” พิสูจน์แล้วข้างบน: บนหน้า prerendered มันรันแค่ครั้งเดียวตอน build กับ request data สังเคราะห์ — ไม่รันอีกเลยสำหรับผู้เข้าชมจริง
- “
next(path)กับcontext.rewrite(path)เหมือนกันแค่ชื่อต่างกัน” พิสูจน์แล้วข้างบนด้วย log trace:next(path)rewrite แล้วรันsequence()chain ที่เหลือต่อ ส่วนcontext.rewrite(path)ส่งต่อทันที - “แค่เช็ค session cookie ตอน login ก็พอ ไม่ต้องแตะ session id เอง” นั่นคือช่องโหว่ fixation พอดี — พิสูจน์แล้วข้างบน วิธีแก้คือ
session.regenerate()ไม่ใช่การเช็ค cookie ที่เข้มขึ้น - “
context.localsก็คือ global state ดี ๆ นี่เอง” มันคือพื้นที่ชั่วคราวต่อ request — อะไรที่ set ไว้ไม่รอดพ้น response ที่ใช้มัน session ต่างหากคือเครื่องมือสำหรับสิ่งที่ต้องรอด