轻量级 API 认证解决方案:Sanctum

Laravel Sanctum

声明:由于商标名称纠纷,Airlock 已更名为 Sanctum。

简介

Laravel Sanctum 为 SPA(Single Page Application,单页面应用)、移动 App 以及基于令牌的简单 API 提供了一个轻量级的认证系统。Sanctum 允许为应用的每个用户账户生成多个 API 令牌,这些令牌可用于授予权限/作用域来指定对应令牌允许执行的操作。

工作原理

API 令牌

Laravel Sanctum 主要用于解决两个问题。

首先,通过这个扩展包可以在不引入 OAuth 的情况下为用户颁发 API 令牌,OAuth 那一套流程太复杂了。这个特性主要源自 Github 访问令牌功能的启发,例如,假设应用的账户设置界面可用于为用户生成 API 令牌,你可以通过 Sanctum 来生成并管理这些令牌。这些令牌通常拥有很长的过期时间(以年计算),但是用户可以在任何时候手动清除该令牌。

Laravel Sanctum 通过保存用户 API 令牌到独立的数据表,然后当输入请求头 Authorization 包含有效 API 令牌时对其进行验证来实现 API 认证功能。

SPA 认证

注:将 Sanctum 只用于 API 令牌认证或者只用于 SPA 认证都是可以的,因为当你使用 Sanctum 时,并不意味着要求你必须使用这两个特性。

其次,Sanctum 还提供了一套简单的机制对单页面应用(SPA)与 Laravel 后端 API 之间的通信进行认证。这些 SPA 可以和 Laravel 应用位于同一个代码仓库,也可以位于独立的代码仓库,比如通过 Vue CLI 创建的 SPA。

Sanctum 并没有使用任何形式的令牌实现认证。取而代之地,Sanctum 会使用 Laravel 内置的基于 Cookie + Session 的认证服务,这样一来,我们就可以充分利用 CSRF 保护、Session 认证、以及防止基于 XSS 的认证凭证泄漏。当然,Scanctum 只会对来自自己系统的 SPA 前端(根域名一致)请求进行基于 Cookie + Session 的认证。

安装

通过 Composer 安装 Laravel Sanctum:

composer require laravel/sanctum

安装完成后,通过 vendor:publish 命令发布扩展包的配置和迁移文件,该命令会将 sanctum 配置文件存放到 config 目录下:

php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"

最后,运行数据库迁移命令生成扩展包需要的数据表:

php artisan migrate

该命令会新创建一个 personal_access_tokens 表用于存放用户 API 认证信息:

-w941

接下来,如果你是为单页面应用提供认证的话,需要在 app/Http/Kernel.phpapi 中间件分组中添加 Sanctum 中间件:

use Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful;
    
'api' => [
    EnsureFrontendRequestsAreStateful::class,
    'throttle:60,1',
    \Illuminate\Routing\Middleware\SubstituteBindings::class,
],

迁移自定义

如果你不准备使用 Sanctum 默认的迁移文件,可以在 AppServiceProviderregister 方法中调用 Sanctum::ignoreMigrations 方法。然后使用 php artisan vendor:publish --tag=sanctum-migrations 导出默认迁移文件进行自定义。

API 令牌认证

注:不要使用 API 令牌来认证自己的第一方 SPA,取而代之地,应该使用 Sanctum 内置的 SPA 认证

颁发 API 令牌

Sanctum 允许你颁发 API 令牌或者个人访问令牌用于认证 API 请求,当使用 API 令牌发起请求时,该令牌应该以 Bearer 令牌形式包含在 Authorization 请求头中。

在为用户颁发令牌之前,需要在 User 模型类中使用 HasApiTokens trait:

use Laravel\Sanctum\HasApiTokens;

class User extends Authenticatable
{
    use HasApiTokens, Notifiable;
}

要颁发令牌,可以使用 createToken 方法,该方法会返回一个 Laravel\Sanctum\NewAccessToken 实例。API 令牌在存放到数据库之前会使用 SHA-256 算法进行哈希,不过你可以使用 NewAccessToken 实例的 plainTextToken 属性来访问该令牌的纯文本格式值。你需要在令牌创建之后立即将其展示给用户:

$token = $user->createToken('token-name');

return $token->plainTextToken;

你可以通过 HasApiTokens trait 提供的 tokens 关联关系访问用户的所有令牌:

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

令牌权限

Sanctum 允许你为令牌分配权限,该功能和 OAuth 的作用域类似。你可以传递权限字符串数组作为 createToken 方法的第二个参数:

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

在使用 Sanctum 处理输入请求认证时,你可以使用 tokenCan 方法判断令牌是否具备给定权限:

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

注:为了方便,如果输入认证请求来自第一方 SPA 并且你正在使用 Sanctum 内置的 SPA 认证,则 tokenCan 方法总是返回 true

保护路由

要保护路由确保所有输入请求必须经过认证,需要在 routes/api.php 路由文件中应用 sanctum 认证守卫到对应 API 路由。该守卫可以确保输入请求要么以来自 SPA 的状态认证请求方式认证,要么以请求头包含有效 API 令牌的第三方请求方式认证:

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

撤销令牌

你可以通过 HasApiTokens trait 提供的 tokens 关联关系删除数据库中的令牌来实现令牌「撤销」:

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

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

