简介

Laravel Passport 可在短短几分钟内,为你的 Laravel 应用程序提供完整的 OAuth2 服务器实现。Passport 构建于由 Andy Millington 和 Simon Hamp 维护的 League OAuth2 服务器 之上。

本文档假定你已熟悉 OAuth2。如果你对 OAuth2 一无所知,建议在继续阅读之前,先熟悉 OAuth2 的通用术语与特性。

Passport 还是 Sanctum?

在开始之前,你可能希望确定你的应用程序更适合使用 Laravel Passport 还是 Laravel Sanctum。如果你的应用程序必须支持 OAuth2,那么你应该使用 Laravel Passport。

但是,如果你要为单页应用程序、移动应用程序进行身份验证,或是要签发 API 令牌,那么应使用 Laravel Sanctum。Laravel Sanctum 不支持 OAuth2,但它能提供更简洁的 API 身份验证开发体验。

安装

你可以使用 install:api Artisan 命令来安装 Laravel Passport:

php artisan install:api --passport

该命令将发布并运行必要的数据库迁移,以创建应用程序存储 OAuth2 客户端与访问令牌所需的表。该命令还会创建生成安全访问令牌所需的加密密钥。

运行 install:api 命令后,请将 Laravel\Passport\HasApiTokens trait 与 Laravel\Passport\Contracts\OAuthenticatable 接口添加到你的 App\Models\User 模型中。该 trait 会为你的模型提供若干辅助方法,使你能够查看已认证用户的令牌与权限范围:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Passport\Contracts\OAuthenticatable;
use Laravel\Passport\HasApiTokens;

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

最后,在应用程序的 config/auth.php 配置文件中,你需要定义一个 api 身份验证守卫,并将 driver 选项设置为 passport。这会让应用程序在对传入的 API 请求进行身份验证时,使用 Passport 的 TokenGuard:

'guards' => [
    'web' => [
        'driver' => 'session',
        'provider' => 'users',
    ],

    'api' => [
        'driver' => 'passport',
        'provider' => 'users',
    ],
],

部署 Passport

首次将 Passport 部署到应用程序的服务器时,你可能需要运行 passport:keys 命令。该命令会生成 Passport 生成访问令牌所需的加密密钥。生成的密钥通常不会保存在源代码管理中:

php artisan passport:keys

如有需要,你可以定义 Passport 应从哪个路径加载密钥。你可以使用 Passport::loadKeysFrom 方法来实现这一点。通常,该方法应在你的应用程序的 App\Providers\AppServiceProvider 类的 boot 方法中调用:

/**
 * 启动任何应用程序服务。
 */
public function boot(): void
{
    Passport::loadKeysFrom(__DIR__.'/../secrets/oauth');
}

从环境变量加载密钥

或者,你也可以使用 vendor:publish Artisan 命令发布 Passport 的配置文件:

php artisan vendor:publish --tag=passport-config

配置文件发布完成后,你可以将应用程序的加密密钥定义为环境变量来进行加载:

PASSPORT_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----
<private key here>
-----END RSA PRIVATE KEY-----"

PASSPORT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----
<public key here>
-----END PUBLIC KEY-----"

升级 Passport

当升级到 Passport 的新主版本时,务必仔细阅读升级指南。

配置

令牌有效期

默认情况下,Passport 签发的是长期访问令牌,有效期为一年。如果你希望配置更长或更短的令牌有效期,可以使用 tokensExpireIn、refreshTokensExpireIn 和 personalAccessTokensExpireIn 方法。这些方法应在你的应用程序的 App\Providers\AppServiceProvider 类的 boot 方法中调用:

use Carbon\CarbonInterval;

/**
 * 启动任何应用程序服务。
 */
public function boot(): void
{
    Passport::tokensExpireIn(CarbonInterval::days(15));
    Passport::refreshTokensExpireIn(CarbonInterval::days(30));
    Passport::personalAccessTokensExpireIn(CarbonInterval::months(6));
}

Passport 数据库表中的 expires_at 列是只读的,仅供显示使用。在签发令牌时,Passport 会将过期信息存储在经过签名和加密的令牌中。如果你需要使某个令牌失效,应将其撤销。

覆盖默认模型

你可以自由扩展 Passport 内部使用的模型,只需定义自己的模型并继承相应的 Passport 模型即可:

use Laravel\Passport\Client as PassportClient;

class Client extends PassportClient
{
    // ...
}

定义好模型后,你可以通过 Laravel\Passport\Passport 类指示 Passport 使用你的自定义模型。通常,你应在应用程序的 App\Providers\AppServiceProvider 类的 boot 方法中告知 Passport 你的自定义模型:

use App\Models\Passport\AuthCode;
use App\Models\Passport\Client;
use App\Models\Passport\DeviceCode;
use App\Models\Passport\RefreshToken;
use App\Models\Passport\Token;
use Laravel\Passport\Passport;

/**
 * 启动任何应用程序服务。
 */
public function boot(): void
{
    Passport::useTokenModel(Token::class);
    Passport::useRefreshTokenModel(RefreshToken::class);
    Passport::useAuthCodeModel(AuthCode::class);
    Passport::useClientModel(Client::class);
    Passport::useDeviceCodeModel(DeviceCode::class);
}

覆盖路由

有时你可能希望自定义 Passport 定义的路由。为此,你首先需要忽略 Passport 注册的路由,方法是在应用程序的 AppServiceProvider 的 register 方法中添加 Passport::ignoreRoutes:

use Laravel\Passport\Passport;

/**
 * 注册任何应用程序服务。
 */
public function register(): void
{
    Passport::ignoreRoutes();
}

然后,你可以将 Passport 在其路由文件中定义的路由复制到应用程序的 routes/web.php 文件中,并按需进行修改:

