简介

Laravel Sanctum 为 SPA(单页应用)、移动应用以及简单的基于令牌的 API 提供了轻量级的认证系统。Sanctum 允许应用程序的每个用户为其账户生成多个 API 令牌。这些令牌可被授予能力/作用域,用以指定令牌允许执行的操作。

工作原理

Laravel Sanctum 旨在解决两个不同的问题。在深入探讨该库之前,让我们先分别讨论这两个问题。

API 令牌

首先,Sanctum 是一个简单的包,可用于向用户发放 API 令牌,而无需处理 OAuth 的复杂性。此功能受 GitHub 及其他发放“个人访问令牌”的应用程序启发。例如,假设您的应用程序的“账户设置”中有一个界面,用户可在该界面生成用于其账户的 API 令牌。您可以使用 Sanctum 来生成和管理这些令牌。这些令牌通常具有很长的有效期(数年),但用户可以随时手动撤销它们。

Laravel Sanctum 通过将用户 API 令牌存储在单个数据库表中,并通过应包含有效 API 令牌的 Authorization 请求头来验证传入的 HTTP 请求,从而实现此功能。

SPA 认证

其次,Sanctum 旨在提供一种简单的方法来认证需要与 Laravel 驱动的 API 通信的单页应用(SPA)。这些 SPA 可能与您的 Laravel 应用程序位于同一代码库中,也可能是一个完全独立的代码库,例如使用 Next.js 或 Nuxt 创建的 SPA。

对于此功能,Sanctum 不使用任何形式的令牌。相反,Sanctum 使用 Laravel 内置的基于 Cookie 的会话认证服务。通常,Sanctum 利用 Laravel 的 web 认证守卫来实现这一点。这提供了 CSRF 保护、会话认证等优势,还能防止认证凭据通过 XSS 泄露。

只有当传入请求源自您自己的 SPA 前端时,Sanctum 才会尝试使用 Cookie 进行认证。当 Sanctum 检查传入的 HTTP 请求时,它会首先检查是否存在认证 Cookie,如果没有,Sanctum 随后会检查 Authorization 请求头中是否存在有效的 API 令牌。

仅将 Sanctum 用于 API 令牌认证,或仅用于 SPA 认证,都是完全可以的。使用 Sanctum 并不意味着您必须同时使用它提供的两个功能。

安装

您可以使用 install:api Artisan 命令安装 Laravel Sanctum:

php artisan install:api

接下来,如果您计划使用 Sanctum 来认证 SPA,请参阅本文档的 SPA 认证部分。

配置

覆盖默认模型

虽然通常不需要,但您可以自由扩展 Sanctum 内部使用的 PersonalAccessToken 模型:

use Laravel\Sanctum\PersonalAccessToken as SanctumPersonalAccessToken;

class PersonalAccessToken extends SanctumPersonalAccessToken
{
    // ...
}

然后,您可以通过 Sanctum 提供的 usePersonalAccessTokenModel 方法,指示 Sanctum 使用您的自定义模型。通常,您应该在应用程序的 AppServiceProvider 文件的 boot 方法中调用此方法:

use App\Models\Sanctum\PersonalAccessToken;
use Laravel\Sanctum\Sanctum;

/**
 * 引导任何应用程序服务。
 */
public function boot(): void
{
    Sanctum::usePersonalAccessTokenModel(PersonalAccessToken::class);
}

API 令牌认证

您不应使用 API 令牌来认证自己的第一方 SPA。相反,请使用 Sanctum 内置的 SPA 认证功能。

发放 API 令牌

Sanctum 允许您发放可用于认证发往您应用程序的 API 请求的 API 令牌/个人访问令牌。使用 API 令牌发起请求时,该令牌应作为 Bearer 令牌包含在 Authorization 请求头中。

要开始为用户发放令牌,您的 User 模型应使用 Laravel\Sanctum\HasApiTokens 特性:

use Laravel\Sanctum\HasApiTokens;

class User extends Authenticatable
{
    use HasApiTokens, HasFactory, Notifiable;
}

