Skip to content

HTTP 认证

HTTP 认证机制允许 Web 服务器在响应请求时要求客户端提供身份凭证。PHP 通过 $_SERVER['PHP_AUTH_USER']$_SERVER['PHP_AUTH_PW']$_SERVER['HTTP_AUTHORIZATION'] 等超全局变量来获取认证信息,配合 header('WWW-Authenticate: ...') 触发浏览器的认证对话框。本节涵盖 HTTP Basic、Digest 认证以及现代 Token 认证的实现方式。

前置知识

阅读本节前,建议先了解:HTTP 头处理HTTP 协议概览

基础概念

HTTP 认证类型

认证方式安全性凭证传输适用场景
Basic低(Base64 编码,非加密)Base64(username:password)内部工具、HTTPS 环境
Digest中(MD5 哈希)MD5 哈希兼容旧系统
Bearer Token高(取决于实现)Authorization: BearerAPI 认证
OAuth 2.0授权码/令牌第三方接入
API KeyHeader/Query 中的密钥简单 API

安全警告

HTTP Basic 认证始终应配合 HTTPS 使用。Base64 不是加密,只是编码,攻击者可以轻易解码。在非 HTTPS 环境下使用 Basic 认证等同于明文传输密码。

HTTP Basic 认证

工作原理

1. 客户端请求受保护资源
2. 服务端返回 401 + WWW-Authenticate 头
3. 浏览器弹出用户名/密码对话框
4. 用户输入后,浏览器发送 Base64 编码的凭证
5. 服务端验证后返回受保护内容

基本实现

php
<?php

declare(strict_types=1);

// === 方式一:使用 PHP 内置变量(CGI/FastCGI)===

if (!isset($_SERVER['PHP_AUTH_USER']) || !isset($_SERVER['PHP_AUTH_PW'])) {
    // 发送 401 状态码和认证挑战
    http_response_code(401);
    header('WWW-Authenticate: Basic realm="My Protected Area"');
    echo '需要认证才能访问此页面';
    exit;
}

$username = $_SERVER['PHP_AUTH_USER'];
$password = $_SERVER['PHP_AUTH_PW'];

// 验证凭证
if ($username === 'admin' && $password === 'secret123') {
    echo "欢迎,{$username}!认证成功。";
} else {
    http_response_code(401);
    header('WWW-Authenticate: Basic realm="My Protected Area"');
    echo '用户名或密码错误';
    exit;
}

使用数据库验证

php
<?php

declare(strict_types=1);

/**
 * 基于 MySQL 数据库的 HTTP Basic 认证
 */