Route::group([
    'as' => 'passport.',
    'prefix' => config('passport.path', 'oauth'),
    'namespace' => '\Laravel\Passport\Http\Controllers',
], function () {
    // Passport routes...
});

授权码授权

通过授权码使用 OAuth2,是大多数开发者所熟悉的 OAuth2 使用方式。在使用授权码时,客户端应用程序会将用户重定向到你的服务器,用户将在该处批准或拒绝向客户端签发访问令牌的请求。

首先,我们需要告知 Passport 如何返回我们的“授权”视图。

授权视图的所有渲染逻辑,都可以通过 Laravel\Passport\Passport 类提供的相应方法进行自定义。通常,你应在应用程序的 App\Providers\AppServiceProvider 类的 boot 方法中调用此方法:

use Inertia\Inertia;
use Laravel\Passport\Passport;

/**
 * 启动任何应用程序服务。
 */
public function boot(): void
{
    // 通过提供视图名称...
    Passport::authorizationView('auth.oauth.authorize');

    // 通过提供闭包...
    Passport::authorizationView(
        fn ($parameters) => Inertia::render('Auth/OAuth/Authorize', [
            'request' => $parameters['request'],
            'authToken' => $parameters['authToken'],
            'client' => $parameters['client'],
            'user' => $parameters['user'],
            'scopes' => $parameters['scopes'],
        ])
    );
}

Passport 会自动定义返回此视图的 /oauth/authorize 路由。你的 auth.oauth.authorize 模板应包含一个向 passport.authorizations.approve 路由发起 POST 请求以批准授权的表单,以及一个向 passport.authorizations.deny 路由发起 DELETE 请求以拒绝授权的表单。passport.authorizations.approve 和 passport.authorizations.deny 路由需要 state、client_id 和 auth_token 字段。

管理客户端

开发需要与你的应用 API 进行交互的应用的开发者,需要通过创建“客户端”来将其应用注册到你的应用中。通常,这包括提供其应用的名称以及用户批准授权请求后应用可以重定向到的 URI。

第一方客户端

创建客户端最简单的方法是使用 passport:client Artisan 命令。该命令可用于创建第一方客户端或测试你的 OAuth2 功能。当你运行 passport:client 命令时,Passport 会提示你输入关于客户端的更多信息,并会向你提供一个客户端 ID 和密钥:

php artisan passport:client

如果你想允许客户端使用多个重定向 URI,当 passport:client 命令提示输入 URI 时,你可以使用以逗号分隔的列表来指定它们。任何包含逗号的 URI 都应进行 URI 编码:

https://third-party-app.com/callback,https://example.com/oauth/redirect

第三方客户端

由于你的应用用户无法使用 passport:client 命令,你可以使用 Laravel\Passport\ClientRepository 类的 createAuthorizationCodeGrantClient 方法为指定用户注册客户端:

use App\Models\User;
use Laravel\Passport\ClientRepository;

$user = User::find($userId);

// Creating an OAuth app client that belongs to the given user...
$client = app(ClientRepository::class)->createAuthorizationCodeGrantClient(
    user: $user,
    name: 'Example App',
    redirectUris: ['https://third-party-app.com/callback'],
    confidential: false,
    enableDeviceFlow: true
);

// Retrieving all the OAuth app clients that belong to the user...
$clients = $user->oauthApps()->get();

createAuthorizationCodeGrantClient 方法会返回 Laravel\Passport\Client 的实例。你可以将 $client->id 作为客户端 ID,$client->plainSecret 作为客户端密钥展示给用户。

请求令牌

重定向以进行授权

客户端创建完成后,开发者可以使用其客户端 ID 和密钥向你的应用请求授权码和访问令牌。首先,消费方应用应向你的应用的 /oauth/authorize 路由发起重定向请求,如下所示:

use Illuminate\Http\Request;
use Illuminate\Support\Str;

Route::get('/redirect', function (Request $request) {
    $request->session()->put('state', $state = Str::random(40));

    $query = http_build_query([
        'client_id' => 'your-client-id',
        'redirect_uri' => 'https://third-party-app.com/callback',
        'response_type' => 'code',
        'scope' => 'user:read orders:create',
        'state' => $state,
        // 'prompt' => '', // "none", "consent", or "login"
    ]);

    return redirect('https://passport-app.test/oauth/authorize?'.$query);
});

prompt 参数可用于指定 Passport 应用的认证行为。

如果 prompt 的值是 none,当用户未在 Passport 应用中通过认证时,Passport 将始终抛出认证错误。如果该值是 consent,即使之前已将所有作用域授予消费方应用,Passport 也会始终显示授权批准屏幕。当该值为 login 时,即使用户已存在会话,Passport 应用也会始终提示用户重新登录应用。

如果未提供 prompt 值,则仅当用户之前未针对请求的作用域向消费方应用授予访问权限时,才会提示用户进行授权。

请记住,/oauth/authorize 路由已由 Passport 定义。你无需手动定义此路由。

批准请求

收到授权请求时,Passport 会根据 prompt 参数的值(如果存在)自动响应,并可能向用户显示一个模板,允许其批准或拒绝授权请求。如果用户批准请求,其将被重定向回消费方应用指定的 redirect_uri。redirect_uri 必须与创建客户端时指定的 redirect URL 相匹配。

有时你可能需要跳过授权提示,例如在授权第一方客户端时。你可以通过扩展 Client 模型并定义 skipsAuthorization 方法来实现这一点。如果 skipsAuthorization 返回 true,客户端将被批准,用户将立即被重定向回 redirect_uri,除非消费方应用在重定向进行授权时显式设置了 prompt 参数:

<?php

namespace App\Models\Passport;

