Laravel Sanctum SPA 无状态认证:Token 签发与过期策略设计
大家好,今天我们来深入探讨 Laravel Sanctum 在构建单页应用 (SPA) 时如何实现无状态认证,并重点讨论 Token 的签发和过期策略设计。Sanctum 是 Laravel 官方提供的轻量级认证包,非常适合 API 认证,尤其是在 SPA 场景下,它能很好地解决 Cookie-based 认证在跨域问题上的局限性。
一、Sanctum 认证原理回顾
在开始之前,我们先快速回顾一下 Sanctum 的核心工作原理。Sanctum 的目标是在不依赖 Cookie 的前提下,为 SPA 应用提供安全的认证机制。它主要依赖以下几点:
- API Token: Sanctum 使用 API Token 来验证用户的身份。这些 Token 是数据库中存储的字符串,与特定用户关联。
- Token 能力 (Abilities): 每个 Token 可以被赋予不同的能力,例如
read,write,admin等。 这允许你控制用户可以通过该 Token 执行的操作。 - 加密: Token 在传输过程中应该通过 HTTPS 加密,以防止被窃取。
- Single Page Application (SPA) 认证: Sanctum 专门为 SPA 认证设计了一套流程,允许前端通过发送请求获取 Token,并将 Token 存储在前端(通常是
localStorage或sessionStorage)并在后续请求的Authorization头部中携带。
二、Sanctum 安装与配置
首先,确保你的 Laravel 项目已经安装了 Sanctum。如果没有,可以通过 Composer 安装:
composer require laravel/sanctum
安装完成后,需要发布 Sanctum 的配置文件和迁移文件,并执行迁移:
php artisan vendor:publish --provider="LaravelSanctumSanctumServiceProvider"
php artisan migrate
这将在你的数据库中创建 personal_access_tokens 表,用于存储 API Token。
最后,在 AppModelsUser 模型中引入 HasApiTokens trait:
<?php
namespace AppModels;
use IlluminateFoundationAuthUser as Authenticatable;
use IlluminateNotificationsNotifiable;
use LaravelSanctumHasApiTokens;
class User extends Authenticatable
{
use HasApiTokens, Notifiable;
// ...
}
三、Token 签发策略设计
Token 签发是认证流程的核心。我们需要考虑以下几个关键点:
-
Token 类型:
- 短期 Token (Short-Lived Token): 用于授权用户访问 API。过期时间较短,例如 1 小时,安全性较高。
- 刷新 Token (Refresh Token): 用于获取新的短期 Token。过期时间较长,例如 30 天,但必须安全存储。
Sanctum 默认只支持短期 Token,我们需要手动实现刷新 Token 的逻辑。
-
Token 存储位置:
- 前端 (localStorage/sessionStorage): 短期Token存储于此
- 数据库: 刷新Token存储于此
选择哪种取决于你的安全需求和用户体验。
-
Token 签发接口: 提供一个 API 接口,用于用户登录后签发 Token。
3.1 短期Token签发
我们可以创建一个 AuthController 来处理用户登录和 Token 签发:
<?php
namespace AppHttpControllers;
use AppModelsUser;
use IlluminateHttpRequest;
use IlluminateSupportFacadesAuth;
use IlluminateSupportFacadesHash;
use IlluminateSupportStr;
class AuthController extends Controller
{
public function login(Request $request)
{
$request->validate([
'email' => 'required|email',
'password' => 'required',
]);
$user = User::where('email', $request->email)->first();
if (!$user || !Hash::check($request->password, $user->password)) {
return response()->json(['message' => 'Invalid credentials'], 401);
}
$token = $user->createToken('auth_token')->plainTextToken;
return response()->json([
'access_token' => $token,
'token_type' => 'Bearer',
]);
}
public function logout(Request $request)
{
$request->user()->currentAccessToken()->delete();
return response()->json(['message' => 'Logged out']);
}
}
路由定义:
Route::post('/login', [AuthController::class, 'login']);
Route::post('/logout', [AuthController::class, 'logout'])->middleware('auth:sanctum');
3.2 刷新Token签发
为了实现刷新 Token 的功能,我们需要进行以下步骤:
-
修改
personal_access_tokens表: 添加refresh_token和expires_at字段。use IlluminateDatabaseMigrationsMigration; use IlluminateDatabaseSchemaBlueprint; use IlluminateSupportFacadesSchema; class AddRefreshTokenToPersonalAccessTokensTable extends Migration { /** * Run the migrations. * * @return void */ public function up() { Schema::table('personal_access_tokens', function (Blueprint $table) { $table->string('refresh_token')->nullable()->unique(); $table->timestamp('expires_at')->nullable(); }); } /** * Reverse the migrations. * * @return void */ public function down() { Schema::table('personal_access_tokens', function (Blueprint $table) { $table->dropColumn('refresh_token'); $table->dropColumn('expires_at'); }); } }运行 migrate
php artisan migrate -
修改
login方法: 生成并存储刷新 Token。
public function login(Request $request)
{
$request->validate([
'email' => 'required|email',
'password' => 'required',
]);
$user = User::where('email', $request->email)->first();
if (!$user || !Hash::check($request->password, $user->password)) {
return response()->json(['message' => 'Invalid credentials'], 401);
}
// 创建短期 Token
$accessToken = $user->createToken('auth_token', ['*'])->plainTextToken;
// 生成刷新 Token
$refreshToken = Str::random(40);
// 保存刷新 Token 到数据库
$token = $user->tokens()->where('name', 'auth_token')->first(); // 获取刚才创建的 token 记录
$token->refresh_token = $refreshToken;
$token->expires_at = now()->addDays(30); // 设置刷新 Token 的过期时间
$token->save();
return response()->json([
'access_token' => $accessToken,
'token_type' => 'Bearer',
'refresh_token' => $refreshToken,
'expires_in' => now()->addMinutes(60)->timestamp // 短期Token过期时间,例如1小时
]);
}
- 创建
refresh方法: 使用刷新 Token 获取新的短期 Token。
public function refresh(Request $request)
{
$request->validate([
'refresh_token' => 'required',
]);
$refreshToken = $request->input('refresh_token');
// 查找有效的刷新 Token
$token = PersonalAccessToken::where('refresh_token', $refreshToken)
->where('expires_at', '>', now())
->first();
if (!$token) {
return response()->json(['message' => 'Invalid refresh token'], 401);
}
$user = $token->tokenable; // 获取关联的用户
// 删除旧的 Token
$token->delete();
// 创建新的短期 Token
$accessToken = $user->createToken('auth_token', ['*'])->plainTextToken;
// 生成新的刷新 Token
$newRefreshToken = Str::random(40);
// 保存新的刷新 Token 到数据库
$newToken = $user->tokens()->where('name', 'auth_token')->first(); // 获取刚才创建的 token 记录
$newToken->refresh_token = $newRefreshToken;
$newToken->expires_at = now()->addDays(30); // 设置刷新 Token 的过期时间
$newToken->save();
return response()->json([
'access_token' => $accessToken,
'token_type' => 'Bearer',
'refresh_token' => $newRefreshToken,
'expires_in' => now()->addMinutes(60)->timestamp // 短期Token过期时间,例如1小时
]);
}
- 添加路由:
Route::post('/refresh', [AuthController::class, 'refresh']);
四、Token 过期策略设计
Token 过期策略对于安全性至关重要。
-
短期 Token 过期时间: 短期 Token 的过期时间应该设置得相对较短,例如 1 小时或更短。这可以减少 Token 被窃取后造成的潜在损害。
-
刷新 Token 过期时间: 刷新 Token 的过期时间可以设置得较长,例如 30 天或更长。但是,必须确保刷新 Token 的安全存储,例如存储在数据库中,并进行加密处理。
-
Token 撤销: 提供一个 API 接口,允许用户主动撤销 Token。这在用户注销或怀疑 Token 泄露时非常有用。
-
定期清理过期 Token: 定期清理数据库中过期的 Token,以减少数据库的存储压力。可以使用 Laravel 的任务调度器来完成这个任务。
4.1 手动设置过期时间
在签发 Token 时,我们可以使用 $token->expires_at 属性来设置过期时间。
$token = $user->createToken('auth_token');
$accessToken = $token->plainTextToken;
$token->accessToken->expires_at = now()->addMinutes(60); // 设置过期时间为 1 小时后
$token->accessToken->save();
4.2 定期清理过期 Token
创建一个 Artisan 命令来清理过期的 Token:
<?php
namespace AppConsoleCommands;
use IlluminateConsoleCommand;
use LaravelSanctumPersonalAccessToken;
class PruneExpiredTokens extends Command
{
protected $signature = 'sanctum:prune-expired';
protected $description = 'Prune expired Sanctum tokens';
public function handle()
{
PersonalAccessToken::where('expires_at', '<', now())->delete();
$this->info('Expired Sanctum tokens pruned successfully.');
}
}
注册命令到 app/Console/Kernel.php:
protected $commands = [
CommandsPruneExpiredTokens::class,
];
使用 Laravel 的任务调度器来定期运行该命令:
protected function schedule(Schedule $schedule)
{
$schedule->command('sanctum:prune-expired')->daily(); // 每天运行一次
}
五、SPA 前端集成
在 SPA 前端,需要实现以下功能:
-
登录: 调用
loginAPI 获取 Token 和刷新 Token,并将它们存储在localStorage或sessionStorage中。 -
发送 API 请求: 在每个 API 请求的
Authorization头部中添加BearerToken。 -
Token 刷新: 在 Token 过期或即将过期时,调用
refreshAPI 获取新的 Token。 -
注销: 调用
logoutAPI 撤销 Token,并从localStorage或sessionStorage中删除 Token。
5.1 前端示例 (JavaScript)
// 登录
async function login(email, password) {
try {
const response = await fetch('/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ email, password })
});
const data = await response.json();
if (response.ok) {
localStorage.setItem('access_token', data.access_token);
localStorage.setItem('refresh_token', data.refresh_token);
localStorage.setItem('expires_in', data.expires_in);
return true;
} else {
console.error('Login failed:', data.message);
return false;
}
} catch (error) {
console.error('Login error:', error);
return false;
}
}
// 发送 API 请求
async function fetchData(url) {
const accessToken = localStorage.getItem('access_token');
if (!accessToken) {
// 用户未登录,重定向到登录页面
window.location.href = '/login';
return;
}
try {
const response = await fetch(url, {
headers: {
'Authorization': `Bearer ${accessToken}`
}
});
if (response.status === 401) {
// Token 过期,尝试刷新
const refreshed = await refreshToken();
if (refreshed) {
// 重新发送请求
return fetchData(url);
} else {
// 刷新失败,重定向到登录页面
window.location.href = '/login';
return;
}
}
const data = await response.json();
return data;
} catch (error) {
console.error('API request error:', error);
}
}
// 刷新 Token
async function refreshToken() {
const refreshToken = localStorage.getItem('refresh_token');
if (!refreshToken) {
return false;
}
try {
const response = await fetch('/refresh', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ refresh_token: refreshToken })
});
const data = await response.json();
if (response.ok) {
localStorage.setItem('access_token', data.access_token);
localStorage.setItem('refresh_token', data.refresh_token);
localStorage.setItem('expires_in', data.expires_in);
return true;
} else {
console.error('Refresh failed:', data.message);
return false;
}
} catch (error) {
console.error('Refresh error:', error);
return false;
}
}
// 注销
async function logout() {
const accessToken = localStorage.getItem('access_token');
try {
const response = await fetch('/logout', {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`
}
});
if (response.ok) {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('expires_in');
window.location.href = '/login';
} else {
console.error('Logout failed:', response.status);
}
} catch (error) {
console.error('Logout error:', error);
}
}
六、安全注意事项
-
HTTPS: 确保所有 API 请求都通过 HTTPS 进行加密,防止 Token 被窃取。
-
CORS: 配置 CORS 策略,只允许受信任的域名访问 API。
-
XSS: 防止 XSS 攻击,避免将 Token 直接渲染到 HTML 页面中。
-
CSRF: 虽然 Sanctum 主要用于无状态认证,但仍然需要注意 CSRF 攻击。 Laravel 提供了 CSRF 保护机制,可以在 API 请求中包含 CSRF Token。
-
Token 存储: 安全地存储 Token 在前端。 考虑使用
HttpOnlyCookie 或加密localStorage的内容。 -
限制 Token 能力: 为 Token 分配最小权限原则的能力。
七、总结
通过以上步骤,我们可以使用 Laravel Sanctum 构建一个安全的 SPA 无状态认证系统。 重点在于合理设计 Token 的签发和过期策略,并采取必要的安全措施来保护 Token 的安全。 记住,安全是一个持续的过程,需要不断地进行监控和改进。
八、核心要点回顾:Token 管理与安全
Sanctum 为 SPA 提供了便捷的 API 认证方案。合理地利用 Token 签发和过期策略,结合安全措施,可以构建一个可靠且安全的无状态认证系统。务必重视 Token 的安全存储和传输,并定期审查和更新你的安全策略。