function authenticateBasic(PDO $pdo): ?array
{
    // 检查认证信息是否存在
    if (!isset($_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW'])) {
        http_response_code(401);
        header('WWW-Authenticate: Basic realm="API Access"');
        exit(json_encode(['error' => 'Authentication required']));
    }

    $username = $_SERVER['PHP_AUTH_USER'];
    $password = $_SERVER['PHP_AUTH_PW'];

    // 从数据库获取用户记录
    $stmt = $pdo->prepare('SELECT id, username, password_hash, status FROM users WHERE username = ?');
    $stmt->execute([$username]);
    $user = $stmt->fetch(PDO::FETCH_ASSOC);

    if (!$user) {
        http_response_code(401);
        header('WWW-Authenticate: Basic realm="API Access"');
        exit(json_encode(['error' => 'Invalid credentials']));
    }

    // 使用 password_verify 验证密码
    if (!password_verify($password, $user['password_hash'])) {
        http_response_code(401);
        header('WWW-Authenticate: Basic realm="API Access"');
        exit(json_encode(['error' => 'Invalid credentials']));
    }

    // 检查账户状态
    if ($user['status'] !== 'active') {
        http_response_code(403);
        exit(json_encode(['error' => 'Account is disabled']));
    }

    return $user;
}

// 使用示例
$pdo = new PDO('mysql:host=localhost;dbname=app', 'root', '');
$user = authenticateBasic($pdo);
echo "欢迎,{$user['username']} (ID: {$user['id']})";

CGI/FastCGI 下的 Authorization 头问题

php
<?php

declare(strict_types=1);

/**
 * 在 CGI/FastCGI 模式下,Apache 可能不自动填充 PHP_AUTH_USER/PHP_AUTH_PW
 * 需要手动解析 HTTP_AUTHORIZATION 头
 */

// 方案一:从 HTTP_AUTHORIZATION 头解析
function getBasicCredentials(): ?array
{
    if (isset($_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW'])) {
        return ['user' => $_SERVER['PHP_AUTH_USER'], 'pass' => $_SERVER['PHP_AUTH_PW']];
    }

    $authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
    if (empty($authHeader) && isset($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])) {
        $authHeader = $_SERVER['REDIRECT_HTTP_AUTHORIZATION'];
    }

    if (str_starts_with($authHeader, 'Basic ')) {
        $decoded = base64_decode(substr($authHeader, 6), true);
        if ($decoded === false) {
            return null;
        }
        $parts = explode(':', $decoded, 2);
        if (count($parts) === 2) {
            return ['user' => $parts[0], 'pass' => $parts[1]];
        }
    }

    return null;
}

// 方案二:.htaccess 规则传递 Authorization 头(Apache)
// 在 .htaccess 中添加:
// SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1
// RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]

// 方案三:php.ini 配置(CGI 模式)
// cgi.fix_pathinfo=1
// 在 .htaccess 中:
// <IfModule mod_rewrite.c>
//     RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
// </IfModule>

登出 Basic 认证

php
<?php

declare(strict_types=1);

/**
 * HTTP Basic 认证的登出非常棘手
 * 因为浏览器会自动缓存凭证,直到浏览器关闭
 */

// 方法一:发送 401 强制重新输入(伪登出)
function basicAuthLogout(): never
{
    http_response_code(401);
    header('WWW-Authenticate: Basic realm="Logout", charset="UTF-8"');
    echo '<p>您已登出。请关闭浏览器以完全清除凭证。</p>';
    exit;
}

// 方法二:使用错误的凭证使浏览器丢弃缓存的凭证
function forceLogout(): never
{
    // 发送 401 并要求重新认证
    header('HTTP/1.1 401 Unauthorized');
    header('WWW-Authenticate: Basic realm="Please logout"');

    // 发送一段误导浏览器清除缓存的脚本
    echo '<html><head><script>';
    echo 'window.onload = function() {';
    echo '  var xhr = new XMLHttpRequest();';
    echo '  xhr.open("GET", location.href, true, "logout", "logout");';
    echo '  xhr.send();';
    echo '};';
    echo '</script></head><body>正在登出...</body></html>';
    exit;
}

// 方法三:推荐方案 - 使用 Session/Cookie 替代 Basic 认证
// Basic 认证不适合需要登出功能的场景

HTTP Digest 认证

基本实现

php
<?php

declare(strict_types=1);

/**
 * HTTP Digest 认证
 * 比 Basic 认证安全,密码以 MD5 哈希形式传输
 */

$users = [
    'admin' => '5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8', // password("password") 的 SHA1
];

$realm = 'Protected Area';
$opaque = md5($realm); // 客户端回传的随机值

// 解析 Authorization 头
function parseDigestHeader(string $header): array
{
    $parts = [];
    $pattern = '/(\w+)=(?:"([^"]+)"|([^\s,]+))/';
    preg_match_all($pattern, $header, $matches, PREG_SET_ORDER);

    foreach ($matches as $match) {
        $parts[$match[1]] = $match[2] ?: $match[3];
    }

    return $parts;
}

// 生成有效的响应
function digestResponse(
    string $username,
    string $realm,
    string $password,
    string $method,
    string $uri,
    string $nonce,
    string $nc,
    string $cnonce,
    string $qop,
): string {
    $ha1 = md5("{$username}:{$realm}:{$password}");
    $ha2 = md5("{$method}:{$uri}");
    $response = md5("{$ha1}:{$nonce}:{$nc}:{$cnonce}:{$qop}:{$ha2}");
    return $response;
}

$authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';

if (empty($authHeader) || !str_starts_with($authHeader, 'Digest ')) {
    http_response_code(401);
    $nonce = md5(uniqid((string) mt_rand(), true));
    header('WWW-Authenticate: Digest realm="' . $realm . '", qop="auth", nonce="' . $nonce . '", opaque="' . $opaque . '"');
    exit('需要 Digest 认证');
}

$digest = parseDigestHeader(substr($authHeader, 7));
$username = $digest['username'] ?? '';

if (!isset($users[$username])) {
    http_response_code(401);
    exit('无效用户');
}

$password = 'password'; // 实际应从安全存储获取
$validResponse = digestResponse(
    username: $username,
    realm: $realm,
    password: $password,
    method: $_SERVER['REQUEST_METHOD'],
    uri: $digest['uri'],
    nonce: $digest['nonce'],
    nc: $digest['nc'],
    cnonce: $digest['cnonce'],
    qop: $digest['qop'],
);

if (!hash_equals($validResponse, $digest['response'])) {
    http_response_code(401);
    exit('认证失败');
}

echo "欢迎,{$username}!Digest 认证成功。";

Digest 认证的局限性

Digest 认证在现代 Web 安全中已不推荐使用:

  • MD5 哈希已不安全
  • 需要在服务端存储明文或可逆的密码
  • 大多数现代应用使用 Bearer Token 或 OAuth 2.0 替代

Bearer Token 认证

基本实现

php
<?php

declare(strict_types=1);

/**
 * Bearer Token 认证
 * 客户端在 Authorization 头中发送 Token
 */

// === 从请求中提取 Token ===
function getBearerToken(): ?string
{
    $authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';

    // 标准 Authorization 头
    if (str_starts_with($authHeader, 'Bearer ')) {
        return trim(substr($authHeader, 7));
    }

    // 兼容方式:通过 GET/POST 参数传递
    $token = $_GET['token'] ?? $_POST['token'] ?? null;
    if ($token !== null) {
        return $token;
    }

    // 兼容方式:Cookie
    $token = $_COOKIE['auth_token'] ?? null;
    if ($token !== null) {
        return $token;
    }

    return null;
}

// === 使用 ===
$token = getBearerToken();

if ($token === null) {
    http_response_code(401);
    header('Content-Type: application/json');
    echo json_encode(['error' => 'Missing authentication token']);
    exit;
}

// 验证 Token(示例:使用数据库查询)
// 实际项目中推荐使用 JWT,详见 API 认证章节
$pdo = new PDO('mysql:host=localhost;dbname=app', 'root', '');
$stmt = $pdo->prepare(
    'SELECT u.id, u.username, t.expires_at
     FROM users u
     JOIN api_tokens t ON u.id = t.user_id
     WHERE t.token = ? AND t.expires_at > NOW()'
);
$stmt->execute([$token]);
$userToken = $stmt->fetch(PDO::FETCH_ASSOC);

if (!$userToken) {
    http_response_code(401);
    echo json_encode(['error' => 'Invalid or expired token']);
    exit;
}

echo "认证用户: {$userToken['username']} (ID: {$userToken['id']})";

API Key 认证

php
<?php

declare(strict_types=1);

/**
 * API Key 认证
 * 简单直接,适合服务间通信和公开 API
 */

class ApiKeyAuth
{
    public function __construct(
        private readonly PDO $pdo,
    ) {
    }

    /**
     * 从请求中获取 API Key
     */
    public function getApiKey(): ?string
    {
        // 1. Authorization 头(推荐)
        $authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
        if (str_starts_with($authHeader, 'ApiKey ')) {
            return trim(substr($authHeader, 6));
        }

        // 2. X-API-Key 头
        $apiKey = $_SERVER['HTTP_X_API_KEY'] ?? '';
        if (!empty($apiKey)) {
            return $apiKey;
        }

        // 3. Query 参数(不推荐,会出现在日志中)
        $apiKey = $_GET['api_key'] ?? null;
        if ($apiKey !== null) {
            return $apiKey;
        }

        return null;
    }

    /**
     * 验证 API Key
     */
    public function validate(string $apiKey): ?array
    {
        // 使用 hash_equals 防止时序攻击
        $stmt = $this->pdo->prepare(
            'SELECT id, name, user_id, permissions, rate_limit
             FROM api_keys
             WHERE key_hash = ? AND is_active = 1'
        );

        // 对 API Key 进行哈希后查询(不在数据库存储明文)
        $keyHash = hash('sha256', $apiKey);
        $stmt->execute([$keyHash]);
        $key = $stmt->fetch(PDO::FETCH_ASSOC);

        return $key ?: null;
    }

    /**
     * 速率限制检查
     */
    public function checkRateLimit(string $apiKey, int $limit = 100): bool
    {
        // 使用 Redis 实现速率限制(示例)
        // $redis = new Redis();
        // $redis->connect('127.0.0.1', 6379);
        // $key = "rate_limit:{$apiKey}:" . date('YmdHi');
        // $current = $redis->incr($key);
        // if ($current === 1) {
        //     $redis->expire($key, 60);
        // }
        // return $current <= $limit;

        return true; // 示例中跳过
    }
}

// 使用中间件模式
$auth = new ApiKeyAuth($pdo);
$apiKey = $auth->getApiKey();

if ($apiKey === null) {
    http_response_code(401);
    echo json_encode(['error' => 'API Key required. Pass via X-API-Key header or Authorization: ApiKey {key}']);
    exit;
}

$keyInfo = $auth->validate($apiKey);
if ($keyInfo === null) {
    http_response_code(401);
    echo json_encode(['error' => 'Invalid API Key']);
    exit;
}

echo "API Key: {$keyInfo['name']},权限: {$keyInfo['permissions']}";

认证中间件

可复用的认证中间件

php
<?php

declare(strict_types=1);

/**
 * 认证中间件基类
 */
abstract class AuthMiddleware
{
    abstract public function authenticate(): ?array;

    public function requireAuth(): array
    {
        $user = $this->authenticate();

        if ($user === null) {
            $this->unauthorized();
        }

        return $user;
    }

    protected function unauthorized(string $message = 'Authentication required'): never
    {
        http_response_code(401);
        header('Content-Type: application/json');
        echo json_encode(['error' => $message]);
        exit;
    }

    protected function forbidden(string $message = 'Access denied'): never
    {
        http_response_code(403);
        header('Content-Type: application/json');
        echo json_encode(['error' => $message]);
        exit;
    }
}

/**
 * Bearer Token 认证中间件
 */
class BearerAuthMiddleware extends AuthMiddleware
{
    public function __construct(
        private readonly PDO $pdo,
    ) {
    }

    public function authenticate(): ?array
    {
        $token = $this->extractToken();
        if ($token === null) {
            return null;
        }

        $stmt = $this->pdo->prepare(
            'SELECT u.id, u.username, u.role
             FROM users u
             JOIN auth_tokens t ON u.id = t.user_id
             WHERE t.token = ? AND t.expires_at > NOW()'
        );
        $stmt->execute([$token]);
        return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
    }

    private function extractToken(): ?string
    {
        $authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
        if (str_starts_with($authHeader, 'Bearer ')) {
            return substr($authHeader, 7);
        }
        return null;
    }
}

/**
 * 角色权限检查
 */
function requireRole(array $user, string ...$roles): void
{
    if (!in_array($user['role'], $roles, true)) {
        http_response_code(403);
        echo json_encode(['error' => '权限不足']);
        exit;
    }
}

// 使用示例
$pdo = new PDO('mysql:host=localhost;dbname=app', 'root', '');
$auth = new BearerAuthMiddleware($pdo);

// 需要认证的 API
$user = $auth->requireAuth();
echo "当前用户: {$user['username']}";

// 需要管理员权限
requireRole($user, 'admin', 'superadmin');
echo "管理员操作执行中...";

实战示例:REST API 认证流程

php
<?php

declare(strict_types=1);

/**
 * 完整的 API 认证流程
 * 包含用户名密码登录、Token 签发和 Token 验证
 */

class ApiAuth
{
    public function __construct(
        private readonly PDO $pdo,
        private readonly string $tokenSecret = 'your-secret-key-change-this',
        private readonly int $tokenLifetime = 86400, // 24 小时
    ) {
    }

    /**
     * 用户登录,签发 Token
     */
    public function login(string $username, string $password): array
    {
        // 获取用户
        $stmt = $this->pdo->prepare('SELECT id, username, password_hash, status FROM users WHERE username = ?');
        $stmt->execute([$username]);
        $user = $stmt->fetch(PDO::FETCH_ASSOC);

        if (!$user || !password_verify($password, $user['password_hash'])) {
            throw new RuntimeException('用户名或密码错误');
        }

        if ($user['status'] !== 'active') {
            throw new RuntimeException('账户已被禁用');
        }

        // 生成 Token
        $token = $this->generateToken($user['id']);

        // 存储 Token
        $expiresAt = date('Y-m-d H:i:s', time() + $this->tokenLifetime);
        $stmt = $this->pdo->prepare(
            'INSERT INTO auth_tokens (user_id, token, expires_at, created_at) VALUES (?, ?, ?, NOW())'
        );
        $stmt->execute([$user['id'], $token, $expiresAt]);

        return [
            'token' => $token,
            'expires_at' => $expiresAt,
            'user' => [
                'id' => $user['id'],
                'username' => $user['username'],
            ],
        ];
    }

    /**
     * 验证 Token,返回用户信息
     */
    public function verifyToken(string $token): ?array
    {
        $stmt = $this->pdo->prepare(
            'SELECT t.id, t.user_id, t.expires_at, u.username, u.role
             FROM auth_tokens t
             JOIN users u ON t.user_id = u.id
             WHERE t.token = ? AND t.expires_at > NOW()'
        );
        $stmt->execute([$token]);
        $result = $stmt->fetch(PDO::FETCH_ASSOC);

        if (!$result) {
            return null;
        }

        return [
            'id' => $result['user_id'],
            'username' => $result['username'],
            'role' => $result['role'],
            'token_id' => $result['id'],
        ];
    }

    /**
     * 注销 Token
     */
    public function logout(string $token): bool
    {
        $stmt = $this->pdo->prepare('DELETE FROM auth_tokens WHERE token = ?');
        return $stmt->execute([$token]) && $stmt->rowCount() > 0;
    }

    /**
     * 生成随机 Token
     */
    private function generateToken(int $userId): string
    {
        $payload = json_encode([
            'user_id' => $userId,
            'iat' => time(),
            'exp' => time() + $this->tokenLifetime,
        ]);

        // 简单的 Token 生成(生产环境推荐 JWT)
        $signature = hash_hmac('sha256', $payload, $this->tokenSecret);
        return base64_encode($payload) . '.' . $signature;
    }

    /**
     * 从请求中提取 Token
     */
    public static function extractTokenFromRequest(): ?string
    {
        $authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
        if (str_starts_with($authHeader, 'Bearer ')) {
            return substr($authHeader, 7);
        }
        return null;
    }
}

// === API 路由 ===
$pdo = new PDO('mysql:host=localhost;dbname=app', 'root', '');
$auth = new ApiAuth($pdo);

$method = $_SERVER['REQUEST_METHOD'];
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);