use Illuminate\Contracts\Auth\Authenticatable;
use Laravel\Passport\Client as BaseClient;

class Client extends BaseClient
{
    /**
     * 判断该客户端是否应跳过授权提示。
     *
     * @param  \Laravel\Passport\Scope[]  $scopes
     */
    public function skipsAuthorization(Authenticatable $user, array $scopes): bool
    {
        return $this->firstParty();
    }
}

将授权码转换为访问令牌

如果用户批准授权请求,其将被重定向回消费方应用。消费方应首先将 state 参数与重定向前存储的值进行验证。如果 state 参数匹配,则消费方应向你的应用发起 POST 请求以请求访问令牌。该请求应包含用户批准授权请求时由你的应用颁发的授权码:

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;

Route::get('/callback', function (Request $request) {
    $state = $request->session()->pull('state');

    throw_unless(
        strlen($state) > 0 && $state === $request->state,
        InvalidArgumentException::class,
        'Invalid state value.'
    );

    $response = Http::asForm()->post('https://passport-app.test/oauth/token', [
        'grant_type' => 'authorization_code',
        'client_id' => 'your-client-id',
        'client_secret' => 'your-client-secret',
        'redirect_uri' => 'https://third-party-app.com/callback',
        'code' => $request->code,
    ]);

    return $response->json();
});

此 /oauth/token 路由将返回包含 access_token、refresh_token 和 expires_in 属性的 JSON 响应。expires_in 属性包含访问令牌过期前的剩余秒数。

与 /oauth/authorize 路由一样,/oauth/token 路由由 Passport 为你定义。无需手动定义此路由。

管理令牌

你可以使用 Laravel\Passport\HasApiTokens trait 的 tokens 方法检索用户的已授权令牌。例如,这可用于为用户提供一个仪表盘,以跟踪他们与第三方应用的连接:

use App\Models\User;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Date;
use Laravel\Passport\Token;

$user = User::find($userId);

// Retrieving all of the valid tokens for the user...
$tokens = $user->tokens()
    ->where('revoked', false)
    ->where('expires_at', '>', Date::now())
    ->get();

// Retrieving all the user's connections to third-party OAuth app clients...
$connections = $tokens->load('client')
    ->reject(fn (Token $token) => $token->client->firstParty())
    ->groupBy('client_id')
    ->map(fn (Collection $tokens) => [
        'client' => $tokens->first()->client,
        'scopes' => $tokens->pluck('scopes')->flatten()->unique()->values()->all(),
        'tokens_count' => $tokens->count(),
    ])
    ->values();

刷新令牌

如果你的应用签发的是短期访问令牌,用户需要通过访问令牌签发时提供给他们的刷新令牌来刷新其访问令牌:

use Illuminate\Support\Facades\Http;

$response = Http::asForm()->post('https://passport-app.test/oauth/token', [
    'grant_type' => 'refresh_token',
    'refresh_token' => 'the-refresh-token',
    'client_id' => 'your-client-id',
    'client_secret' => 'your-client-secret', // 仅对机密客户端是必需的...
    'scope' => 'user:read orders:create',
]);

return $response->json();

此 /oauth/token 路由将返回包含 access_token、refresh_token 和 expires_in 属性的 JSON 响应。expires_in 属性包含访问令牌过期前的剩余秒数。

撤销令牌

您可以通过对 Laravel\Passport\Token 模型使用 revoke 方法来撤销令牌。您可以通过对 Laravel\Passport\RefreshToken 模型使用 revoke 方法来撤销令牌的刷新令牌:

use Laravel\Passport\Passport;
use Laravel\Passport\Token;

$token = Passport::token()->find($tokenId);

// 撤销访问令牌...
$token->revoke();

// 撤销令牌的刷新令牌...
$token->refreshToken?->revoke();

// 撤销用户的所有令牌...
User::find($userId)->tokens()->each(function (Token $token) {
    $token->revoke();
    $token->refreshToken?->revoke();
});

清理令牌

当令牌已被撤销或过期时,您可能需要将其从数据库中清理掉。Passport 自带的 passport:purge Artisan 命令可以为您完成此操作:

# 清理已撤销和已过期的令牌、授权码以及设备码...
php artisan passport:purge

# 仅清理过期超过 6 小时的令牌...
php artisan passport:purge --expired --hours=6

# 仅清理已撤销的令牌、授权码以及设备码...
php artisan passport:purge --revoked

# 仅清理已过期的令牌、授权码以及设备码...
php artisan passport:purge --expired

您还可以在应用程序的 routes/console.php 文件中配置定时任务,以便按计划自动清理令牌:

use Illuminate\Support\Facades\Schedule;

Schedule::command('passport:purge')->hourly();

基于 PKCE 的授权码授权

带有“代码交换凭证密钥”(PKCE)的授权码授权,是一种安全的方式,用于对单页应用或移动应用进行身份验证,以访问您的 API。当您无法保证客户端密钥会被保密,或为了降低授权码被攻击者拦截的威胁时,应使用此授权方式。在用授权码交换访问令牌时,“代码验证器”和“代码质询”的组合将替代客户端密钥。

创建客户端

在您的应用能够通过基于 PKCE 的授权码授权方式签发令牌之前,您需要创建一个支持 PKCE 的客户端。您可以使用带有 --public 选项的 passport:client Artisan 命令来完成此操作:

php artisan passport:client --public

请求令牌

代码验证器和代码质询

由于此授权方式不提供客户端密钥,开发人员需要生成代码验证器和代码质询的组合,以请求令牌。

代码验证器应为 43 到 128 个字符之间的随机字符串,包含字母、数字以及 "-"、"."、"_"、"~" 字符,如 RFC 7636 规范中所定义。