要发放令牌,您可以使用 createToken 方法。createToken 方法返回一个 Laravel\Sanctum\NewAccessToken 实例。API 令牌在存储到数据库之前会使用 SHA-256 进行哈希处理,但您可以使用 NewAccessToken 实例的 plainTextToken 属性来访问令牌的明文值。您应该在令牌创建后立即向用户显示该值:

use Illuminate\Http\Request;

Route::post('/tokens/create', function (Request $request) {
    $token = $request->user()->createToken($request->token_name);

    return ['token' => $token->plainTextToken];
});

您可以使用 HasApiTokens 特性提供的 tokens Eloquent 关系访问用户的所有令牌:

foreach ($user->tokens as $token) {
    // ...
}

令牌能力

Sanctum 允许您为令牌分配“能力”。能力的用途与 OAuth 的“作用域”相似。您可以将字符串能力的数组作为第二个参数传递给 createToken 方法:

return $user->createToken('token-name', ['server:update'])->plainTextToken;

在处理经 Sanctum 认证的传入请求时,您可以使用 tokenCan 或 tokenCant 方法来判断令牌是否具有给定的能力:

if ($user->tokenCan('server:update')) {
    // ...
}

if ($user->tokenCant('server:update')) {
    // ...
}

令牌能力中间件

Sanctum 还包含两个中间件,可用于验证传入请求是否通过已被授予给定能力的令牌进行了认证。首先,在应用程序的 bootstrap/app.php 文件中定义以下中间件别名:

use Laravel\Sanctum\Http\Middleware\CheckAbilities;
use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility;

->withMiddleware(function (Middleware $middleware): void {
    $middleware->alias([
        'abilities' => CheckAbilities::class,
        'ability' => CheckForAnyAbility::class,
    ]);
})

可将 abilities 中间件分配给路由,以验证传入请求的令牌是否具有所有列出的能力:

Route::get('/orders', function () {
    // 令牌同时具有“check-status”和“place-orders”能力……
})->middleware(['auth:sanctum', 'abilities:check-status,place-orders']);

可将 ability 中间件分配给路由,以验证传入请求的令牌是否具有所列能力中的至少一项:

Route::get('/orders', function () {
    // 令牌具有“check-status”或“place-orders”能力……
})->middleware(['auth:sanctum', 'ability:check-status,place-orders']);

第一方界面发起的请求

为了方便起见,如果传入的认证请求来自您的第一方 SPA,并且您正在使用 Sanctum 内置的 SPA 认证,tokenCan 方法将始终返回 true。

然而,这并不一定意味着您的应用程序必须允许用户执行该操作。通常,您应用程序的授权策略将决定令牌是否被授权执行相应能力,同时也会检查用户实例本身是否应被允许执行该操作。

例如,如果设想一个管理服务器的应用程序,这可能意味着要检查令牌是否有权更新服务器并且该服务器属于该用户:

return $request->user()->id === $server->user_id &&
       $request->user()->tokenCan('server:update');

起初,允许调用 tokenCan 方法且对于第一方界面发起的请求始终返回 true 似乎很奇怪;然而,能够始终假定存在 API 令牌且可通过 tokenCan 方法进行检查,这会带来便利。通过采用这种方法,您可以在应用程序的授权策略中始终调用 tokenCan 方法,而无需担心请求是由应用程序的界面触发,还是由 API 的某个第三方使用者发起。

保护路由

要保护路由,使所有传入请求都必须经过认证,您应在 routes/web.php 和 routes/api.php 路由文件中,将 sanctum 认证守卫附加到受保护的路由上。该守卫将确保传入请求要么被认证为有状态的、基于 Cookie 认证的请求,要么(如果请求来自第三方)包含有效的 API 令牌请求头。

你可能想知道,为什么我们建议在应用程序的 routes/web.php 文件中,使用 sanctum 守卫对路由进行身份验证。请记住,Sanctum 会首先尝试使用 Laravel 常规的会话认证 Cookie 对传入请求进行身份验证。如果该 Cookie 不存在,Sanctum 将尝试使用请求 Authorization 请求头中的令牌对请求进行身份验证。此外,使用 Sanctum 对所有请求进行身份验证,能确保我们始终可以在当前已认证的用户实例上调用 tokenCan 方法:

use Illuminate\Http\Request;

Route::get('/user', function (Request $request) {
    return $request->user();
})->middleware('auth:sanctum');

撤销令牌

你可以利用 Laravel\Sanctum\HasApiTokens trait 提供的 tokens 关系,将令牌从数据库中删除,以此“撤销”令牌:

// Revoke all tokens...
$user->tokens()->delete();

// Revoke the token that was used to authenticate the current request...
$request->user()->currentAccessToken()->delete();

// Revoke a specific token...
$user->tokens()->where('id', $tokenId)->delete();

令牌过期

默认情况下,Sanctum 令牌不会过期,只能通过撤销令牌使其失效。不过,如果你想为应用程序的 API 令牌配置过期时间,可以通过应用程序 sanctum 配置文件中定义的 expiration 配置选项来实现。该配置选项规定了已发放的令牌在多少分钟后会被视为过期:

'expiration' => 525600,

如果你想单独指定每个令牌的过期时间,可以将过期时间作为 createToken 方法的第三个参数传入来实现:

return $user->createToken(
    'token-name', ['*'], now()->plus(weeks: 1)
)->plainTextToken;

如果你已为应用程序配置了令牌过期时间,可能还需要调度一个任务,用于清理应用程序中已过期的令牌。好在 Sanctum 自带一个 sanctum:prune-expired Artisan 命令,你可以用它来完成这项工作。例如,你可以配置一个定时任务,删除所有已过期至少 24 小时的过期令牌数据库记录:

use Illuminate\Support\Facades\Schedule;

Schedule::command('sanctum:prune-expired --hours=24')->daily();

单页应用认证

Sanctum 的存在,也为需要与 Laravel 驱动的 API 进行通信的单页应用(SPA)提供了一种简单的认证方法。这些单页应用可能与你的 Laravel 应用程序处于同一代码仓库,也可能是完全独立的代码仓库。

对于此功能,Sanctum 不使用任何形式的令牌。相反,Sanctum 使用 Laravel 内置的基于 Cookie 的会话认证服务。这种认证方式具备 CSRF 保护、会话认证的优势,还能防范认证凭据通过 XSS 泄露的风险。

为了完成认证,你的单页应用和 API 必须共享同一个顶级域名,不过它们可以部署在不同的子域名下。此外,你应确保在请求中发送 Accept: application/json 请求头,以及 Referer 或 Origin 请求头。

配置

配置第一方域名

首先,你需要配置单页应用发起请求的域名。你可以通过 sanctum 配置文件中的 stateful 配置选项来设置这些域名。该配置项用于确定在向 API 发起请求时,哪些域名会借助 Laravel 会话 Cookie 维持“有状态”认证。

为协助你设置第一方有状态域名,Sanctum 提供了两个可加入配置的辅助函数。首先,Sanctum::currentApplicationUrlWithPort() 会返回 APP_URL 环境变量中配置的当前应用 URL;Sanctum::currentRequestHost() 会在有状态域名列表中注入一个占位符,该占位符在运行时会替换为当前请求的主机,从而让所有相同域名的请求都被视为有状态请求。

如果你通过包含端口的 URL(127.0.0.1:8000)访问应用程序,应确保在域名中包含端口号。

Sanctum 中间件

接下来,你需要告知 Laravel,来自单页应用的传入请求可使用 Laravel 的会话 Cookie 进行身份验证,同时仍允许来自第三方或移动应用的请求通过 API 令牌进行身份验证。你可以在应用程序的 bootstrap/app.php 文件中调用 statefulApi 中间件方法,轻松实现这一点:

->withMiddleware(function (Middleware $middleware): void {
    $middleware->statefulApi();
})

CORS 与 Cookie

如果你从部署在不同子域名下的单页应用访问应用程序时,在身份验证方面遇到问题,很可能是 CORS(跨域资源共享)或会话 Cookie 配置有误。

