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

Middleware & Sessions

Middleware คือ function ตัวเดียวที่รันก่อนทุก requestจะถูกจัดการ มันอยู่ใน src/middleware.ts และ export onRequest(context, next) — คุณทำงานอะไรบางอย่าง แล้วเรียก next() เพื่อไปต่อยัง page หรือ endpoint (หรือตัด flow ด้วย Response ของตัวเอง)

src/middleware.ts
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 รันก่อน route และตัด flow ได้

โปรเจกต์จริงแทบไม่เคยมี concern เดียวให้ middleware จัดการ — auth, request logging, middleware ของ integration เอง, กฎ rewrite เขียนทุกอย่างลงใน onRequest ก้อนเดียวทำให้แต่ละ concern test หรือสลับลำดับยาก sequence() รวมหลาย middleware function เป็นตัวเดียว รันตามลำดับที่ส่งเข้าไป:

src/middleware.ts
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-order
x-mw-order: first,second

x-mw-order header ข้างบนถูก middleware ตัวที่สองใน chain sequence(first, second, guard) set โดยอ่าน list ที่ middleware ทั้งสองต่อชื่อตัวเองใส่ไว้ — first รันจริง แล้วส่งต่อให้ second ตามลำดับที่ส่งเข้า sequence() ไม่ใช่กลับกัน

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 แทน:

src/env.d.ts
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>}

มีสองเครื่องมือที่เปลี่ยนว่า 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 ยังรันกับ 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.

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 แล้ว:

astro.config.mjs
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 id เดิมซ้ำข้ามการ login เป็นช่องโหว่จริง (session fixation): ถ้า attacker ตั้ง session cookie ของเหยื่อไว้ได้ก่อนเขา login id เดิมนั้นยัง valid หลัง login — id ที่ attacker ปลูกไว้กลายเป็น session ที่ authenticate แล้ว session.regenerate() หมุน session ไป id ใหม่โดยเก็บข้อมูลไว้ ตำแหน่งที่ถูกต้องในการเรียกคือตอนที่ session ได้สิทธิ์เพิ่ม (login, privilege escalation):

src/pages/api/login.ts
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 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

เมื่อ review middleware หรือ session code ที่ generate มา ให้เช็ค:

  • onRequest เป็น function ก้อนเดียวจัดการ auth, logging และ rewrite รวมกัน หรือ compose ด้วย sequence(...) ให้แต่ละ concern test/สลับลำดับได้อิสระ — พิสูจน์แล้วข้างบนว่าลำดับสำคัญและพิสูจน์ได้จาก header
  • context.locals ถูกใช้ที่ไหนโดยไม่มี interface App.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 ในโค้ดนี้

src/middleware.ts
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 เลยด้วย

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 ต่างหากคือเครื่องมือสำหรับสิ่งที่ต้องรอด
onRequest middleware รันตอนไหน?
context.locals มีไว้ทำอะไร และหลีกเลี่ยงไม่ให้มันเป็น any โดย implicit ได้ยังไง?
นี่พิสูจน์อะไรเกี่ยวกับ sequence(first, second, guard)?
$ curl -sD - -o /dev/null http://127.0.0.1:4351/ | grep -i x-mw-order
x-mw-order: first,second
middleware เรียก next('/api/rewrite-demo') เมื่อเห็น /old-path ก่อนถึง middleware ตัวที่สองใน sequence() เดียวกัน middleware ตัวที่สองเห็นอะไร?
page มี export const prerender = true middleware ของมันรันกับ request data จริงบ่อยแค่ไหน?
ทำไมต้องเรียก session.regenerate() ทันทีหลัง login สำเร็จ แทนที่จะเชื่อ session cookie เดิม?