Skip to content

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 签名

下一节

继续学习:请求解析 request_parse_body

参考链接