SPA 认证

Sanctum 提供了一套简单的机制对单页面应用(SPA)与 Laravel 后端 API 之间的通信进行认证。这些 SPA 可以和 Laravel 应用位于同一个代码仓库,也可以位于独立的代码仓库,比如通过 Vue CLI 创建的 SPA。

Sanctum 并没有使用任何形式的令牌实现认证。取而代之地,Sanctum 会使用 Laravel 内置的基于 Cookie + Session 的认证服务,这样一来,我们就可以充分利用 CSRF 保护、Session 认证、以及防止基于 XSS 的认证凭证泄漏。当然,Scanctum 只会对来自自己系统的 SPA 前端(根域名一致)请求进行基于 Cookie + Session 的认证。

注:要实现这种认证,SPA 前端和后端 API 必须共享同样的顶级域名(子域名可以不同),比如都是 www.blog.test,或者 SPA 前端是 blog.test,后端 API 是 api.blog.test

配置

配置第一方域名

首先,你需要配置 SPA 发起请求的域名,这可以在 sanctum 配置文件的 stateful 配置项中完成。该配置设置可以决定哪些域名可以在发起 API 接口请求时使用 Laravel Session 认证维护认证状态:

'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', 'localhost,127.0.0.1')),

Sanctum 中间件

接下来,你需要添加 Sanctum 中间件到 app/Http/Kernel.php 文件的 api 中间件分组。该中间件负责确保来自 SPA 的输入请求可以使用 Laravel Session 机制进行认证,同时也支持来自第三方或者移动应用的请求使用 API 令牌进行认证:

use Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful;

'api' => [
    EnsureFrontendRequestsAreStateful::class,
    'throttle:60,1',
    \Illuminate\Routing\Middleware\SubstituteBindings::class,
],

CORS & Cookies

如果你在处理来自独立子域名的 SPA 请求认证时遇到问题,可能是没有配置 CORS 或者 Session cookie 设置。

你需要确保应用的 CORS 配置会返回 Access-Control-Allow-Credentials 值为 True 的响应头,这可以通过在 cors 配置文件中将 supports_credentials 配置值设置为 true 来完成:

'supports_credentials' => true,

此外,你需要在全局 axios 实例中启用 withCredentials 选项,通常,这可以在 resources/js/bootstrap.js 文件中完成:

axios.defaults.withCredentials = true;

最后,你需要确保你的应用会话 Cookie 域名配置支持根域名的任意子域名,这可以通过在 session 配置文件中为 domain 配置值添加一个 . 前缀来完成(或者通过在 .env 中设置 SESSION_DOMAIN):

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

认证

要认证 SPA,SPA 的登录页面需要首先对 /sanctum/csrf-cookie 路由发起请求来初始化 CSRF 保护:

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

CSRF 保护初始化之后,需要向 /login 路由发起 POST 请求,/login 路由可以通过 laravel/ui 认证脚手架扩展包提供。

如果登录请求成功,则用户认证成功,针对 API 的后续请求序列会自动通过 Cookie + Session 机制进行认证。

注:你可以随意编写自己的 /login 接口实现,不过,需要确保使用标准的、Laravel 提供的基于 Session 的认证服务进行认证。

保护路由

要保护路由确保所有输入请求必须经过认证,需要在 routes/api.php 路由文件中应用 sanctum 认证守卫到对应 API 路由。该守卫可以确保输入请求要么以来自 SPA 的状态认证请求方式认证,要么以请求头包含有效 API 令牌的第三方请求方式认证:

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

授权私有广播频道

如果你的 SPA 需要使用私有/存在广播频道进行认证,可以在 routes/api.php 文件中调用 Broadcast::routes 方法:

Broadcast::routes(['middleware' => ['auth:sanctum']]);

然后,为了让 Pusher 的授权请求可以成功,需要在初始化 Laravel Echo 时提供自定义的 Pusher authorizer,这样一来,应用就可以配置 Pusher 使用已经配置过正确处理 CORS 请求axios 实例了:

window.Echo = new Echo({
    broadcaster: "pusher",
    cluster: process.env.MIX_PUSHER_APP_CLUSTER,
    encrypted: true,
    key: process.env.MIX_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 令牌。API 端点会返回纯文本格式的 Sanctum 令牌,然后将其存储到移动设备用于后续发起 API 请求:

use App\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 认证守卫到路由来确保所有输入请求必须经过认证,从而实现路由保护。通常,你可以添加这个守卫到定义在 routes/api.php 文件的路由上:

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

撤销令牌

为了允许用户撤销颁发给移动设备的 API 令牌,你可以在一个「账户设置」页面通过名称来列举它们,并附上一个「撤销」按钮。当用户点击「撤销」按钮时,你可以从数据库删除对应令牌。你可以通过 HasApiTokens trait 提供的 tokens 关联关系来访问用户的所有 API 令牌:

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

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

测试

测试期间,可以使用 Sanctum::actingAs 方法来认证用户并指定分配给对应令牌的权限:

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

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

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

    $response->assertOk();
}

如果你想要授予所有权限到该令牌,需要在传递给 actingAs 方法的权限列表中包含 * 通配符:

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

实例教程

上一篇: 基于 OAuth 的 API 认证解决方案:Passport

下一篇: 全文搜索解决方案:Scout