代码质询应为采用 URL 和文件名安全字符的 Base64 编码字符串。末尾的 '=' 字符应被移除,且不应包含换行符、空白字符或其他多余字符。

$encoded = base64_encode(hash('sha256', $codeVerifier, true));

$codeChallenge = strtr(rtrim($encoded, '='), '+/', '-_');

跳转以进行授权

客户端创建完成后,您可以使用客户端 ID 以及生成的代码验证器和代码质询,从您的应用请求授权码和访问令牌。首先,使用方应用应向您的应用的 /oauth/authorize 路由发起重定向请求:

use Illuminate\Http\Request;
use Illuminate\Support\Str;

Route::get('/redirect', function (Request $request) {
    $request->session()->put('state', $state = Str::random(40));

    $request->session()->put(
        'code_verifier', $codeVerifier = Str::random(128)
    );

    $codeChallenge = strtr(rtrim(
        base64_encode(hash('sha256', $codeVerifier, true))
    , '='), '+/', '-_');

    $query = http_build_query([
        'client_id' => 'your-client-id',
        'redirect_uri' => 'https://third-party-app.com/callback',
        'response_type' => 'code',
        'scope' => 'user:read orders:create',
        'state' => $state,
        'code_challenge' => $codeChallenge,
        'code_challenge_method' => 'S256',
        // 'prompt' => '', // "none", "consent", 或 "login"
    ]);

    return redirect('https://passport-app.test/oauth/authorize?'.$query);
});

将授权码转换为访问令牌

如果用户批准授权请求,他们将被重定向回使用方应用。使用方应按照标准授权码授权的方式,将 state 参数与重定向前存储的值进行比对验证。

如果状态参数匹配,使用方应向您的应用发送 POST 请求以请求访问令牌。请求应包含用户批准授权请求时由您的应用签发的授权码,以及最初生成的代码验证器:

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;

Route::get('/callback', function (Request $request) {
    $state = $request->session()->pull('state');

    $codeVerifier = $request->session()->pull('code_verifier');

    throw_unless(
        strlen($state) > 0 && $state === $request->state,
        InvalidArgumentException::class
    );

    $response = Http::asForm()->post('https://passport-app.test/oauth/token', [
        'grant_type' => 'authorization_code',
        'client_id' => 'your-client-id',
        'redirect_uri' => 'https://third-party-app.com/callback',
        'code_verifier' => $codeVerifier,
        'code' => $request->code,
    ]);

    return $response->json();
});

设备授权

OAuth2 设备授权允许无浏览器设备或输入受限的设备(如电视和游戏主机),通过交换“设备码”来获取访问令牌。使用设备流程时,设备客户端会指示用户使用辅助设备(如电脑或智能手机)连接到您的服务器,用户在那里输入所提供的“用户码”,并批准或拒绝访问请求。

要开始使用,我们需要指示 Passport 如何返回我们的“用户码”和“授权”视图。

所有授权视图的渲染逻辑,都可以通过 Laravel\Passport\Passport 类提供的相应方法进行自定义。通常,您应该在应用程序的 App\Providers\AppServiceProvider 类的 boot 方法中调用此方法。

use Inertia\Inertia;
use Laravel\Passport\Passport;

/**
 * 引导启动任何应用服务。
 */
public function boot(): void
{
    // 通过提供视图名称...
    Passport::deviceUserCodeView('auth.oauth.device.user-code');
    Passport::deviceAuthorizationView('auth.oauth.device.authorize');

    // 通过提供闭包...
    Passport::deviceUserCodeView(
        fn ($parameters) => Inertia::render('Auth/OAuth/Device/UserCode')
    );

    Passport::deviceAuthorizationView(
        fn ($parameters) => Inertia::render('Auth/OAuth/Device/Authorize', [
            'request' => $parameters['request'],
            'authToken' => $parameters['authToken'],
            'client' => $parameters['client'],
            'user' => $parameters['user'],
            'scopes' => $parameters['scopes'],
        ])
    );

    // ...
}

Passport 会自动定义用于返回这些视图的路由。您的 auth.oauth.device.user-code 模板应包含一个表单,该表单向 passport.device.authorizations.authorize 路由发送 GET 请求。passport.device.authorizations.authorize 路由需要一个 user_code 查询参数。

您的 auth.oauth.device.authorize 模板应包含一个向 passport.device.authorizations.approve 路由发送 POST 请求以批准授权的表单,以及一个向 passport.device.authorizations.deny 路由发送 DELETE 请求以拒绝授权的表单。passport.device.authorizations.approve 和 passport.device.authorizations.deny 路由需要 state、client_id 和 auth_token 字段。

创建设备授权客户端

在你的应用能够通过设备授权授权(device authorization grant)签发令牌之前,你需要创建一个启用了设备流程的客户端。你可以使用带有 --device 选项的 passport:client Artisan 命令来完成此操作。该命令会创建一个第一方设备流程启用客户端,并为你提供 client ID 和密钥:

php artisan passport:client --device

此外,你也可以在 ClientRepository 类上使用 createDeviceAuthorizationGrantClient 方法,以注册属于指定用户的第三方客户端:

use App\Models\User;
use Laravel\Passport\ClientRepository;

$user = User::find($userId);

$client = app(ClientRepository::class)->createDeviceAuthorizationGrantClient(
    user: $user,
    name: 'Example Device',
    confidential: false,
);

请求令牌

请求设备码

客户端创建完成后,开发者可以使用其 client ID 向你的应用请求设备码。首先,消费设备应向你的应用的 /oauth/device/code 路由发起 POST 请求,以请求设备码:

use Illuminate\Support\Facades\Http;

$response = Http::asForm()->post('https://passport-app.test/oauth/device/code', [
    'client_id' => 'your-client-id',
    'scope' => 'user:read orders:create',
]);