header('Content-Type: application/json');

match ($uri) {
    '/api/login' => match ($method) {
        'POST' => handleLogin(),
        default => (function () { http_response_code(405); echo json_encode(['error' => 'Method Not Allowed']); })(),
    },
    '/api/logout' => match ($method) {
        'POST' => handleLogout(),
        default => (function () { http_response_code(405); echo json_encode(['error' => 'Method Not Allowed']); })(),
    },
    '/api/profile' => match ($method) {
        'GET' => handleProfile(),
        default => (function () { http_response_code(405); echo json_encode(['error' => 'Method Not Allowed']); })(),
    },
    default => (function () { http_response_code(404); echo json_encode(['error' => 'Not Found']); })(),
};

function handleLogin(): never
{
    global $auth;
    $data = json_decode(file_get_contents('php://input'), true) ?? [];

    try {
        $result = $auth->login($data['username'] ?? '', $data['password'] ?? '');
        echo json_encode(['success' => true, 'data' => $result], JSON_UNESCAPED_UNICODE);
    } catch (RuntimeException $e) {
        http_response_code(401);
        echo json_encode(['success' => false, 'error' => $e->getMessage()]);
    }
    exit;
}

function handleLogout(): never
{
    global $auth;
    $token = ApiAuth::extractTokenFromRequest();
    if ($token === null) {
        http_response_code(401);
        echo json_encode(['error' => 'Token required']);
        exit;
    }
    $auth->logout($token);
    echo json_encode(['success' => true, 'message' => '已登出']);
    exit;
}

