ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

PHP 8.2怎么实现JWT身份验证保护API

PHP 8.2怎么实现JWT身份验证保护API 前言先澄清一个容易误会的点JWTJSON Web Token不是 PHP 的语言特性PHP 8.2 在这里只是运行环境换到 8.0、8.1 或 8.3 代码写法完全一样。真正决定安全性的是你的签名算法、密钥管理和 claims 校验逻辑而不是小版本号。用 JWT 保护 API 时出问题的往往不是「token 怎么签发」而是几个更隐蔽的地方把Authorization头里的 token 取不出来、只验签名不验exp、允许客户端自选算法、把用户敏感信息直接塞进 payload、以及 token 泄露后无法吊销。这些坑的共同点是——测试的时候全都正常出问题的时候已经晚了。本文用一个完整的、不依赖任何 Composer 包的手写实现讲清 JWT 的签名原理再给出生产环境更推荐的库方案最后把鉴权中间件该做的校验逐条落实。示例代码需要 PHP 8.0 及以上用到了构造器属性提升、str_contains()等 8.0 语法在 PHP 8.2 上可直接运行。一、JWT 长什么样一个 JWT 由三段用.连接header.payload.signature。eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 . eyJzdWIiOiIxIiwibmFtZSI6ImFsaWNlIn0 . dBjftJeZ4CVP... └──────── header ────────┘ └──────── payload ────────┘ └──── signature ────┘headerJSON说明用了什么算法alg和类型typ。payloadJSON装 claims声明。注册的标准字段有iss签发者、sub主体、aud受众、exp过期时间、nbf生效时间、iat签发时间、jti唯一编号。signature对header.payload这段字符串的签名。两个必须记住的事实header 和 payload 只是 Base64URL 编码不是加密。任何人拿到 token 都能解出里面的内容。所以 payload 里绝不能放密码、身份证号、内部接口地址。签名的对象是编码后的字符串不是解码后的 JSON。所以校验时必须用客户端原样传回来的那两段解码再重新编码会破坏签名的可比性。Base64URL 是 Base64 的一个变体把换成-、/换成_并去掉末尾的填充。URL 和 HTTP 头里都能安全传输。二、手写一个 HS256 的签发与校验HS256HMAC-SHA256是对称算法签发和校验用同一个密钥。它足够简单适合单体服务。下面这个文件可以直接保存成jwt.php运行PHP 8.0?php // jwt.php —— 需要 PHP 8.0 declare(strict_types1); final class JwtError extends RuntimeException {} function b64uEncode(string $raw): string { return rtrim(strtr(base64_encode($raw), /, -_), ); } function b64uDecode(string $encoded): string { $pad strlen($encoded) % 4; if ($pad 0) { $encoded . str_repeat(, 4 - $pad); } $decoded base64_decode(strtr($encoded, -_, /), true); if ($decoded false) { throw new JwtError(base64url 解码失败); } return $decoded; } /** * 签发 token。$claims 是业务声明时间类声明由本函数统一写入避免调用方覆盖。 */ function jwtSign(array $claims, string $secret, int $ttl 3600): string { if (strlen($secret) 32) { throw new JwtError(密钥太短HS256 的密钥至少应有 32 字节); } $now time(); $header [alg HS256, typ JWT]; $payload array_merge($claims, [ iat $now, nbf $now, exp $now $ttl, jti bin2hex(random_bytes(16)), ]); $h b64uEncode(json_encode($header, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)); $p b64uEncode(json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)); $sig b64uEncode(hash_hmac(sha256, {$h}.{$p}, $secret, true)); return {$h}.{$p}.{$sig}; } /** * 校验 token 并返回 payload。$leeway 是允许的时钟偏移秒。 */ function jwtVerify(string $token, string $secret, int $leeway 0): array { $parts explode(., $token); if (count($parts) ! 3) { throw new JwtError(token 结构不合法); } [$h, $p, $sig] $parts; // 1) 先校验算法白名单再验签。顺序不能反。 $header json_decode(b64uDecode($h), true, 512, JSON_THROW_ON_ERROR); $alg $header[alg] ?? null; if ($alg ! HS256) { throw new JwtError(不支持的签名算法: . var_export($alg, true)); } // 2) 用 hash_equals 做定时安全比较避免按字节比较泄露信息 $expected b64uEncode(hash_hmac(sha256, {$h}.{$p}, $secret, true)); if (!hash_equals($expected, $sig)) { throw new JwtError(签名校验失败); } // 3) 通过验签之后才解析 payload $payload json_decode(b64uDecode($p), true, 512, JSON_THROW_ON_ERROR); $now time(); if (isset($payload[nbf]) $now $leeway $payload[nbf]) { throw new JwtError(token 尚未生效); } if (!isset($payload[exp])) { throw new JwtError(token 缺少 exp 声明); } if ($now - $leeway $payload[exp]) { throw new JwtError(token 已过期); } if (isset($payload[iss]) $payload[iss] ! api.example.com) { throw new JwtError(签发者不匹配); } return $payload; } // ---- 演示 ---- $secret random_bytes(32); // 真实项目里应从环境变量读 $token jwtSign([sub 1001, role admin], $secret, 60); echo token: {$token}\n\n; $claims jwtVerify($token, $secret); printf(sub%s role%s exp%d\n, $claims[sub], $claims[role], $claims[exp]); // 篡改 payload 中的一个字符验签必须失败 $bad substr_replace($token, $token[10] a ? b : a, 10, 1); try { jwtVerify($bad, $secret); echo 不该走到这里\n; } catch (JwtError $e) { echo 篡改后被拒绝: {$e-getMessage()}\n; }把这三点固化下来绝大多数「JWT 被绕过」的攻击就没戏了算法白名单、hash_equals定时安全比较、exp必填且强校验。三、生产环境更推荐库自己写签名逻辑适合理解原理但生产上更推荐用经过审计的库比如firebase/php-jwt。6.x 版本要求传入Key对象而不是裸字符串composer require firebase/php-jwt?php // 需要 PHP 8.0 与 firebase/php-jwt 6.x declare(strict_types1); require __DIR__ . /vendor/autoload.php; use Firebase\JWT\JWT; use Firebase\JWT\Key; use Firebase\JWT\ExpiredException; use Firebase\JWT\SignatureInvalidException; $secret getenv(JWT_SECRET) ?: throw new RuntimeException(缺少 JWT_SECRET); // 签发 $token JWT::encode( [sub 1001, role admin, exp time() 3600], $secret, HS256 ); // 校验算法由 Key 对象固定客户端无法通过 header 影响它 try { $claims (array) JWT::decode($token, new Key($secret, HS256)); echo $claims[sub], PHP_EOL; } catch (ExpiredException $e) { http_response_code(401); echo token 已过期; } catch (SignatureInvalidException $e) { http_response_code(401); echo 签名无效; }注意JWT::decode()的第二个参数在 6.x 里必须是Key或Key的数组传裸字符串会直接报类型错误——这正是为了防止算法被 header 里的alg牵着走。四、token 泄露了怎么办JWT 是无状态的签出去就收不回来。要获得「吊销」能力通常有两条路方案做法代价短过期 刷新令牌exp设 15 分钟另发一个长过期的 refresh token 存库多一次刷新请求版本号声明用户表存token_version校验时比对 claims 里的ver每次请求要查一次用户或缓存该字段黑名单把未过期但需要作废的jti写进 RedisTTL 设为剩余有效期增加一次存储查询最不推荐的是「把exp设成 30 天」——那等于放弃了所有补救手段。代码实战把校验接进 API 中间件签发和校验有了还需要一个统一入口。下面这段PHP 8.0演示如何在请求最前面完成鉴权并把结果交给业务代码?php declare(strict_types1); function bearerToken(): ?string { // FPM/CGI 环境下Authorization 头未必落在 $_SERVER 里这里做三重兜底 $raw $_SERVER[HTTP_AUTHORIZATION] ?? $_SERVER[REDIRECT_HTTP_AUTHORIZATION] ?? ; if ($raw function_exists(getallheaders)) { foreach (getallheaders() as $k $v) { if (strcasecmp($k, Authorization) 0) { $raw $v; break; } } } if (!str_starts_with($raw, Bearer )) { return null; } $token trim(substr($raw, 7)); return $token ? null : $token; } /** 返回鉴权通过的 claims失败直接终止请求 */ function requireAuth(string $secret): array { $token bearerToken(); if ($token null) { http_response_code(401); header(WWW-Authenticate: Bearer realmapi); exit(json_encode([error missing_token])); } try { return jwtVerify($token, $secret, 30); // 允许 30 秒时钟偏移 } catch (JwtError $e) { http_response_code(401); header(WWW-Authenticate: Bearer errorinvalid_token); exit(json_encode([error invalid_token])); } } // ---- 使用 ---- $secret getenv(JWT_SECRET) ?: str_repeat(k, 32); $claims requireAuth($secret); // 到这里说明 token 可信可以做细分授权 if (($claims[role] ?? ) ! admin) { http_response_code(403); exit(json_encode([error forbidden])); } echo json_encode([ok true, sub $claims[sub]]);常见坑点只验签名不验exp❌ 自己解出 payload 就直接用过期 token 永久有效。 ✅ 强制要求exp存在并校验缺失就当作非法。让客户端决定算法❌ 从 header 里读alg然后动态选择hash_hmac还是openssl_verify。 ✅ 在服务端写死允许的算法白名单遇到其他值直接拒绝用库时用Key对象固定算法。拿不到Authorization头❌ 本地用php -S测得好好的上了 nginx FPM 后$_SERVER[HTTP_AUTHORIZATION]是空的。 ✅ 在 Apache 里开CGIPassAuth On或在 nginx 里显式透传fastcgi_param HTTP_AUTHORIZATION $http_authorization;。用比较签名❌if ($sig $expected)之外的各种宽松比较或者用strcmp提前返回。 ✅ 一律用hash_equals()它是定时安全的。把敏感数据塞进 payload❌[sub 1, phone 138..., id_card ...]。 ✅ payload 对任何拿到 token 的人都是明文敏感数据只放数据库或服务端 sessionpayload 里只放标识符。密钥硬编码在代码库❌const JWT_SECRET my-secret;跟着代码提交到仓库。 ✅ 从环境变量或密钥管理服务读取并且长度不少于 32 字节换密钥时要注意所有已签发 token 会立即失效。忘了处理时钟偏移❌ 多台机器时间差几十秒nbf刚签出来就被判定「尚未生效」。 ✅ 校验时留出leeway如 30 秒并确保服务器开启 NTP 同步。把 JWT 放在 URL 查询串里❌GET /api/orders?tokenxxx。 ✅ token 会进入访问日志、浏览器历史和 Referer只能放在Authorization头里。总结环节必须做的事常见错误算法服务端白名单拒绝none与其他算法信任 header 的alg签名比较hash_equals()定时安全比较用或strcmp时间校验exp必填nbf/iat视情况校验留 leeway只验签名不验时间密钥环境变量注入≥32 字节可轮换硬编码、密钥太短传输只放Authorization: Bearer头放 URL 或请求体吊销短exp refresh token或版本号声明exp设成 30 天JWT 保护 API 的正确姿势可以压缩成一句话用经过审计的库、把算法固定在服务端、把时间声明当成必填项、把 payload 当成公开信息对待。PHP 8.2 本身只是承载这些逻辑的运行环境真正决定这套鉴权能不能扛住攻击的是上面这几条校验有没有一条不落地执行。
返回列表