return $response->json();

这将返回一个 JSON 响应,其中包含 device_code、user_code、verification_uri、interval 和 expires_in 属性。expires_in 属性表示设备码到期的剩余秒数。interval 属性表示消费设备在轮询 /oauth/token 路由时应在请求之间等待的秒数,以避免出现速率限制错误。

请记住,/oauth/device/code 路由已由 Passport 定义。你无需手动定义此路由。

显示验证 URI 和用户码

获取到设备码请求后,消费设备应提示用户使用另一台设备访问提供的 verification_uri,并输入 user_code,以便批准授权请求。

轮询令牌请求

由于用户将使用单独的设备来授予(或拒绝)访问权限,消费设备应轮询你应用的 /oauth/token 路由,以判断用户何时对请求作出响应。消费设备在请求设备码时应使用 JSON 响应中提供的最小轮询 interval,以避免出现速率限制错误:

use Illuminate\Support\Facades\Http;
use Illuminate\Support\Sleep;

$interval = 5;

do {
    Sleep::for($interval)->seconds();

    $response = Http::asForm()->post('https://passport-app.test/oauth/token', [
        'grant_type' => 'urn:ietf:params:oauth:grant-type:device_code',
        'client_id' => 'your-client-id',
        'client_secret' => 'your-client-secret', // Required for confidential clients only...
        'device_code' => 'the-device-code',
    ]);

    if ($response->json('error') === 'slow_down') {
        $interval += 5;
    }
} while (in_array($response->json('error'), ['authorization_pending', 'slow_down']));

return $response->json();

如果用户已批准授权请求,将返回一个 JSON 响应,其中包含 access_token、refresh_token 和 expires_in 属性。expires_in 属性表示访问令牌到期的剩余秒数。

密码授权

我们不再建议使用密码授权令牌。相反,你应该选择OAuth2 Server 目前推荐的授权类型。

OAuth2 密码授权允许你的其他第一方客户端(例如移动应用)使用电子邮件地址 / 用户名和密码获取访问令牌。这使你能够安全地向第一方客户端签发访问令牌,而无需让用户经历完整的 OAuth2 授权码重定向流程。

要启用密码授权,请在你的应用 App\Providers\AppServiceProvider 类的 boot 方法中调用 enablePasswordGrant 方法:

/**
 * Bootstrap any application services.
 */
public function boot(): void
{
    Passport::enablePasswordGrant();
}

创建密码授权客户端

在你的应用能够通过密码授权签发令牌之前,你需要创建一个密码授权客户端。你可以使用带有 --password 选项的 passport:client Artisan 命令来完成此操作。

php artisan passport:client --password

请求令牌

启用该授权并创建密码授权客户端后,你可以通过向 /oauth/token 路由发起带有用户电子邮件地址和密码的 POST 请求来请求访问令牌。请记住,此路由已由 Passport 注册,因此无需手动定义。如果请求成功,你将从服务器的 JSON 响应中收到 access_token 和 refresh_token:

use Illuminate\Support\Facades\Http;

$response = Http::asForm()->post('https://passport-app.test/oauth/token', [
    'grant_type' => 'password',
    'client_id' => 'your-client-id',
    'client_secret' => 'your-client-secret', // Required for confidential clients only...
    'username' => '[email protected]',
    'password' => 'my-password',
    'scope' => 'user:read orders:create',
]);

return $response->json();

请记住,访问令牌默认为长期有效。不过,你可以根据需要自由配置访问令牌的最大有效期。

请求所有作用域

在使用密码授权或客户端凭证授权时,你可能希望为令牌授权应用支持的所有作用域。你可以通过请求 * 作用域来实现这一点。如果你请求 * 作用域,令牌实例上的 can 方法将始终返回 true。此作用域仅可分配给使用 password 或 client_credentials 授权签发的令牌:

use Illuminate\Support\Facades\Http;

$response = Http::asForm()->post('https://passport-app.test/oauth/token', [
    'grant_type' => 'password',
    'client_id' => 'your-client-id',
    'client_secret' => 'your-client-secret', // Required for confidential clients only...
    'username' => '[email protected]',
    'password' => 'my-password',
    'scope' => '*',
]);

自定义用户提供程序

如果你的应用使用多个身份验证用户提供程序,你可以通过在使用 artisan passport:client --password 命令创建客户端时提供 --provider 选项,来指定密码授权客户端使用的用户提供程序。所给的提供程序名称应与应用 config/auth.php 配置文件中定义的有效提供程序相匹配。随后你可以使用中间件保护你的路由,确保仅来自该守卫(guard)指定提供程序的用户获得授权。

自定义用户名字段

在使用密码授权进行身份验证时,Passport 会将你可认证模型的 email 属性用作“用户名”。不过,你可以通过在模型上定义 findForPassport 方法来自定义此行为:

<?php

namespace App\Models;

use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Passport\Bridge\Client;
use Laravel\Passport\Contracts\OAuthenticatable;
use Laravel\Passport\HasApiTokens;

class User extends Authenticatable implements OAuthenticatable
{
    use HasApiTokens, Notifiable;

    /**
     * Find the user instance for the given username.
     */
    public function findForPassport(string $username, Client $client): User
    {
        return $this->where('username', $username)->first();
    }
}

自定义密码验证

在使用密码授权进行身份验证时,Passport 会使用模型的 password 属性来验证给定的密码。如果你的模型没有 password 属性,或者你希望自定义密码验证逻辑,你可以在模型上定义 validateForPassportPasswordGrant 方法:

<?php

namespace App\Models;

use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Facades\Hash;
use Laravel\Passport\Contracts\OAuthenticatable;
use Laravel\Passport\HasApiTokens;

class User extends Authenticatable implements OAuthenticatable
{
    use HasApiTokens, Notifiable;