function handleProfile(): never
{
    global $auth;
    $token = ApiAuth::extractTokenFromRequest();
    if ($token === null) {
        http_response_code(401);
        echo json_encode(['error' => 'Token required']);
        exit;
    }
    $user = $auth->verifyToken($token);
    if ($user === null) {
        http_response_code(401);
        echo json_encode(['error' => 'Invalid or expired token']);
        exit;
    }
    echo json_encode(['success' => true, 'data' => $user]);
    exit;
}

注意事项

1. 时序攻击防范

php
<?php

// 错误:使用 == 比较 Token(可能受时序攻击影响)
// if ($token == $storedToken) { ... }

// 正确:使用 hash_equals 进行恒定时间比较
if (hash_equals($storedToken, $providedToken)) {
    // Token 有效
}

// password_verify 内部已使用 hash_equals,无需额外处理
if (password_verify($password, $hash)) {
    // 密码正确
}

2. 认证信息不要写入日志

php
<?php

// 错误:将 Authorization 头写入日志
error_log("Auth: " . $_SERVER['HTTP_AUTHORIZATION']); // 泄露 Token

// 正确:记录认证事件,不记录凭证
error_log("Authentication attempt for user: {$username}, result: success/failure");

3. HTTPS 是强制性的

php
<?php

// 在所有需要认证的端点强制 HTTPS
function enforceHttps(): void
{
    if (
        (empty($_SERVER['HTTPS']) || $_SERVER['HTTPS'] !== 'on')
        && ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') !== 'https'
    ) {
        $httpsUrl = 'https://' . ($_SERVER['HTTP_HOST'] ?? 'localhost') . $_SERVER['REQUEST_URI'];
        http_response_code(301);
        header('Location: ' . $httpsUrl);
        exit;
    }
}

最佳实践

1. 选择合适的认证方式

php
<?php

// 场景推荐:
//
// 1. 浏览器访问的 Web 应用 -> Session + Cookie
// 2. SPA(前后端分离)-> Bearer Token (JWT)
// 3. 服务间通信 -> API Key / mTLS
// 4. 第三方接入 -> OAuth 2.0
// 5. 简单内部工具 -> Basic Auth (仅 HTTPS)
// 6. 移动应用 -> Bearer Token (JWT) + Refresh Token

2. 认证失败信息不要暴露具体原因

php
<?php

// 错误:告诉攻击者用户名存在
if (!userExists($username)) {
    die('用户名不存在');
}
if (!passwordCorrect($password)) {
    die('密码错误');
}

// 正确:统一的错误信息
if (!validCredentials($username, $password)) {
    die('用户名或密码错误'); // 不区分是用户名还是密码错误
}

下一节

继续学习:表单提交与验证

参考链接