config/cors.php 配置文件默认不会发布。如果你需要自定义 Laravel 的 CORS 选项,应使用 config:publish Artisan 命令发布完整的 cors 配置文件:

php artisan config:publish cors

接下来,你应确保应用程序的 CORS 配置返回值为 True 的 Access-Control-Allow-Credentials 响应头。要实现这一点,可将应用程序 config/cors.php 配置文件中的 supports_credentials 选项设置为 true。

此外,你应在应用程序全局的 axios 实例上启用 withCredentials 和 withXSRFToken 选项。你可以在 resources/js/app.js 文件中进行此操作。如果你不使用 Axios 从前端发起 HTTP 请求,应在自己的 HTTP 客户端上进行等效配置:

axios.defaults.withCredentials = true;
axios.defaults.withXSRFToken = true;

最后,你应确保应用程序的会话 Cookie 域名配置支持根域名下的任意子域名。你可以在应用程序的 config/session.php 配置文件中,在域名前加上 . 来实现这一点:

'domain' => '.domain.com',

身份验证

CSRF 防护

要对单页应用进行身份验证,单页应用的“登录”页面应先向 /sanctum/csrf-cookie 端点发起请求,为应用程序初始化 CSRF 防护:

axios.get('/sanctum/csrf-cookie').then(response => {
    // Login...
});

在此请求过程中,Laravel 会设置一个包含当前 CSRF 令牌的 XSRF-TOKEN Cookie。随后,该令牌应进行 URL 解码,并在后续请求中放入 X-XSRF-TOKEN 请求头,像 Axios 和 Angular HttpClient 这类 HTTP 客户端库会自动完成此操作。如果你使用的 JavaScript HTTP 库不会自动设置该值,你需要手动将 X-XSRF-TOKEN 请求头的值,设置为与该路由所设置的 XSRF-TOKEN Cookie 经过 URL 解码后的值一致。

登录

CSRF 防护初始化完成后,你应该向你的 Laravel 应用程序的 /login 路由发起 POST 请求。此 /login 路由可以手动实现,也可以使用像 Laravel Fortify 这样的无头认证包来实现。

若登录请求成功,你将完成身份验证,后续对该应用程序路由的请求,会通过 Laravel 应用程序向客户端发放的会话 Cookie 自动完成身份验证。此外,由于你的应用程序已向 /sanctum/csrf-cookie 路由发起过请求,只要 JavaScript HTTP 客户端在 X-XSRF-TOKEN 请求头中发送 XSRF-TOKEN Cookie 的值,后续请求就会自动获得 CSRF 防护。

当然,如果用户因长时间无操作导致会话过期,后续发往 Laravel 应用程序的请求可能会收到 401 或 419 HTTP 错误响应。这种情况下,你应该将用户重定向到单页应用的登录页面。

由于这种单页应用认证方式是基于会话的,你可以使用 Laravel 的标准认证服务,包括“记住我”功能。

你可以自行编写 /login 端点;不过,你应确保该端点使用 Laravel 提供的标准基于会话的身份验证服务来对用户进行身份验证。通常,这意味着要使用 web 身份验证守卫。

保护路由

要保护路由,使所有传入请求都必须经过身份验证,你应该在 routes/api.php 文件中将 sanctum 身份验证守卫附加到你的 API 路由上。该守卫将确保传入请求要么是来自你的 SPA 的有状态已验证请求,要么在请求来自第三方时包含有效的 API 令牌请求头:

use Illuminate\Http\Request;

Route::get('/user', function (Request $request) {
    return $request->user();
})->middleware('auth:sanctum');

授权私有广播频道

如果你的 SPA 需要与私有/在线状态广播频道进行身份验证,你应该从应用程序 bootstrap/app.php 文件中的 withRouting 方法里移除 channels 条目。相反,你应该调用 withBroadcasting 方法,以便为应用程序的广播路由指定正确的中间件:

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        // ...
    )
    ->withBroadcasting(
        __DIR__.'/../routes/channels.php',
        ['prefix' => 'api', 'middleware' => ['api', 'auth:sanctum']],
    )
    ->create();

