构建安全的 Laravel API 与 JWT 身份验证
JWT(JSON Web Token)身份验证在 Laravel 中提供了一种无状态、加密签名机制,用于在不依赖服务器端会话存储的情况下验证 API 消费者。JWT 将有效载荷(通常是用户身份和声明)编码为紧凑的、URL 安全的字符串,并使用密钥或 RSA 密钥进行签名,使任何持有验证密钥的服务都能独立验证令牌。
本指南涵盖了使用 `tymon/jwt-auth` 包在 Laravel API 中实现 JWT 身份验证的完整流程,包括设置、模型配置、控制器逻辑、路由保护、令牌刷新策略和生产环境加固。每个步骤都包含超越入门教程的技术背景。
前提条件与环境假设
开始之前,请确认以下内容:
- PHP 8.1 或更高版本(Laravel 11 推荐使用 PHP 8.2)
- 通过 Composer 安装的 Laravel 10 或 11
- Composer 2.x
- MySQL 8.0、PostgreSQL 15 或任何兼容 PDO 的数据库
- 已配置 `.env` 文件,并填写有效的 `DB_*` 凭据
- 对 Laravel 服务容器、中间件和 Eloquent ORM 有基本了解
如果您要将此 API 部署到生产环境,具有完整 root 访问权限的 VPS 托管方案可为您提供配置 PHP-FPM、安全管理环境变量以及正确设置文件权限所需的控制能力——这些对于 JWT 密钥管理至关重要。
第一步:创建新的 Laravel 项目
“`bash
composer create-project laravel/laravel laravel-jwt-api
cd laravel-jwt-api
“`
验证安装:
“`bash
php artisan –version
“`
如果未自动生成应用密钥,请手动设置:
“`bash
php artisan key:generate
“`
`.env` 中的 `APP_KEY` 与 JWT 密钥是相互独立的。两者都是必需的,且服务于不同的加密目的——`APP_KEY` 用于保护加密的 Cookie 和会话数据,而 `JWT_SECRET` 用于签名令牌。
第二步:安装 JWT 身份验证包
`tymon/jwt-auth` 包是 Laravel 中 JWT 的事实标准。安装方式如下:
“`bash
composer require tymon/jwt-auth
“`
发布包配置:
“`bash
php artisan vendor:publish –provider="TymonJWTAuthProvidersLaravelServiceProvider"
“`
这将创建 `config/jwt.php`,用于控制令牌 TTL、刷新 TTL、算法、黑名单行为和必需声明。请仔细查阅此文件——默认值在开发环境中是合理的,但在生产环境中需要进行调整。
`config/jwt.php` 中的关键配置参数:
| 参数 | 默认值 | 生产环境建议 |
|---|
| — | — | — |
|---|
| `ttl` | 60 分钟 | 15–30 分钟 |
|---|
| `refresh_ttl` | 20160 分钟(2 周) | 1440–10080 分钟 |
|---|
| `algo` | `HS256` | 分布式系统使用 `RS256` |
|---|
| `blacklist_enabled` | `true` | 必须为 `true` |
|---|
| `blacklist_grace_period` | 0 秒 | 并发请求建议设为 10–30 秒 |
|---|
| `required_claims` | `['iss','iat','exp','nbf','sub','jti']` | 保留所有声明;多租户 API 可添加 `aud` |
|---|
第三步:生成 JWT 密钥
“`bash
php artisan jwt:secret
“`
这将把 `JWT_SECRET` 追加到您的 `.env` 文件中。使用默认 `HS256` 算法时,该值将作为所有令牌的 HMAC-SHA256 签名密钥。
重要安全注意事项:
- 切勿将 `.env` 提交到版本控制系统。请立即将其添加到 `.gitignore`。
- 在共享部署流水线中,应通过 CI/CD 系统以环境变量的方式注入 `JWT_SECRET`,而非将其存储在文件中。
- 如果您轮换密钥,所有现有令牌将立即失效。请相应地规划轮换窗口,并提前通知 API 消费者。
- 对于需要多个服务验证令牌的微服务架构,请切换到 `RS256`。生成 RSA 密钥对,将私钥存储在认证服务上,仅将公钥分发给消费服务。
第四步:配置身份验证守卫
打开 `config/auth.php`,更新 defaults 和 guards 部分:
“`php
'defaults' => [
'guard' => 'api',
'passwords' => 'users',
],
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
'api' => [
'driver' => 'jwt',
'provider' => 'users',
],
],
“`
这将指示 Laravel 的身份验证系统在解析 `api` 守卫时使用 JWT 驱动。`auth:api` 中间件现在将把令牌验证委托给 `tymon/jwt-auth`,而不是 Laravel Passport 或默认令牌驱动。
请勿删除 `web` 守卫。许多 Laravel 内部组件依赖它,删除后会导致与身份验证系统交互的控制台命令和队列工作者出现意外故障。
第五步:创建用户模型和迁移
如果默认的 `User` 模型和迁移已存在(全新 Laravel 安装中默认存在),您可以直接修改它们。如果从头开始:
“`bash
php artisan make:model User -m
“`
打开 `database/migrations/` 中的迁移文件并定义数据库结构:
“`php
public function up(): void
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->rememberToken();
$table->timestamps();
});
}
“`
运行迁移:
“`bash
php artisan migrate
“`
生产环境注意事项:在 `APP_ENV=production` 的生产环境中,始终使用 `–force` 标志运行迁移,否则 Laravel 会提示确认:
“`bash
php artisan migrate –force
“`
第六步:在用户模型中实现 JWTSubject 接口
`tymon/jwt-auth` 包要求您的可认证模型实现 `JWTSubject` 契约。打开 `app/Models/User.php`:
“`php
<?php
namespace AppModels;
use IlluminateDatabaseEloquentFactoriesHasFactory;
use IlluminateFoundationAuthUser as Authenticatable;
use IlluminateNotificationsNotifiable;
use TymonJWTAuthContractsJWTSubject;
class User extends Authenticatable implements JWTSubject
{
use HasFactory, Notifiable;
protected $fillable = [
'name',
'email',
'password',
];
protected $hidden = [
'password',
'remember_token',
];
protected $casts = [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
/**
- Get the identifier that will be stored in the JWT subject claim.
*/
public function getJWTIdentifier(): mixed
{
return $this->getKey();
}
/**
- Return a key-value array of arbitrary claims to add to the JWT payload.
*/
public function getJWTCustomClaims(): array
{
return [];
}
}
“`
关于自定义声明:`getJWTCustomClaims()` 方法用于将应用特定数据直接嵌入令牌有效载荷。常见用例包括嵌入 `role`、`tenant_id` 或 `permissions`,以便下游服务无需查询数据库即可做出授权决策。请谨慎选择嵌入内容——每个声明都会增加令牌大小,且任何人都可以通过 base64 解码读取有效载荷。切勿嵌入密码或个人身份信息等敏感数据。
第七步:构建身份验证控制器
“`bash
php artisan make:controller AuthController
“`
在 `app/Http/Controllers/AuthController.php` 中填充完整的身份验证逻辑:
“`php
<?php
namespace AppHttpControllers;
use AppModelsUser;
use IlluminateHttpJsonResponse;
use IlluminateHttpRequest;
use IlluminateSupportFacadesAuth;
use IlluminateSupportFacadesHash;
use IlluminateValidationValidationException;
use TymonJWTAuthExceptionsJWTException;
use TymonJWTAuthFacadesJWTAuth;
class AuthController extends Controller
{
/**
- Register a new user and return a JWT.
*/
public function register(Request $request): JsonResponse
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|string|email|max:255|unique:users',
'password' => 'required|string|min:8|confirmed',
]);
$user = User::create([
'name' => $validated['name'],
'email' => $validated['email'],
'password' => Hash::make($validated['password']),
]);
$token = JWTAuth::fromUser($user);
return response()->json([
'token' => $token,
'token_type' => 'bearer',
'expires_in' => auth('api')->factory()->getTTL() * 60,
], 201);
}
/**
- Authenticate a user and return a JWT.
*/
public function login(Request $request): JsonResponse
{
$credentials = $request->validate([
'email' => 'required|string|email',
'password' => 'required|string',
]);
if (!$token = Auth::guard('api')->attempt($credentials)) {
return response()->json(['error' => 'Invalid credentials'], 401);
}
return $this->respondWithToken($token);
}
/**
- Invalidate the current token (logout).
*/
public function logout(): JsonResponse
{
try {
Auth::guard('api')->logout();
} catch (JWTException $e) {
return response()->json(['error' => 'Failed to invalidate token'], 500);
}
return response()->json(['message' => 'Successfully logged out']);
}
/**
- Refresh the current token.
*/
public function refresh(): JsonResponse
{
try {
$token = Auth::guard('api')->refresh();
} catch (JWTException $e) {
return response()->json(['error' => 'Token cannot be refreshed'], 401);
}
return $this->respondWithToken($token);
}
/**
- Return the authenticated user's profile.
*/
public function me(): JsonResponse
{
return response()->json(Auth::guard('api')->user());
}
/**
- Format the token response consistently.
*/
protected function respondWithToken(string $token): JsonResponse
{
return response()->json([
'token' => $token,
'token_type' => 'bearer',
'expires_in' => auth('api')->factory()->getTTL() * 60,
]);
}
}
“`
为何明确指定守卫很重要:不指定守卫直接调用 `Auth::attempt()` 会回退到默认守卫。如果您已将默认守卫更改为 `api`,这虽然可以正常工作,但较为脆弱。请始终明确调用 `Auth::guard('api')->attempt()`,以避免在重构过程中默认守卫发生变化时出现隐性错误。
第八步:定义 API 路由
打开 `routes/api.php`,定义身份验证和受保护路由:
“`php
<?php
use AppHttpControllersAuthController;
use IlluminateSupportFacadesRoute;
// Public authentication routes
Route::prefix('auth')->group(function () {
Route::post('register', [AuthController::class, 'register']);
Route::post('login', [AuthController::class, 'login']);
});
// Protected routes — require a valid JWT
Route::middleware('auth:api')->prefix('auth')->group(function () {
Route::post('logout', [AuthController::class, 'logout']);
Route::post('refresh', [AuthController::class, 'refresh']);
Route::get('me', [AuthController::class, 'me']);
});
// Example protected resource routes
Route::middleware('auth:api')->group(function () {
Route::apiResource('posts', AppHttpControllersPostController::class);
});
“`
路由前缀策略:将认证端点分组在 `/api/auth/` 下是一种广泛采用的约定,可使 API 文档更清晰,并简化限流规则——您可以对 `/api/auth/login` 独立应用比资源端点更严格的限流策略。
第九步:保护路由并处理中间件失败
当令牌缺失、过期或无效时,Laravel 的 `auth:api` 中间件将返回 `401 Unauthenticated` 响应。为了返回一致的 JSON 响应而非 HTML 重定向,请在 `app/Exceptions/Handler.php` 中重写 `unauthenticated` 方法:
“`php
use IlluminateAuthAuthenticationException;
use IlluminateHttpRequest;
protected function unauthenticated($request, AuthenticationException $exception)
{
if ($request->expectsJson() || $request->is('api/*')) {
return response()->json(['error' => 'Unauthenticated'], 401);
}
return redirect()->guest(route('login'));
}
“`
如果不进行此重写,API 客户端将收到 HTML 302 重定向,指向纯 API 应用中不存在的登录页面——这是集成测试中常见的困惑来源。
第十步:令牌刷新策略与黑名单
JWT 的无状态特性带来了一个矛盾:令牌是自包含的,在过期前始终有效,但您需要一种在注销时撤销令牌的方式。`tymon/jwt-auth` 包通过基于 Laravel 缓存驱动的令牌黑名单来解决这一问题。
黑名单的工作原理:
- 注销时,令牌的 `jti`(JWT ID)声明将以与令牌剩余生命周期匹配的 TTL 存储在缓存中。
- 每次经过身份验证的请求时,中间件在接受令牌之前会检查黑名单。
- `blacklist_grace_period` 设置允许在令牌刷新期间有一个短暂的窗口期,在此期间旧令牌仍可使用,以防止客户端并发请求时出现竞态条件。
请确保您的缓存驱动支持此功能。默认的 `file` 驱动适用于单服务器部署。对于跨多个节点水平扩展的 API——在负载均衡配置中使用独立服务器时很常见——请切换到 Redis 或 Memcached:
“`env
CACHE_DRIVER=redis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
“`
令牌刷新流程:
“`
Client API Server
| — POST /api/auth/refresh ——> |
|---|
| Authorization: Bearer <old> |
|---|
| — Validate old token |
|---|
| — Blacklist old token's jti |
|---|
| — Issue new token |
|---|
| <– 200 { token: <new> } ——– |
|---|
“`
旧令牌在刷新后立即被加入黑名单。客户端必须存储新令牌并丢弃旧令牌。在前端通过 Axios 拦截器或等效机制实现此功能,以透明地处理令牌刷新。
第十一步:测试 API
使用 `curl` 或 Postman 验证每个端点。
注册用户:
“`bash
curl -X POST https://your-domain.com/api/auth/register
-H "Content-Type: application/json"
-d '{
"name": "Jane Smith",
"email": "jane@example.com",
"password": "SecurePass123!",
"password_confirmation": "SecurePass123!"
}'
“`
预期响应(`201 Created`):
“`json
{
"token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9…",
"token_type": "bearer",
"expires_in": 3600
}
“`
登录:
“`bash
curl -X POST https://your-domain.com/api/auth/login
-H "Content-Type: application/json"
-d '{"email": "jane@example.com", "password": "SecurePass123!"}'
“`
访问受保护路由:
“`bash
curl -X GET https://your-domain.com/api/auth/me
-H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9…"
“`
刷新令牌:
“`bash
curl -X POST https://your-domain.com/api/auth/refresh
-H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9…"
“`
注销:
“`bash
curl -X POST https://your-domain.com/api/auth/logout
-H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9…"
“`
JWT 与 Session、Laravel Sanctum、Passport 的对比
了解何时选择 JWT 需要将其与 Laravel 生态系统中的其他可用方案进行比较。
| 评判标准 | JWT(`tymon/jwt-auth`) | Laravel Sanctum | Laravel Passport | 基于 Session |
|---|
| — | — | — | — | — |
|---|
| **有状态性** | 无状态 | 无状态(SPA 令牌)/ 有状态(Cookie) | 有状态(数据库令牌) | 有状态 |
|---|
| **令牌存储** | 客户端 | 客户端或 Cookie | 数据库 | 服务器端 Session |
|---|
| **撤销方式** | 黑名单(缓存) | 即时(数据库删除) | 即时(数据库删除) | 即时 |
|---|
| **可扩展性** | 优秀(每次请求无需查询数据库) | 良好 | 中等(每次请求需查询数据库) | 差(需要 Session 同步) |
|---|
| **OAuth2 支持** | 否 | 否 | 是(完整 OAuth2 服务器) | 否 |
|---|
| **复杂度** | 中等 | 低 | 高 | 低 |
|---|
| **适用场景** | 无状态 API、微服务 | SPA、移动应用 | 第三方 OAuth 客户端 | 传统 Web 应用 |
|---|
| **令牌内省** | 客户端解码有效载荷 | 需要 API 调用 | 需要 API 调用 | 不适用 |
|---|
何时选择 JWT 而非 Sanctum:如果您的 API 由第三方客户端、移动应用或无法与 Laravel 应用共享 Session Cookie 或数据库连接的微服务消费,JWT 的自包含特性具有显著优势。如果您正在同一域名下构建第一方 SPA,使用基于 Cookie 的 Sanctum 身份验证更为简单,且完全避免了令牌存储的安全隐患。
生产环境安全加固
一个可正常运行的 JWT 实现并不意味着默认就是安全的。在上线前请应用以下加固措施:
1. 无条件强制使用 HTTPS
通过 HTTP 传输的 JWT 令牌极易被截获。在 Web 服务器层面强制使用 TLS 并将所有 HTTP 流量重定向。配合 SSL 证书,确保每个 API 请求都经过加密传输。
2. 设置较短的令牌 TTL
短生命周期的访问令牌(15–30 分钟)结合较长生命周期的刷新令牌(7–14 天),可以限制令牌被盗后的影响范围。更新 `config/jwt.php`:
“`php
'ttl' => 15,
'refresh_ttl' => 10080,
“`
3. 对身份验证端点应用限流
Laravel 内置的 throttle 中间件可防止暴力破解攻击:
“`php
Route::middleware(['throttle:10,1'])->group(function () {
Route::post('auth/login', [AuthController::class, 'login']);
Route::post('auth/register', [AuthController::class, 'register']);
});
“`
这将每个 IP 在认证端点上的请求限制为每分钟 10 次。
4. 为多租户 API 验证 `aud` 声明
如果您的 API 服务于多个客户端应用,请嵌入并验证 `audience` 声明,以防止令牌在不同服务间被复用:
“`php
// In getJWTCustomClaims()
return [
'aud' => config('app.jwt_audience'),
];
“`
5. 在操作系统层面保护 JWT_SECRET
为 `.env` 设置严格的文件权限:
“`bash
chmod 640 .env
chown www-data:www-data .env
“`
在配置正确的带 cPanel 的 VPS 上,您可以通过文件管理器或 SSH 管理文件所有权和权限,而不会有将密钥暴露给系统其他用户的风险。
6. 记录身份验证事件
集成 Laravel 的事件系统,将登录失败、令牌刷新和注销事件记录到集中式日志服务中。这对于异常检测至关重要。
添加基于角色的访问控制
自定义声明使得直接在令牌中嵌入角色变得简单:
“`php
// In User model
public function getJWTCustomClaims(): array
{
return [
'role' => $this->role, // e.g., 'admin', 'editor', 'viewer'
];
}
“`
创建一个中间件来强制执行角色要求:
“`php
<?php
namespace AppHttpMiddleware;
use Closure;
use IlluminateHttpRequest;
use TymonJWTAuthFacadesJWTAuth;
class RoleMiddleware
{
public function handle(Request $request, Closure $next, string $role): mixed
{
$payload = JWTAuth::parseToken()->getPayload();
if ($payload->get('role') !== $role) {
return response()->json(['error' => 'Forbidden'], 403);
}
return $next($request);
}
}
“`
在 `app/Http/Kernel.php`(Laravel 10)或 `bootstrap/app.php`(Laravel 11)中注册,并将其应用到路由:
“`php
Route::middleware(['auth:api', 'role:admin'])->group(function () {
Route::apiResource('admin/users', AdminUserController::class);
});
“`
注意事项:嵌入令牌中的角色数据仅在令牌本身有效期内保持最新。如果用户角色发生变更,旧令牌将继续授予旧角色,直到令牌过期或被刷新。对于需要立即生效的高安全性角色变更(例如立即撤销管理员权限),应将令牌黑名单与短 TTL 结合使用,或在中间件中同时进行数据库角色检查和声明检查。
Laravel JWT API 的部署注意事项
从本地开发迁移到生产服务器时,有几个特定于环境的因素会影响 JWT 的行为:
- 时区一致性:JWT 的 `iat`、`nbf` 和 `exp` 声明均为 Unix 时间戳。请确保服务器时区设置为 UTC(在 `php.ini` 中设置 `date.timezone = UTC`),以防止因时钟偏差导致令牌被拒绝。
- OPcache:启用 PHP OPcache 可减少每次请求加载 JWT 库文件的开销。这对高流量 API 的影响尤为显著。
- 令牌清理的队列工作者:如果您实现了自定义令牌黑名单清理任务,请确保队列工作者作为受监督的进程运行(使用 Supervisor 或 systemd)。
- 环境变量管理:在VPS 控制面板上,请使用面板的环境变量管理器,而非在生产环境中直接编辑 `.env`,以避免部署过程中意外覆盖配置。
上线前决策检查清单
使用此检查清单验证您的实现是否已准备好投入生产:
- `JWT_SECRET` 至少 32 个字符,随机生成,且未提交到版本控制系统
- `config/jwt.php` 中 `blacklist_enabled` 设置为 `true`
- 访问令牌的 TTL 设置为 30 分钟或更短
- 刷新令牌的 TTL 设置为符合您会话策略的适当值
- 所有 API 端点仅通过 HTTPS 提供服务
- `/login` 和 `/register` 端点已应用限流
- `unauthenticated` 异常处理器返回 JSON,而非 HTML 重定向
- 自定义声明不包含密码、密钥或敏感个人身份信息
- 多服务器部署中缓存驱动使用 Redis 或 Memcached(而非 `file`)
- 身份验证事件已记录并受到监控
- 需要立即生效的角色变更通过黑名单处理,而非仅依赖声明过期
- `.env` 文件权限已限制为仅 Web 服务器用户可访问
常见问题
Laravel 中 `JWT_SECRET` 和 `APP_KEY` 有什么区别?
`APP_KEY` 由 Laravel 的加密服务使用,用于加密 Cookie、Session 数据以及通过 `Crypt::encrypt()` 传递的值。`JWT_SECRET` 专门由 `tymon/jwt-auth` 用于签名和验证 JSON Web Token。两者在密码学上相互独立,服务于完全不同的目的,且都必须保密。
为什么我的 JWT 令牌未过期却持续返回 401?
最常见的原因包括:令牌已被加入黑名单(例如注销或刷新后)、令牌签发后 `JWT_SECRET` 被轮换、存储黑名单的缓存驱动不可用,或签发服务器与验证服务器之间的时钟偏差超过了 `config/jwt.php` 中 `leeway` 的设置值。请按顺序逐一排查。
我可以在 Laravel 队列或控制台命令中使用 JWT 身份验证吗?
JWT 专为 HTTP 请求身份验证而设计。在队列任务或 Artisan 命令内部,不存在 HTTP 请求上下文,因此无法通过中间件从令牌中解析用户。请改为将用户的主键作为任务参数传递,并直接使用 `User::find($userId)` 加载模型。
如何在令牌刷新期间处理并发请求以避免 401 错误?
在 `config/jwt.php` 中将 `blacklist_grace_period` 设置为 10 到 30 秒之间的值。在此窗口期内,刚刚被刷新(技术上已加入黑名单)的令牌仍将被接受。这可以防止客户端在刷新进行中同时发出多个请求时出现竞态条件。
`tymon/jwt-auth` 是否兼容 Laravel 11?
截至当前发布周期,`tymon/jwt-auth` 版本 `2.x` 通过 `dev-develop` 分支或声明了兼容性的标记版本支持 Laravel 10 和 11。在升级依赖此包的项目的 Laravel 版本之前,请务必检查包的 `composer.json` 约束和 GitHub issue 追踪器。建议在 `composer.json` 中固定包版本,以避免 `composer update` 时出现意外的破坏性变更。
