API 认证
API 认证是确保只有授权客户端才能访问接口的安全机制。在现代 PHP 应用中,JWT(JSON Web Token)和 OAuth 2.0 是最常用的两种认证方式。本节讲解 JWT 的原理与实现、OAuth 2.0 基本概念、API Key 认证方式,以及在 PHP 中集成这些认证机制的完整方案。
前置知识
阅读本节前,建议先了解:HTTP 认证、RESTful API 设计、JSON 处理
基础概念
API 认证方式对比
| 方式 | 安全性 | 复杂度 | 适用场景 |
|---|---|---|---|
| API Key | 中 | 低 | 简单 API、服务间通信 |
| Bearer Token(不透明) | 高 | 中 | Web/移动应用 |
| JWT | 高 | 中 | 无状态 API、微服务 |
| OAuth 2.0 | 最高 | 高 | 第三方接入、开放平台 |
| HMAC 签名 | 高 | 中 | 开放 API、支付接口 |
JWT 认证
JWT 结构
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9. <- Header (Base64)
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFs <- Payload (Base64)
aWNlIiwiYWRtaW4iOnRydWV9.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c <- Signature (HMAC)php
<?php
declare(strict_types=1);
// JWT 三部分
// Header: {"alg": "HS256", "typ": "JWT"}
// Payload: {"sub": "1234567890", "name": "Alice", "iat": 1516239022}
// Signature: HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)PHP JWT 实现
php
<?php
declare(strict_types=1);
/**
* JWT 编码与解码
*/
class JwtService
{
public function __construct(
private readonly string $secret,
private readonly string $algorithm = 'HS256',
private readonly int $accessTokenTtl = 3600, // 1 小时
private readonly int $refreshTokenTtl = 2592000, // 30 天
) {
if (strlen($this->secret) < 32) {
throw new InvalidArgumentException('JWT secret must be at least 32 characters');
}
}
/**
* 生成 Access Token
*/
public function generateAccessToken(int $userId, array $claims = []): string
{
$payload = array_merge($claims, [
'sub' => (string) $userId,
'iat' => time(),
'exp' => time() + $this->accessTokenTtl,
'type' => 'access',
]);
return $this->encode($payload);
}
/**
* 生成 Refresh Token
*/
public function generateRefreshToken(int $userId): string
{
$payload = [
'sub' => (string) $userId,
'iat' => time(),
'exp' => time() + $this->refreshTokenTtl,
'type' => 'refresh',
'jti' => bin2hex(random_bytes(16)), // 唯一标识符
];
return $this->encode($payload);
}
/**
* 编码 JWT
*/
private function encode(array $payload): string
{
$header = [
'alg' => $this->algorithm,
'typ' => 'JWT',
];
$headerEncoded = $this->base64UrlEncode(json_encode($header, JSON_UNESCAPED_SLASHES));
$payloadEncoded = $this->base64UrlEncode(json_encode($payload, JSON_UNESCAPED_SLASHES));
$signature = hash_hmac(
$this->getHashAlgorithm(),
"{$headerEncoded}.{$payloadEncoded}",
$this->secret,
true
);
return "{$headerEncoded}.{$payloadEncoded}." . $this->base64UrlEncode($signature);
}
/**
* 解码并验证 JWT
*/
public function verify(string $token): ?array
{
$parts = explode('.', $token);
if (count($parts) !== 3) {
return null;
}
[$headerEncoded, $payloadEncoded, $signatureEncoded] = $parts;
// 验证签名
$expectedSignature = hash_hmac(
$this->getHashAlgorithm(),
"{$headerEncoded}.{$payloadEncoded}",
$this->secret,
true
);
if (!hash_equals($expectedSignature, $this->base64UrlDecode($signatureEncoded))) {
return null;
}
// 解码 payload
$payload = json_decode($this->base64UrlDecode($payloadEncoded), true);
if ($payload === null) {
return null;
}
// 验证过期时间
if (isset($payload['exp']) && $payload['exp'] < time()) {
return null;
}
// 验证签发时间
if (isset($payload['iat']) && $payload['iat'] > time() + 60) {
return null; // 签发时间在未来(时钟偏移容差 60 秒)
}
// 验证 Token 类型
if (!isset($payload['type']) || !in_array($payload['type'], ['access', 'refresh'], true)) {
return null;
}
return $payload;
}
/**
* 解码 Access Token 并返回用户 ID
*/
public function getAccessTokenUserId(string $token): ?int
{
$payload = $this->verify($token);
if ($payload === null || ($payload['type'] ?? '') !== 'access') {
return null;
}
return (int) ($payload['sub'] ?? 0) ?: null;
}
private function getHashAlgorithm(): string
{
return match ($this->algorithm) {
'HS256' => 'sha256',
'HS384' => 'sha384',
'HS512' => 'sha512',
default => throw new InvalidArgumentException("Unsupported algorithm: {$this->algorithm}"),
};
}
private function base64UrlEncode(string $data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
private function base64UrlDecode(string $data): string
{
$remainder = strlen($data) % 4;
if ($remainder) {
$data .= str_repeat('=', 4 - $remainder);
}
return base64_decode(strtr($data, '-_', '+/'));
}
}JWT 认证中间件
php
<?php
declare(strict_types=1);
class JwtAuthMiddleware
{
public function __construct(
private readonly JwtService $jwt,
) {
}
/**
* 从请求中提取 Token
*/
public function extractToken(): ?string
{
// 1. Authorization: Bearer {token}
$authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (str_starts_with($authHeader, 'Bearer ')) {
return trim(substr($authHeader, 7));
}
// 2. Query 参数(不推荐,但兼容移动端)
$token = $_GET['token'] ?? null;
if ($token !== null) {
return $token;
}
return null;
}
/**
* 认证并返回用户 ID
*/
public function authenticate(): ?array
{
$token = $this->extractToken();
if ($token === null) {
$this->respondUnauthorized('Missing authentication token');
}
$payload = $this->jwt->verify($token);
if ($payload === null) {
$this->respondUnauthorized('Invalid or expired token');
}
return $payload;
}
/**
* 认证并要求特定角色
*/
public function requireRole(string ...$roles): array
{
$payload = $this->authenticate();
$userRole = $payload['role'] ?? '';
if (!in_array($userRole, $roles, true)) {
$this->respondForbidden('Insufficient permissions');
}
return $payload;
}
private function respondUnauthorized(string $message): never
{
http_response_code(401);
header('Content-Type: application/json');
echo json_encode(['error' => $message], JSON_UNESCAPED_UNICODE);
exit;
}
private function respondForbidden(string $message): never
{
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['error' => $message], JSON_UNESCAPED_UNICODE);
exit;
}
}
// === 使用示例 ===
$jwt = new JwtService(
secret: getenv('JWT_SECRET'),
algorithm: 'HS256',
);
$auth = new JwtAuthMiddleware($jwt);
// 保护路由
$user = $auth->authenticate();
echo "用户 ID: {$user['sub']}";
// 角色保护
$admin = $auth->requireRole('admin', 'superadmin');
echo "管理员 ID: {$admin['sub']}";Access Token + Refresh Token 模式
php
<?php
declare(strict_types=1);
class TokenController
{
public function __construct(
private readonly JwtService $jwt,
private readonly PDO $pdo,
) {
}
/**
* 登录获取 Token
*/
public function login(string $username, string $password): array
{
$stmt = $this->pdo->prepare('SELECT id, username, password_hash, role FROM users WHERE username = ?');
$stmt->execute([$username]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$user || !password_verify($password, $user['password_hash'])) {
throw new RuntimeException('Invalid credentials');
}
$accessToken = $this->jwt->generateAccessToken((int) $user['id'], [
'username' => $user['username'],
'role' => $user['role'],
]);
$refreshToken = $this->jwt->generateRefreshToken((int) $user['id']);
// 存储 Refresh Token(用于撤销)
$this->storeRefreshToken((int) $user['id'], $refreshToken);
return [
'access_token' => $accessToken,
'refresh_token' => $refreshToken,
'token_type' => 'Bearer',
'expires_in' => 3600,
];
}
/**
* 刷新 Token
*/
public function refresh(string $refreshToken): array
{
$payload = $this->jwt->verify($refreshToken);
if ($payload === null || ($payload['type'] ?? '') !== 'refresh') {
throw new RuntimeException('Invalid refresh token');
}
$userId = (int) $payload['sub'];
// 验证 Refresh Token 是否在存储中(未被撤销)
if (!$this->validateRefreshToken($userId, $refreshToken)) {
throw new RuntimeException('Refresh token has been revoked');
}
// 生成新的 Access Token
$accessToken = $this->jwt->generateAccessToken($userId);
return [
'access_token' => $accessToken,
'token_type' => 'Bearer',
'expires_in' => 3600,
];
}
/**
* 注销(撤销 Refresh Token)
*/
public function logout(string $refreshToken): void
{
$payload = $this->jwt->verify($refreshToken);
if ($payload !== null) {
$jti = $payload['jti'] ?? '';
$this->pdo->prepare('DELETE FROM refresh_tokens WHERE jti = ?')
->execute([$jti]);
}
}
private function storeRefreshToken(int $userId, string $token): void
{
$payload = $this->jwt->verify($token);
$jti = $payload['jti'] ?? '';
$expiresAt = date('Y-m-d H:i:s', $payload['exp'] ?? time());
$this->pdo->prepare(
'INSERT INTO refresh_tokens (user_id, jti, expires_at) VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE jti = VALUES(jti), expires_at = VALUES(expires_at)'
)->execute([$userId, $jti, $expiresAt]);
}
private function validateRefreshToken(int $userId, string $token): bool
{
$payload = $this->jwt->verify($token);
$jti = $payload['jti'] ?? '';
$stmt = $this->pdo->prepare(
'SELECT id FROM refresh_tokens WHERE user_id = ? AND jti = ? AND expires_at > NOW()'
);
$stmt->execute([$userId, $jti]);
return $stmt->fetch() !== false;
}
}OAuth 2.0 基础
OAuth 2.0 授权码流程
客户端 授权服务器 资源服务器
| | |
| 1. 用户点击授权 -----> | |
| | 2. 显示授权页面 |
| 3. 用户同意授权 -----> | |
| <--- 4. 授权码 ---------| |
| | |
| 5. 用授权码换 Token --> | |
| <--- 6. Access Token ---| |
| | |
| 7. 用 Token 访问 API ------------------------------------>|
| <--- 8. 返回资源 ---------------------------------------|使用第三方 OAuth 2.0
php
<?php
declare(strict_types=1);
/**
* OAuth 2.0 授权码模式实现
*/
class OAuthClient
{
public function __construct(
private readonly string $clientId,
private readonly string $clientSecret,
private readonly string $authorizeUrl,
private readonly string $tokenUrl,
private readonly string $redirectUri,
) {
}
/**
* 生成授权 URL
*/
public function getAuthorizeUrl(string $state, array $scopes = ['read']): string
{
$params = http_build_query([
'response_type' => 'code',
'client_id' => $this->clientId,
'redirect_uri' => $this->redirectUri,
'scope' => implode(' ', $scopes),
'state' => $state,
]);
return $this->authorizeUrl . '?' . $params;
}
/**
* 用授权码换取 Access Token
*/
public function exchangeCodeForToken(string $code): array
{
$response = $this->httpPost($this->tokenUrl, [
'grant_type' => 'authorization_code',
'code' => $code,
'redirect_uri' => $this->redirectUri,
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret,
]);
return $response;
}
private function httpPost(string $url, array $data): array
{
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($data),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded'],
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
throw new RuntimeException("OAuth request failed: HTTP {$httpCode}");
}
return json_decode($response, true, 512, JSON_THROW_ON_ERROR);
}
}HMAC 签名认证
php
<?php
declare(strict_types=1);
/**
* HMAC 签名认证(类似 AWS Signature)
* 适合开放 API 和支付接口
*/
class HmacAuth
{
public function __construct(
private readonly string $appId,
private readonly string $appSecret,
) {
}
/**
* 生成签名请求
*/
public function signRequest(string $method, string $url, array $params = [], string $body = ''): array
{
$timestamp = (string) time();
$nonce = bin2hex(random_bytes(8));
// 待签名字符串
$stringToSign = strtoupper($method) . "\n"
. parse_url($url, PHP_URL_PATH) . "\n"
. http_build_query($params) . "\n"
. $body . "\n"
. $timestamp . "\n"
. $nonce;
// HMAC-SHA256 签名
$signature = base64_encode(
hash_hmac('sha256', $stringToSign, $this->appSecret, true)
);
// 添加认证参数
$params['app_id'] = $this->appId;
$params['timestamp'] = $timestamp;
$params['nonce'] = $nonce;
$params['signature'] = $signature;
return $params;
}
/**
* 验证签名
*/
public static function verifySignature(string $method, string $url, array $params, string $body, string $appSecret): bool
{
$signature = $params['signature'] ?? '';
$timestamp = $params['timestamp'] ?? '';
$nonce = $params['nonce'] ?? '';
// 验证时间戳(5 分钟内)
if (abs(time() - (int) $timestamp) > 300) {
return false;
}
// 移除签名参数,重新计算
$unsignedParams = $params;
unset($unsignedParams['signature']);
$stringToSign = strtoupper($method) . "\n"
. parse_url($url, PHP_URL_PATH) . "\n"
. http_build_query($unsignedParams) . "\n"
. $body . "\n"
. $timestamp . "\n"
. $nonce;
$expectedSignature = base64_encode(
hash_hmac('sha256', $stringToSign, $appSecret, true)
);
return hash_equals($expectedSignature, $signature);
}
}注意事项
1. JWT 安全配置
php
<?php
// Secret 长度至少 32 字符(HS256 推荐 64 字节)
// Secret 使用环境变量,不硬编码
// Access Token 有效期尽量短(15-60 分钟)
// Refresh Token 有效期可以长(7-30 天)
// 生产环境考虑使用 RS256(非对称加密)
// JWT 不存储敏感信息(payload 可被 Base64 解码)2. Token 黑名单
php
<?php
// JWT 是无状态的,注销需要黑名单机制
// 可以使用 Redis 存储已注销的 Token ID
// 存储:SET jwt:blacklist:{jti} 1 EX {remaining_ttl}
// 验证:检查 jti 是否在黑名单中最佳实践
1. 认证方式选择
- 同域 SPA 应用:Session + CSRF Token
- 前后端分离 SPA:JWT Access + Refresh Token
- 移动应用:JWT Access + Refresh Token
- 服务间通信:API Key / mTLS
- 第三方接入:OAuth 2.0 授权码
- 开放 API:HMAC 签名下一节