    /**
     * 验证 Passport 密码授权的用户密码。
     */
    public function validateForPassportPasswordGrant(string $password): bool
    {
        return Hash::check($password, $this->password);
    }
}

隐式授权

我们不再建议使用隐式授权令牌。相反,你应该选择OAuth2 Server 当前推荐的授权类型。

隐式授权与授权码授权类似;不过,令牌会在未交换授权码的情况下返回给客户端。这种授权最常用于无法安全存储客户端凭据的 JavaScript 或移动应用。要启用该授权,请在应用的 App\Providers\AppServiceProvider 类的 boot 方法中调用 enableImplicitGrant 方法:

/**
 * 引导任何应用服务。
 */
public function boot(): void
{
    Passport::enableImplicitGrant();
}

在应用可以通过隐式授权颁发令牌之前,你需要创建一个隐式授权客户端。你可以使用带有 --implicit 选项的 passport:client Artisan 命令来完成此操作。

php artisan passport:client --implicit

一旦启用了该授权并创建了隐式客户端,开发者就可以使用他们的客户端 ID 向应用请求访问令牌。消费端应用应向应用的 /oauth/authorize 路由发起重定向请求,如下所示:

use Illuminate\Http\Request;

Route::get('/redirect', function (Request $request) {
    $request->session()->put('state', $state = Str::random(40));

    $query = http_build_query([
        'client_id' => 'your-client-id',
        'redirect_uri' => 'https://third-party-app.com/callback',
        'response_type' => 'token',
        'scope' => 'user:read orders:create',
        'state' => $state,
        // 'prompt' => '', // "none", "consent", or "login"
    ]);

    return redirect('https://passport-app.test/oauth/authorize?'.$query);
});

请记住,/oauth/authorize 路由已经由 Passport 定义。你无需手动定义此路由。

客户端凭据授权

客户端凭据授权适用于机器对机器的身份验证。例如,你可以将此授权用于通过 API 执行维护任务的定时作业。

在应用可以通过客户端凭据授权颁发令牌之前,你需要创建一个客户端凭据授权客户端。你可以使用 passport:client Artisan 命令的 --client 选项来完成此操作:

php artisan passport:client --client

接下来,将 Laravel\Passport\Http\Middleware\EnsureClientIsResourceOwner 中间件分配给路由:

use Laravel\Passport\Http\Middleware\EnsureClientIsResourceOwner;

Route::get('/orders', function (Request $request) {
    // 访问令牌有效且客户端为资源所有者……
})->middleware(EnsureClientIsResourceOwner::class);

