前置知識

Sanctum 是 Laravel 提供的輕量化 API 服務認證(Authenticate)解決方案。

以下我們用一個表格來揭示各種不同的 Session Guard Driver 的功能差異:

Driver 套件 Bearer Token Session Cookie Token Scope
Session 內建
Token 內建
Sanctum laravel/sanctum
Passport laravel/passport

Session Guard Driver 的應用情境

  • Session
    • Laravel 預設的認證方式
    • 常用於 Server Side Rendering 應用程式
  • Token
    • Laravel API 預設的認證方式,罕用
    • 每個用戶僅能有一組 Token
    • 無法設定 Token 過期時間
    • 無法設定 Token 應用範圍(Scope)
  • Sanctum
    • Laravel 輕量化的認證方式
    • 每個用戶能有多組 Token
    • 可以設定 Token 過期時間與應用範圍
    • 可用於 SPA 的服務,且不需要 Token 作為溝通媒介
    • 可核發單獨的 Token 用於其它服務(例如手機 APP)
      • 這部份類似於 GitHub 提供的 Personal Access Token,也可以做成開發者服務
  • Passport

緣起

會想到要寫這篇文章,主要是被 Sanctum 的 不需要 Token 就可以讓 SPA 認證 的功能所吸引。

實務上,將 Laravel 作為一個 API Service,並且用 React 製作一個 SPA,它們分屬於兩個不同的 Repository,這種做法一般稱為前後端分離。

然而我很疑惑,究竟 React SPA 是如何用 Sanctum 與 Laravel API 進行認證的。

實驗

環境建立

新建 Laravel 專案

1
$ laravel new test-sanctum

設定 Database

  • .envDB_CONNECTION=sqlite
  • 移除 .envDB_HOST, DB_PORT, DB_DATABASE, DB_USERNAMEDB_PASSWORD
  • 建立 database/database.sqlite 的空檔案

安裝 Sanctum

  • composer require laravel/sacntum
  • php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
  • config/auth.phpguards.api.driver 改為 sanctum
  • config/cors.phppaths 加入 sanctum/csrf-cookie
  • .env 中加入 SANCTUM_STATEFUL_DOMAINS=127.0.0.1:8000,localhost:8000
  • app/Http/Kernel.php$middlewareGroupsapi 中加入 Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class

執行 Migration 與建立用戶

1
2
3
$ php artisan migrate
$ php artisan tinker
(tinker) $ User::factory()->create(['email' => 'test@sanctum.test'])

基礎功能

登入

1
2
3
4
5
6
7
<?php
// routes/web.php

Route::post('login', function (Request $request) {
    return Auth::attempt($request->only('email', 'password'))
        ? 'auth.success' : 'auth.failed';
});

顯示使用者資訊

1
2
3
4
5
<?php
// routes/api.php

Route::middleware('api:sanctum')
    ->get('user', fn(Request $request) => $request->user());

測試:用內建的 axios 測試認證功能

安裝所需套件與編譯

1
$ npm install

設定 axios

1
2
3
4
5
6
// resources/js/bootstrap.js

window.axios = require('axios');

window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
window.axios.defaults.withCredentials = true; // 這一行是新增的
  • 記得程式更改後要執行 npm run dev

建立一個空白頁面

將預設的 resources/views/welcom.blade.js 改成以下內容

1
2
3
4
5
6
<html>
    <head></head>
    <body>
    <script src="{{ url('js/app.js') }}"></script>
    </body>
</html>

測試登入與獲取使用者資訊

執行 php artisan serve 之後,開啟 http://localhost:8000

使用開發者工具執行以下程式並觀察結果

1
2
3
await axios.get('sanctum/csrf-cookie');
await axios.post('login', {email: 'test@sanctum.test', password: 'password'});
await axios.get('api/user');

理論上應該要能正常看到執行結果

測試:用 curl 進行測試

1
$ curl -i -X GET http://localhost:8000/sanctum/csrf-cookie

註:此處應會取得 XSRF-TOKENlaravel_session 這兩個 Cookie,請記錄下來

Login

1
$ curl -i -X POST http://localhost:8000/login
  • 註:此處建議帶上 -H 'Accept: application/json 以顯示 JSON Type 的 Error Message
  • 註2:此處要加上 -H 'X-XSRF-TOKEN: {token}',其中 {token} 是上一步的 XSRF-TOKEN
    • XSRF-TOKEN 的結尾處可能會是 %3D,這邊要改成 =
  • 註3:此處要加上 --cookie "{XSRF-TOKEN}&{laravel_session}"

取得用戶

1
$ curl -i -X GET http://localhost:8000/api/user
  • 註:此處要帶上 -H 'Accept: application/json 以顯示 JSON Type 的 Error
  • 註2:要加上 --cookie "{XSRF-TOKEN}&{laravel_session}"
  • 註3:要加上 -H 'Origin: http://localhost:8000-H 'Referer: http://localhost:8000/ 才能正常取得資料

實驗結果

從 curl 的實驗結論我們得知:要加上 Origin 或 Referer Header 才能正常取得資料

這是因為 Laravel 在判斷是否為 SPA 是以這兩個 Header 作為依據,我們可以在 Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class 查閱相關的程式碼

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
<?php
    /**
     * Middleware 的進入點
     */ 
    public function handle($request, $next)
    {
        $this->configureSecureCookieSessions();

        return (new Pipeline(app()))->send($request)->through(static::fromFrontend($request) ? [ // 確認是否為 SPA 打過來的請求
            function ($request, $next) {
                $request->attributes->set('sanctum', true);

                return $next($request);
            },
            // 啟動 Session 相關的 Middlewares
            config('sanctum.middleware.encrypt_cookies', \Illuminate\Cookie\Middleware\EncryptCookies::class),
            \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
            \Illuminate\Session\Middleware\StartSession::class,
            config('sanctum.middleware.verify_csrf_token', \Illuminate\Foundation\Http\Middleware\VerifyCsrfToken::class),
        ] : [])->then(function ($request) use ($next) {
            return $next($request);
        });
    }

    public static function fromFrontend($request)
    {
        // $domain 的來源是 Referer Header 或 Origin Header
        $domain = $request->headers->get('referer') ?: $request->headers->get('origin');

        // 過濾 $domain,把前方的 Protocol 去掉並且確保一定會有 / 結尾
        $domain = Str::replaceFirst('https://', '', $domain);
        $domain = Str::replaceFirst('http://', '', $domain);
        $domain = Str::endsWith($domain, '/') ? $domain : "{$domain}/";

        $stateful = array_filter(config('sanctum.stateful', []));

        // 確認 $domain 是否符合 config/sacntum.php stateful 中設定的 Pattern
        return Str::is(Collection::make($stateful)->map(function ($uri) {
            return trim($uri).'/*';
        })->all(), $domain);
    }