接下来,为了让 Pusher 的授权请求能够成功,在初始化Laravel Echo时,你需要提供一个自定义的 Pusher authorizer。这能让你的应用程序配置 Pusher 使用为跨域请求正确配置好的 axios 实例:

window.Echo = new Echo({
    broadcaster: "pusher",
    cluster: import.meta.env.VITE_PUSHER_APP_CLUSTER,
    encrypted: true,
    key: import.meta.env.VITE_PUSHER_APP_KEY,
    authorizer: (channel, options) => {
        return {
            authorize: (socketId, callback) => {
                axios.post('/api/broadcasting/auth', {
                    socket_id: socketId,
                    channel_name: channel.name
                })
                .then(response => {
                    callback(false, response.data);
                })
                .catch(error => {
                    callback(true, error);
                });
            }
        };
    },
})

移动应用身份验证

你也可以使用 Sanctum 令牌来验证移动应用对你 API 的请求。验证移动应用请求的流程与验证第三方 API 请求类似;不过,在颁发 API 令牌的方式上存在细微差别。

颁发 API 令牌

首先,创建一个路由,该路由接收用户的电子邮件/用户名、密码和设备名称,然后用这些凭据换取一个新的 Sanctum 令牌。提供给该端点的“设备名称”仅供参考,可以是你想要的任意值。一般而言,设备名称的值应该是用户能够识别的名称,例如“努诺的 iPhone 17”。

通常情况下,你会从移动应用的“登录”界面向令牌端点发起请求。该端点会返回纯文本形式的 API 令牌,之后可将该令牌存储在移动设备上,用于发起其他 API 请求:

use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;

Route::post('/sanctum/token', function (Request $request) {
    $request->validate([
        'email' => 'required|email',
        'password' => 'required',
        'device_name' => 'required',
    ]);

    $user = User::where('email', $request->email)->first();

    if (! $user || ! Hash::check($request->password, $user->password)) {
        throw ValidationException::withMessages([
            'email' => ['The provided credentials are incorrect.'],
        ]);
    }

    return $user->createToken($request->device_name)->plainTextToken;
});

当移动应用使用令牌向你的应用程序发起 API 请求时,应在 Authorization 请求头中以 Bearer 令牌的形式传递该令牌。

为移动应用颁发令牌时,你也可以自行指定令牌的权限。

保护路由

正如之前所记录的那样,你可以通过将 sanctum 身份验证守卫附加到路由上,来保护路由,使所有传入请求都必须经过身份验证:

Route::get('/user', function (Request $request) {
    return $request->user();
})->middleware('auth:sanctum');

撤销令牌

为了允许用户撤销发放给移动设备的 API 令牌,你可以在 Web 应用界面的“账户设置”部分,按名称列出这些令牌,并附上一个“撤销”按钮。当用户点击“撤销”按钮时,你可以从数据库中删除该令牌。请记住,你可以通过 Laravel\Sanctum\HasApiTokens 特性提供的 tokens 关系来访问用户的 API 令牌:

// Revoke all tokens...
$user->tokens()->delete();

// Revoke a specific token...
$user->tokens()->where('id', $tokenId)->delete();

测试

在测试过程中,可以使用 Sanctum::actingAs 方法来验证用户身份,并指定应授予其令牌的权限:

use App\Models\User;
use Laravel\Sanctum\Sanctum;

test('task list can be retrieved', function () {
    Sanctum::actingAs(
        User::factory()->create(),
        ['view-tasks']
    );

    $response = $this->get('/api/task');

    $response->assertOk();
});
use App\Models\User;
use Laravel\Sanctum\Sanctum;

public function test_task_list_can_be_retrieved(): void
{
    Sanctum::actingAs(
        User::factory()->create(),
        ['view-tasks']
    );

    $response = $this->get('/api/task');

    $response->assertOk();
}

如果你想为令牌授予所有权限,应该在提供给 actingAs 方法的权限列表中包含 *:

Sanctum::actingAs(
    User::factory()->create(),
    ['*']
);