若要将对该路由的访问限制为特定范围,你可以向 using 方法提供一个所需范围的列表`:

Route::get('/orders', function (Request $request) {
    // 访问令牌有效,客户端为资源所有者,并且拥有“servers:read”和“servers:create”范围……
})->middleware(EnsureClientIsResourceOwner::using('servers:read', 'servers:create'));

底层的 OAuth2 服务器会将客户端凭据令牌的令牌 sub 声明设置为客户端的标识符。默认情况下,Passport 使用 UUID 作为客户端标识符,因此这不会与用户的整数主键发生冲突。但是,如果你已将 Passport::$clientUuids 设置为 false,客户端凭据令牌可能会无意中解析出 ID 与客户端 ID 匹配的用户。在这种情况下,使用此中间件无法保证传入的令牌是客户端凭据令牌。

获取令牌

要使用此授权类型获取令牌,请向 oauth/token 端点发起请求:

use Illuminate\Support\Facades\Http;

$response = Http::asForm()->post('https://passport-app.test/oauth/token', [
    'grant_type' => 'client_credentials',
    'client_id' => 'your-client-id',
    'client_secret' => 'your-client-secret',
    'scope' => 'servers:read servers:create',
]);

return $response->json()['access_token'];

个人访问令牌

有时,用户可能希望在不经过典型的授权码重定向流程的情况下为自己颁发访问令牌。允许用户通过应用的用户界面自行颁发令牌,对于方便用户试验你的 API 很有用,或者可以作为颁发访问令牌的一种更简单的方法。

如果你的应用主要使用 Passport 来颁发个人访问令牌,不妨考虑使用 Laravel Sanctum,这是 Laravel 用于颁发 API 访问令牌的轻量级官方库。

创建个人访问客户端

在应用可以颁发个人访问令牌之前,你需要创建一个个人访问客户端。你可以通过执行带有 --personal 选项的 passport:client Artisan 命令来完成此操作。如果你已经运行了 passport:install 命令,则无需运行此命令:

php artisan passport:client --personal

自定义用户提供程序

如果你的应用使用了多个身份验证用户提供程序,则可以在通过 artisan passport:client --personal 命令创建客户端时提供 --provider 选项,以指定个人访问授权客户端使用的用户提供程序。提供的提供程序名称应与应用的 config/auth.php 配置文件中定义的有效提供程序相匹配。随后,你可以使用中间件保护路由,以确保只有来自守卫所指定提供程序的用户才会被授权。

管理个人访问令牌

创建个人访问客户端后,你可以使用 App\Models\User 模型实例上的 createToken 方法为指定用户颁发令牌。createToken 方法的第一个参数接受令牌的名称,第二个可选参数是范围数组:

use App\Models\User;
use Illuminate\Support\Facades\Date;
use Laravel\Passport\Token;

$user = User::find($userId);

// 创建不带范围的令牌……
$token = $user->createToken('My Token')->accessToken;

// 创建带范围的令牌……
$token = $user->createToken('My Token', ['user:read', 'orders:create'])->accessToken;

// 创建具有所有范围的令牌……
$token = $user->createToken('My Token', ['*'])->accessToken;

// 检索属于该用户的所有有效个人访问令牌……
$tokens = $user->tokens()
    ->with('client')
    ->where('revoked', false)
    ->where('expires_at', '>', Date::now())
    ->get()
    ->filter(fn (Token $token) => $token->client->hasGrantType('personal_access'));

保护路由

通过中间件

Passport 包含一个身份验证守卫,它会验证传入请求中的访问令牌。一旦你将 api 守卫配置为使用 passport 驱动,就只需在任何需要有效访问令牌的路由上指定 auth:api 中间件:

Route::get('/user', function () {
    // 只有通过 API 身份验证的用户才能访问此路由……
})->middleware('auth:api');

如果你使用的是客户端凭据授权,则应使用 Laravel\Passport\Http\Middleware\EnsureClientIsResourceOwner 中间件来保护路由,而不是 auth:api 中间件。

多重身份验证守卫

如果你的应用会对不同类型的用户进行身份验证,而这些用户可能使用完全不同的 Eloquent 模型,那么你可能需要在应用中为每种用户提供程序类型定义守卫配置。这可以让你保护针对特定用户提供程序的请求。例如,给定 config/auth.php 配置文件中的以下守卫配置:

'guards' => [
    'api' => [
        'driver' => 'passport',
        'provider' => 'users',
    ],

    'api-customers' => [
        'driver' => 'passport',
        'provider' => 'customers',
    ],
],

以下路由将使用 api-customers 守卫,该守卫使用 customers 用户提供程序来对传入请求进行身份验证:

Route::get('/customer', function () {
    // ...
})->middleware('auth:api-customers');

如需了解在 Passport 中使用多个用户提供程序的更多信息,请参阅个人访问令牌文档和密码授权文档。

传递访问令牌

当调用受 Passport 保护的路由时,应用程序的 API 使用者应在请求的 Authorization 请求头中将访问令牌指定为 Bearer 令牌。例如,当使用 Http Facade 时:

use Illuminate\Support\Facades\Http;

$response = Http::withHeaders([
    'Accept' => 'application/json',
    'Authorization' => "Bearer $accessToken",
])->get('https://passport-app.test/api/user');

return $response->json();

令牌作用域

作用域允许 API 客户端在请求账户访问授权时申请一组特定权限。例如,如果你在构建一个电商应用程序,并非所有 API 使用者都需要下单的权限。相反,你可能只允许使用者申请访问订单配送状态的授权。换句话说,作用域允许应用程序的用户限制第三方应用程序代其执行的操作。

定义作用域

你可以在应用程序的 App\Providers\AppServiceProvider 类的 boot 方法中使用 Passport::tokensCan 方法来定义 API 的作用域。tokensCan 方法接收一个由作用域名称和作用域描述组成的数组。作用域描述可以是你想要的任何内容,并将在授权批准界面向用户显示:

/**
 * Bootstrap any application services.
 */
public function boot(): void
{
    Passport::tokensCan([
        'user:read' => 'Retrieve the user info',
        'orders:create' => 'Place orders',
        'orders:read:status' => 'Check order status',
    ]);
}

默认作用域

如果客户端未请求任何特定作用域,你可以使用 defaultScopes 方法配置 Passport 服务器,将默认作用域附加到令牌上。通常,你应该在应用程序的 App\Providers\AppServiceProvider 类的 boot 方法中调用此方法:

use Laravel\Passport\Passport;

Passport::tokensCan([
    'user:read' => 'Retrieve the user info',
    'orders:create' => 'Place orders',
    'orders:read:status' => 'Check order status',
]);

Passport::defaultScopes([
    'user:read',
    'orders:create',
]);

为令牌分配作用域

请求授权码时

当使用授权码授权方式请求访问令牌时,使用者应将期望的作用域指定为 scope 查询字符串参数。scope 参数应为以空格分隔的作用域列表:

Route::get('/redirect', function () {
    $query = http_build_query([
        'client_id' => 'your-client-id',
        'redirect_uri' => 'https://third-party-app.com/callback',
        'response_type' => 'code',
        'scope' => 'user:read orders:create',
    ]);

    return redirect('https://passport-app.test/oauth/authorize?'.$query);
});

签发个人访问令牌时

如果使用 App\Models\User 模型的 createToken 方法签发个人访问令牌,你可以将期望的作用域数组作为该方法的第二个参数传入:

$token = $user->createToken('My Token', ['orders:create'])->accessToken;

检查作用域

Passport 包含两个中间件,可用于验证传入请求是否使用已被授予特定作用域的令牌进行了身份验证。

检查所有作用域

可以将 Laravel\Passport\Http\Middleware\CheckToken 中间件分配给路由,以验证传入请求的访问令牌是否拥有所有列出的作用域:

use Laravel\Passport\Http\Middleware\CheckToken;

Route::get('/orders', function () {
    // Access token has both "orders:read" and "orders:create" scopes...
})->middleware(['auth:api', CheckToken::using('orders:read', 'orders:create')]);

检查任意一个作用域

可以将 Laravel\Passport\Http\Middleware\CheckTokenForAnyScope 中间件分配给路由,以验证传入请求的访问令牌是否拥有列出的作用域中至少一个:

use Laravel\Passport\Http\Middleware\CheckTokenForAnyScope;

Route::get('/orders', function () {
    // Access token has either "orders:read" or "orders:create" scope...
})->middleware(['auth:api', CheckTokenForAnyScope::using('orders:read', 'orders:create')]);

作用域特性

如果你的应用程序使用了控制器中间件特性,则可以使用 Laravel\Passport\Attributes\AuthorizeToken 特性作为 Passport 作用域中间件的便捷快捷方式:

<?php

namespace App\Http\Controllers;

use Laravel\Passport\Attributes\AuthorizeToken;

#[AuthorizeToken('orders:read')]
#[AuthorizeToken('orders:create', only: ['store'])]
class OrderController
{
    #[AuthorizeToken(['orders:read', 'orders:create'], anyScope: true)]
    public function index()
    {
        // Access token has either "orders:read" or "orders:create" scope...
    }

    public function store()
    {
        // Access token has both "orders:read" and "orders:create" scopes...
    }
}

默认情况下,AuthorizeToken 特性需要所有给定的作用域。如果你传入 anyScope: true,则当令牌拥有给定作用域中至少一个时,请求将被授权。

在令牌实例上检查作用域

当使用访问令牌进行身份验证的请求进入你的应用程序后,你仍然可以使用已认证的 App\Models\User 实例上的 tokenCan 方法来检查令牌是否具有特定作用域:

use Illuminate\Http\Request;

Route::get('/orders', function (Request $request) {
    if ($request->user()->tokenCan('orders:create')) {
        // ...
    }
});

其他作用域方法

scopeIds 方法将返回所有已定义的 ID / 名称组成的数组:

use Laravel\Passport\Passport;

Passport::scopeIds();

scopes 方法将返回所有已定义的作用域作为 Laravel\Passport\Scope 实例组成的数组:

Passport::scopes();

scopesFor 方法将返回与给定 ID / 名称匹配的 Laravel\Passport\Scope 实例组成的数组:

Passport::scopesFor(['user:read', 'orders:create']);

你可以使用 hasScope 方法来确定是否已定义特定作用域:

Passport::hasScope('orders:create');

SPA 身份验证

在构建 API 时,能够从自己的 JavaScript 应用程序中调用自己的 API 会非常实用。这种 API 开发方式允许你自己的应用程序调用你正在与外界共享的同一个 API。该 API 可以被你的 Web 应用程序、移动应用程序、第三方应用程序以及你在各种包管理器上发布的任何 SDK 调用。

通常,如果你想从 JavaScript 应用程序中调用自己的 API,你需要手动将访问令牌发送给应用程序,并在每次向应用程序发送请求时都传递该令牌。然而,Passport 包含了一个可以为你处理此事的中间件。你只需将 CreateFreshApiToken 中间件追加到应用程序 bootstrap/app.php 文件中的 web 中间件组即可:

use Laravel\Passport\Http\Middleware\CreateFreshApiToken;

->withMiddleware(function (Middleware $middleware): void {
    $middleware->web(append: [
        CreateFreshApiToken::class,
    ]);
})

你应确保 CreateFreshApiToken 中间件是中间件堆栈中列出的最后一个中间件。

此中间件会在你的响应中附加一个 laravel_token cookie。此 cookie 包含一个加密的 JWT,Passport 将使用它来对来自 JavaScript 应用程序的 API 请求进行身份验证。该 JWT 的有效期等于你的 session.lifetime 配置值。现在,由于浏览器会在所有后续请求中自动发送该 cookie,因此你可以在向应用程序的 API 发送请求时,无需显式传递访问令牌:

axios.get('/api/user')
    .then(response => {
        console.log(response.data);
    });

自定义 Cookie 名称

如有需要,你可以使用 Passport::cookie 方法来自定义 laravel_token cookie 的名称。通常,应在应用程序的 App\Providers\AppServiceProvider 类的 boot 方法中调用此方法:

/**
 * 引导启动任意应用服务。
 */
public function boot(): void
{
    Passport::cookie('custom_name');
}

CSRF 保护

使用此身份验证方法时,你需要确保请求中包含有效的 CSRF 令牌请求头。骨架应用及所有入门套件中默认包含的 Laravel JavaScript 脚手架提供了一个 Axios 实例,该实例会自动使用加密后的 XSRF-TOKEN Cookie 值,在同源请求中发送 X-XSRF-TOKEN 请求头。

如果你选择发送 X-CSRF-TOKEN 请求头而非 X-XSRF-TOKEN,则需要使用 csrf_token() 提供的未加密令牌。

事件

Passport 在签发访问令牌和刷新令牌时会触发事件。你可以监听这些事件,以便清理或吊销数据库中的其他访问令牌:

事件名称
Laravel\Passport\Events\AccessTokenCreated
Laravel\Passport\Events\AccessTokenRevoked
Laravel\Passport\Events\RefreshTokenCreated

测试

Passport 的 actingAs 方法可用于指定当前已认证用户及其作用域。传递给 actingAs 方法的第一个参数为用户实例,第二个参数为应授予用户令牌的作用域数组:

use App\Models\User;
use Laravel\Passport\Passport;

test('orders can be created', function () {
    Passport::actingAs(
        User::factory()->create(),
        ['orders:create']
    );

    $response = $this->post('/api/orders');

    $response->assertStatus(201);
});
use App\Models\User;
use Laravel\Passport\Passport;

public function test_orders_can_be_created(): void
{
    Passport::actingAs(
        User::factory()->create(),
        ['orders:create']
    );

    $response = $this->post('/api/orders');

    $response->assertStatus(201);
}

Passport 的 actingAsClient 方法可用于指定当前已认证客户端及其作用域。传递给 actingAsClient 方法的第一个参数为客户端实例,第二个参数为应授予客户端令牌的作用域数组:

use Laravel\Passport\Client;
use Laravel\Passport\Passport;

test('servers can be retrieved', function () {
    Passport::actingAsClient(
        Client::factory()->create(),
        ['servers:read']
    );

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

    $response->assertStatus(200);
});
use Laravel\Passport\Client;
use Laravel\Passport\Passport;

public function test_servers_can_be_retrieved(): void
{
    Passport::actingAsClient(
        Client::factory()->create(),
        ['servers:read']
    );

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

    $response->assertStatus(200);
}