iT邦幫忙

2026 iThome 鐵人賽

DAY 17
0
Modern Web

我推的Laravel S2!系列 第 17

我推的Laravel S2|Day 16:Middleware 與請求偽造防護新篇章:bootstrap/app.php、CSRF 改名

  • 分享至 

  • xImage
  •  

一句話破題

建立 Middleware 之後要跑去 app/Http/Kernel.php 註冊——這個檔案 Day 5 就講過,Laravel 11 新骨架裡已經不存在了。今天把 Middleware 的完整生命週期重新過一遍,並處理這系列目前為止「Likelihood Of Impact」等級最高的一項變動:Laravel 13 把 CSRF 防護中介層整個改名並加強了。

建立一個 Middleware

php artisan make:middleware EnsurePostIsPublished
// app/Http/Middleware/EnsurePostIsPublished.php
class EnsurePostIsPublished
{
    public function handle(Request $request, Closure $next): Response
    {
        $post = $request->route('post');

        if ($post && is_null($post->published_at) && ! auth()->user()?->can('viewDraft', $post)) {
            abort(404);
        }

        return $next($request);
    }
}

$next($request) 呼叫之前的程式碼在請求「進入」應用程式時執行,$next($request) 之後的程式碼(如果有)則在回應「離開」之前執行——這是 Middleware 洋蔥式架構的核心:一層層包住實際的請求處理邏輯。

洋蔥模型:想像請求怎麼穿過每一層

用文字描述一下這個「洋蔥」比喻,會比單看程式碼更容易建立直覺:假設你有三個中介層 A、B、C 依序套用在一條路由上,一個請求進來時的實際執行順序是:

請求進來
  → A 的 $next() 之前
    → B 的 $next() 之前
      → C 的 $next() 之前
        → Controller 實際處理邏輯
      → C 的 $next() 之後
    → B 的 $next() 之後
  → A 的 $next() 之後
回應送出

洋蔥模型示意圖:請求從外圈的 A 一路穿過 B、C 到達最中心的 Controller,回應再依相反順序 C→B→A 往外傳出

這代表最先註冊的中介層,最後才會處理回應——如果你的中介層邏輯需要用到「回應已經產生」這個時機點(例如 Day 15 提過的記錄請求處理耗時),要寫在 $next($request) 之後

class LogRequestDuration
{
    public function handle(Request $request, Closure $next): Response
    {
        $start = microtime(true);

        $response = $next($request);   // 這一行才是實際執行 Controller 邏輯的地方

        $duration = microtime(true) - $start;
        Log::info('請求處理完成', ['duration_ms' => round($duration * 1000)]);

        return $response;
    }
}

註冊 Middleware:從 Kernel.php 搬到 bootstrap/app.php

舊寫法是在 app/Http/Kernel.php 的四個陣列裡註冊:$middleware(全域)、$middlewareGroupsweb/api 群組)、$routeMiddleware(可指名套用)、$middlewarePriority(執行順序)。現在全部收斂進 bootstrap/app.phpwithMiddleware() 閉包:

// bootstrap/app.php
use App\Http\Middleware\EnsurePostIsPublished;
use App\Http\Middleware\LogRequestDuration;
use Illuminate\Foundation\Configuration\Middleware;

->withMiddleware(function (Middleware $middleware) {
    // 對應原本的 $middleware(全域套用到每個請求)
    $middleware->append(LogRequestDuration::class);

    // 對應原本的 $middlewareGroups['web'],用 append/prepend 增減群組內容
    $middleware->web(append: [
        EnsurePostIsPublished::class,
    ]);

    // 對應原本的 $routeMiddleware,指名套用到特定路由
    $middleware->alias([
        'published' => EnsurePostIsPublished::class,
    ]);

    // 對應原本的 $middlewarePriority
    $middleware->priority([
        \Illuminate\Cookie\Middleware\EncryptCookies::class,
        \Illuminate\Session\Middleware\StartSession::class,
        EnsurePostIsPublished::class,
    ]);
})

設定好別名之後,套用到路由的方式完全沒變:

Route::get('/posts/{post}', [PostController::class, 'show'])->middleware('published');

withMiddleware() 的完整方法一覽

除了範例用到的 append()/web()/alias()/priority(),這個閉包還提供其他實用方法:

->withMiddleware(function (Middleware $middleware) {
    $middleware->prepend(SomeMiddleware::class);              // append() 的反面,插到最前面
    $middleware->api(prepend: [ThrottleApiRequests::class]);  // 對應 api 群組(跟 web() 是同一組方法族)

    // 定義一個全新的自訂群組(不是 Laravel 預設的 web/api)
    $middleware->appendToGroup('custom-group', [
        SomeMiddleware::class,
        AnotherMiddleware::class,
    ]);

    // web()/api() 也接受 remove、replace 具名參數,不需要額外的獨立方法
    $middleware->web(
        remove: [SomeDefaultMiddleware::class],
        replace: [OldMiddleware::class => NewMiddleware::class],
    );
});

replace 這個具名參數特別實用——如果你想客製化 Laravel 內建某個中介層的行為(例如自訂維護模式頁面的邏輯),不需要整個移除再重新設計整套流程,用一個實作相同介面的自訂類別替換掉原本的即可,其餘設定(優先順序、群組成員關係)都會自動沿用。如果你想完全重新定義 web/api 這兩個預設群組本身包含哪些中介層(而不只是增減),改用 $middleware->group('web', [...]) 整組覆寫。

九個內建中介層去哪了

Laravel 10 的新專案預設會有九個中介層檔案(處理受信任的代理伺服器、字串修剪、CSRF 驗證等)。Laravel 11 起這些實作邏輯移進框架本體,不再以獨立檔案出現在 app/Http/Middleware 目錄裡,需要客製化行為時改用 withMiddleware() 提供的專用方法,例如 $middleware->trimStrings(except: [...])$middleware->preventRequestForgery(except: [...])。功能不受影響,純粹是讓新專案的檔案數量更精簡。

Middleware 參數

Middleware 可以接受額外參數,實務上常見於角色/權限判斷:

class EnsureUserHasRole
{
    public function handle(Request $request, Closure $next, string $role): Response
    {
        if (! $request->user()?->hasRole($role)) {
            abort(403);
        }

        return $next($request);
    }
}
Route::delete('/posts/{post}', [PostController::class, 'destroy'])
    ->middleware('role:admin');

冒號後面的字串會依序對應到 handle() 方法裡 $next 之後的參數,多個參數用逗號分隔(role:admin,editor)。

Terminable Middleware:回應送出之後才執行的邏輯

前面提過寫在 $next($request) 之後的程式碼,會在回應「送給客戶端之前」執行——但有些情境你會希望邏輯是在回應真正送出去之後才跑(例如寫一段耗時的統計記錄邏輯,不想讓使用者多等這段時間)。這種情境用「可終止的中介層」(Terminable Middleware):

class LogRequestAfterResponse
{
    public function handle(Request $request, Closure $next): Response
    {
        return $next($request);
    }

    public function terminate(Request $request, Response $response): void
    {
        // 回應已經送給客戶端之後才執行,不會拖慢使用者收到回應的時間
        Log::info('請求已完成', ['status' => $response->getStatusCode()]);
    }
}

只要中介層類別除了 handle() 還定義了 terminate() 方法,Laravel 會自動偵測並在適當時機呼叫,不需要額外設定。這在需要做「使用者不需要等待的收尾工作」時很實用,跟 Day 19 會講到的 Queue 概念是類似的思路——只是 Terminable Middleware 是「回應送出後、同一個 PHP 行程結束前」執行,Queue 則是完全交給背景行程非同步處理,兩者適合的情境深淺不同。

CSRF 防護:VerifyCsrfToken 更名為 PreventRequestForgery

這是這系列目前遇到的第一個「Likelihood Of Impact: High」等級變動。CSRF(Cross-Site Request Forgery,跨站請求偽造)防護一直是 Laravel 表單安全的核心機制——Day 12 提到的 @csrf 指令,背後靠的就是這個中介層驗證。

Laravel 13 把這個中介層從 VerifyCsrfToken 更名為 PreventRequestForgery,同時改成「兩層防護」的設計。這裡的順序值得講清楚,因為跟直覺相反:

  1. 先檢查 Sec-Fetch-Site 標頭。現代瀏覽器會自動在每個請求帶上這個標頭,標示請求來自同源(same-origin)、同站(same-site)還是跨站。如果判定為同源,請求直接放行,完全不驗證 token
  2. 來源驗證沒過才退回傳統的 token 驗證——例如舊瀏覽器不送這個標頭,或連線不是 HTTPS(Sec-Fetch-Site 只在安全連線下才會被送出)。

換句話說,token 驗證從「唯一防線」變成了「後備防線」,而不是在 token 之上多加一道檢查。實務影響:本機開發如果跑在 http:// 上,走的其實還是原本的 token 那條路。

PreventRequestForgery 決策流程:先檢查 Sec-Fetch-Site 是否同源,同源直接放行不驗證 token;不同源或沒有這個標頭才退回傳統 token 驗證

排除特定 URI 的設定方法也跟著改名成 preventRequestForgery()

// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
    $middleware->preventRequestForgery(except: [
        'webhooks/stripe',
        'line/webhook',   // Day 29 的 LINE Webhook 也需要排除
    ]);
})

另外兩個具名參數是 Laravel 13 才有的新選項:

// 只信任來源驗證,完全關閉 token 後備機制(失敗回 403 而不是 419)
$middleware->preventRequestForgery(originOnly: true);

// 允許子網域之間的請求(例如 dashboard.example.com 接受來自 example.com 的請求)
$middleware->preventRequestForgery(allowSameSite: true);

VerifyCsrfTokenValidateCsrfToken 這兩個舊名稱目前仍保留為已棄用的別名,繼續能用,但如果你的程式碼裡有直接 use 這個類別(例如在測試裡用 withoutMiddleware([VerifyCsrfToken::class]) 排除 CSRF 驗證),建議更新成 PreventRequestForgery

// tests/Feature/PostTest.php
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;

public function test_can_create_post_via_webhook(): void
{
    $this->withoutMiddleware(PreventRequestForgery::class)
        ->post('/webhooks/some-service', $payload)
        ->assertOk();
}

舊的設定方法名稱 validateCsrfTokens() 目前仍然可用(跟類別別名一樣是相容性保留),但 Laravel 13 文件已經全面改用 preventRequestForgery(),新專案直接用新名稱即可。

測試中介層邏輯

寫完自訂中介層,除了手動打路由測試,也可以直接對中介層本身寫單元測試,不需要透過完整的 HTTP 請求:

test('未發布的文章會被 EnsurePostIsPublished 擋下來', function () {
    $post = Post::factory()->create(['published_at' => null]);

    $response = $this->get(route('posts.show', $post));

    $response->assertNotFound();
});

test('已發布的文章可以正常存取', function () {
    $post = Post::factory()->create(['published_at' => now()]);

    $response = $this->get(route('posts.show', $post));

    $response->assertOk();
});

這種寫法本質上是 Feature Test(Day 27 會深入),透過實際發出 HTTP 請求間接驗證中介層邏輯,是實務上最常見也最貼近真實使用情境的測試方式——比起針對中介層類別寫純粹的單元測試(自己組 Request/Closure 物件呼叫 handle()),透過路由測試更能確保中介層真的正確掛載在你以為它掛載的地方。

常見錯誤與踩雷點

  • 照舊教材去改 Kernel.php 卻找不到檔案:正確位置是 bootstrap/app.phpwithMiddleware(),這是這次改版影響最直接的地方。
  • 測試程式碼裡寫死 VerifyCsrfToken::class,升級後產生棄用警告:雖然舊名稱還能用,但既然文件已經明確建議改用新名稱,建議搜尋專案裡所有 VerifyCsrfToken/ValidateCsrfToken 的引用,逐一更新成 PreventRequestForgery,尤其是排除中介層的測試程式碼最容易被忽略。
  • Middleware 順序寫錯,導致 Session 還沒啟動就想讀取:例如自訂的 Middleware 需要用到 auth()->user(),卻被排在 StartSession/認證中介層前面執行,會拿到錯誤的認證狀態,遇到這類問題先檢查 priority()web()/api() 群組裡的排列順序。
  • 把「回應送出後才做的收尾工作」寫在一般的 $next() 之後,而不是 terminate():這代表使用者要多等這段收尾邏輯執行完才會收到回應,如果這段邏輯不影響回應內容本身(純粹是記錄、統計),應該用 Terminable Middleware,讓使用者不需要為它多等待。
  • replace 替換掉框架內建中介層卻沒有完整理解原本的行為:客製化框架內建的中介層邏輯前,先仔細讀懂原本的實作在做什麼(例如 PreventRequestForgery 背後的兩層防護設計),避免替換後不小心弱化了原本的安全機制。

小結

Middleware 的建立方式(make:middlewarehandle() 方法簽名)完全沒變,變的是註冊位置——從 Kernel.php 的四個陣列,整併進 bootstrap/app.phpwithMiddleware() 閉包,並提供更語意化的方法(web()api()alias()priority()replace())取代直接操作陣列。今天也認識了洋蔥模型的實際執行順序,以及 Terminable Middleware 這個處理「回應送出後才執行」邏輯的專門機制。CSRF 中介層更名為 PreventRequestForgery 並加強來源驗證,是 Laravel 13 這次改版裡少數「Likelihood Of Impact: High」的項目,值得認真檢查你的專案裡有沒有寫死舊類別名稱的地方。

防患於未然 — 《周易・既濟》

明日預告

Day 17 進入 Validation 驗證器:常用規則、Form Request、自訂驗證,並認識 Laravel 13 新增的 array_keys 規則。


上一篇
我推的Laravel S2|Day 15:Logging 日誌系統
下一篇
我推的Laravel S2|Day 17:Validation 驗證器:規則、自訂驗證、Form Request、array_keys
系列文
我推的Laravel S2!18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言