現(xiàn)用戶認(rèn)證)
前言用 JWTJSON Web Token做接口認(rèn)證的項(xiàng)目出問題的姿勢(shì)高度雷同本地一切正常上線后要么是「令牌永遠(yuǎn)不過期」要么是「用戶改了密碼舊令牌照樣能用」最糟糕的一種是安全掃描直接報(bào)出「簽名可繞過」。根因通常不在 JWT 本身而在于把它當(dāng)成了一個(gè)「加密過的 cookie」。JWT 的前兩段header 與 payload只是 Base64URL 編碼任何人拿到令牌就能解開、看到里面的全部?jī)?nèi)容安全邊界完全落在第三段的簽名上。一旦簽名校驗(yàn)寫錯(cuò)——用比較、不限定算法、拿到false不看——JWT 就退化成一個(gè)可以任意偽造的明文 JSON。還需要先澄清一點(diǎn)JWT 不是 PHP 的語言特性PHP 8.1 并沒有為它引入任何東西。它是一套令牌格式規(guī)范你既可以用hash_hmac()手寫也可以用firebase/php-jwt之類的庫。8.1 能幫上忙的只是語法層面枚舉enumPHP 8.1、readonly提升屬性PHP 8.1、never返回類型PHP 8.1這些能讓簽發(fā)與校驗(yàn)的代碼少踩幾個(gè)字面量拼錯(cuò)的坑。本文先講清 JWT 的簽名原理再給出一個(gè)不依賴任何第三方庫、純hash_hmac()實(shí)現(xiàn)的完整可運(yùn)行示例含篡改檢測(cè)與過期檢測(cè)最后說明接入firebase/php-jwt與框架集成時(shí)的注意事項(xiàng)。一、JWT 的三段結(jié)構(gòu)與簽名原理一個(gè) JWT 長(zhǎng)這樣用兩個(gè)點(diǎn)分成三段eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsImV4cCI6MTc2MDAwMDAwMH0.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk段名稱內(nèi)容是否保密第 1 段header{alg:HS256,typ:JWT}的 Base64URL否可解碼第 2 段payload聲明claimssub、exp、iat、role等否可解碼第 3 段signature對(duì)前兩段的簽名是但不可逆只能驗(yàn)不能解簽名的計(jì)算方式是signature HMAC-SHA256( base64url(header) . . . base64url(payload) , secret )三個(gè)關(guān)鍵推論也是后面所有坑點(diǎn)的來源payload 不是加密的。把密碼、手機(jī)號(hào)、身份證號(hào)放進(jìn) payload等于把它們貼在瀏覽器地址欄上。Base64 不是加密。簽名只保證「沒被改過」不保證「沒被別人看過」。需要保密就上 JWEJSON Web Encryption或者干脆別把敏感數(shù)據(jù)放進(jìn)去。驗(yàn)證必須用「先重算再定長(zhǎng)比較」的方式而且重算時(shí)用的算法必須是服務(wù)端白名單里的算法絕不能聽 header 里alg字段的。Base64URL 是 Base64 的變體換成-、/換成_并去掉末尾的填充以便安全地放進(jìn) URL、cookie 與 HTTP 頭。很多「令牌一放進(jìn) URL 就失效」的問題就是忘了這一步轉(zhuǎn)換。二、PHP 8.1 下用 hash_hmac 手寫簽發(fā)與校驗(yàn)不依賴庫的版本只有一百來行而且把每個(gè)校驗(yàn)點(diǎn)都擺在明面上?php declare(strict_types1); // 最低 PHP 8.1用到了 enum、readonly 屬性提升、never 返回類型、array_is_list final class JwtError extends RuntimeException {} /** 用戶角色枚舉讓「字符串寫錯(cuò)」在靜態(tài)分析階段就暴露 */ enum Role: string { case User user; case Admin admin; } /** * 解碼后的令牌載荷。 * 用 readonly 提升屬性PHP 8.1 特性注意不是 readonly 類——那是 PHP 8.2 才有的 * 保證拿到手后不會(huì)被意外改寫。 */ final class Claims { public function __construct( public readonly int $sub, public readonly Role $role, public readonly int $exp, public readonly int $iat, ) {} } /** * 支持的算法白名單alg 名字 hash_hmac 的算法名 * 只允許 HS 系列杜絕 RS256/HS256 混淆與 algnone */ const JWT_ALGS [ HS256 sha256, HS384 sha384, HS512 sha512, ]; function base64UrlEncode(string $raw): string { return rtrim(strtr(base64_encode($raw), /, -_), ); } function base64UrlDecode(string $seg): string { $pad strlen($seg) % 4; if ($pad ! 0) { $seg . str_repeat(, 4 - $pad); // 解碼前必須補(bǔ)回填充 } $raw base64_decode(strtr($seg, -_, /), true); if ($raw false) { throw new JwtError(base64url 段解碼失敗); } return $raw; } function jwtSign(string $signingInput, string $secret, string $alg): string { if (!isset(JWT_ALGS[$alg])) { throw new JwtError(不支持的算法$alg); } return hash_hmac(JWT_ALGS[$alg], $signingInput, $secret, true); } /** * 簽發(fā)令牌。$ttl 是有效期秒數(shù)。 */ function jwtIssue(int $userId, Role $role, string $secret, int $ttl 900, string $alg HS256): string { $now time(); $header [alg $alg, typ JWT]; $payload [ sub $userId, role $role-value, iat $now, nbf $now, exp $now $ttl, ]; $h base64UrlEncode(json_encode($header, JSON_THROW_ON_ERROR)); $p base64UrlEncode(json_encode($payload, JSON_THROW_ON_ERROR)); $signingInput $h . . . $p; return $signingInput . . . base64UrlEncode(jwtSign($signingInput, $secret, $alg)); } /** * 校驗(yàn)令牌。失敗一律拋 JwtError絕不返回 false 讓調(diào)用方自己猜。 */ function jwtVerify(string $jwt, string $secret, string $issuer my-app): Claims { $parts explode(., $jwt); if (count($parts) ! 3) { throw new JwtError(令牌格式不是三段); } [$h, $p, $s] $parts; $header json_decode(base64UrlDecode($h), true, 8, JSON_THROW_ON_ERROR); if (!is_array($header)) { throw new JwtError(header 不是對(duì)象); } // 關(guān)鍵算法只認(rèn)服務(wù)端白名單絕不信 header 里寫了什么 $alg $header[alg] ?? ; if (!is_string($alg) || !isset(JWT_ALGS[$alg])) { throw new JwtError(算法不在白名單內(nèi)); } $expected base64UrlEncode(jwtSign($h . . . $p, $secret, $alg)); // 關(guān)鍵定長(zhǎng)比較不能用 或 if (!hash_equals($expected, $s)) { throw new JwtError(簽名不匹配); } $payload json_decode(base64UrlDecode($p), true, 16, JSON_THROW_ON_ERROR); if (!is_array($payload)) { throw new JwtError(payload 不是對(duì)象); } $now time(); foreach ([exp 已過期, nbf 尚未生效] as $key $msg) { if (!isset($payload[$key]) || !is_int($payload[$key])) { throw new JwtError(缺少 $key 聲明); } if ($key exp $payload[$key] $now) { throw new JwtError($msg); } if ($key nbf $payload[$key] $now) { throw new JwtError($msg); } } if (($payload[iss] ?? $issuer) ! $issuer) { throw new JwtError(簽發(fā)方不匹配); } $role Role::tryFrom((string) ($payload[role] ?? )); if ($role null) { throw new JwtError(未知角色); } return new Claims( sub: (int) $payload[sub], role: $role, exp: $payload[exp], iat: (int) $payload[iat], ); }這里有幾個(gè)刻意的設(shè)計(jì)都是踩過坑之后才加的Role用枚舉而不是字符串。校驗(yàn)$claims-role Role::Admin時(shí)拼錯(cuò)角色名會(huì)直接報(bào)錯(cuò)而不是靜默地永遠(yuǎn)不成立。Claims用readonly類。令牌一旦驗(yàn)證通過中間件把它注入后續(xù)流程后任何一層都改不動(dòng)它避免「越權(quán)」來自內(nèi)部而不是外部。校驗(yàn)失敗一律拋異常。返回false的接口最容易被調(diào)用方寫成if (!$ok) { /* 忘了 return */ }那才是真正的漏洞。hash_equals()。它按位比較且耗時(shí)與內(nèi)容無關(guān)。用比較簽名會(huì)被時(shí)序攻擊timing attack逐字節(jié)爆破。三、接入 firebase/php-jwt 與框架中間件生產(chǎn)項(xiàng)目一般直接用庫省得自己維護(hù)加密細(xì)節(jié)。下面是 6.x 的 API 形態(tài)?php 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 $_ENV[JWT_SECRET] ?? ; // 密鑰只從環(huán)境變量來絕不寫進(jìn)代碼庫 // 簽發(fā) $token JWT::encode( [sub 42, role admin, iat time(), exp time() 900], $secret, HS256 ); // 校驗(yàn) try { $decoded JWT::decode($token, new Key($secret, HS256)); echo 通過sub . $decoded-sub . PHP_EOL; } catch (ExpiredException $e) { echo 令牌過期 . PHP_EOL; } catch (SignatureInvalidException $e) { echo 簽名無效 . PHP_EOL; } catch (Throwable $e) { echo 其他失敗 . $e-getMessage() . PHP_EOL; }注意6.x 里算法由Key對(duì)象帶進(jìn)來第三個(gè)參數(shù)$allowed_algs已廢棄5.x 的decode()簽名不同請(qǐng)以vendor/firebase/php-jwt/src/JWT.php里的實(shí)際簽名為準(zhǔn)不要照抄博客??蚣芾锿ǔ懗梢粋€(gè)中間件。核心邏輯只有三句從Authorization: Bearer xxx里取出令牌、校驗(yàn)、把Claims掛到請(qǐng)求屬性上。?php declare(strict_types1); /** * 認(rèn)證失敗直接終止請(qǐng)求。 * never 返回類型是 PHP 8.1 新增的它告訴靜態(tài)分析器「這個(gè)函數(shù)不會(huì)正常返回」 * 因而后面不會(huì)出現(xiàn)「忘記 return 導(dǎo)致繼續(xù)往下走」的漏網(wǎng)之魚。 */ function abortUnauthorized(string $reason): never { http_response_code(401); header(Content-Type: application/json; charsetutf-8); echo json_encode([error $reason], JSON_UNESCAPED_UNICODE); exit; } function authenticate(string $authorizationHeader, string $secret): Claims { if (!str_starts_with($authorizationHeader, Bearer )) { abortUnauthorized(缺少 Bearer 令牌); } try { return jwtVerify(substr($authorizationHeader, 7), $secret); } catch (JwtError $e) { abortUnauthorized($e-getMessage()); } }其中的str_starts_with()是 PHP 8.0 引入的。校驗(yàn)項(xiàng)必須做不做的后果簽名hash_equals()或庫的 verify令牌可任意偽造算法服務(wù)端白名單algnone、HS/RS 混淆攻擊exp與服務(wù)器時(shí)間比較令牌永久有效nbf/iat檢查是否提前生效時(shí)鐘漂移被利用iss/aud多系統(tǒng)共用密鑰時(shí)必查別家系統(tǒng)的令牌能登你的系統(tǒng)角色枚舉校驗(yàn)空角色被當(dāng)成普通用戶放行四、完整可運(yùn)行示例把下面這段存成jwt_demo.php直接運(yùn)行它會(huì)把「正常簽發(fā)、被篡改、已過期」三種情況都跑一遍?php declare(strict_types1); // 需要 PHP 8.1。上面第二節(jié)的類與函數(shù)請(qǐng)一并放進(jìn)這個(gè)文件。 $secret dev-only-secret-do-not-use-in-production; // ---------- 1. 正常簽發(fā)與校驗(yàn) ---------- $token jwtIssue(1001, Role::Admin, $secret, ttl: 900); echo 令牌$token\n; $claims jwtVerify($token, $secret); printf( 校驗(yàn)通過sub%d, role%s, 剩余 %d 秒\n, $claims-sub, $claims-role-value, $claims-exp - time() ); // ---------- 2. 篡改 payload把 role 改成 admin 或把 sub 改掉---------- $parts explode(., $token); $fake json_decode(base64UrlDecode($parts[1]), true, 16, JSON_THROW_ON_ERROR); $fake[sub] 1; $parts[1] base64UrlEncode(json_encode($fake, JSON_THROW_ON_ERROR)); $tampered implode(., $parts); try { jwtVerify($tampered, $secret); echo 嚴(yán)重問題篡改后的令牌居然通過了\n; } catch (JwtError $e) { echo 篡改被攔下{$e-getMessage()}\n; } // ---------- 3. 過期令牌 ---------- $expired jwtIssue(1001, Role::User, $secret, ttl: -10); try { jwtVerify($expired, $secret); echo 嚴(yán)重問題過期令牌居然通過了\n; } catch (JwtError $e) { echo 過期被攔下{$e-getMessage()}\n; } // ---------- 4. 換個(gè)密鑰再驗(yàn)?zāi)M密鑰輪換后的舊令牌是否還能用---------- try { jwtVerify($token, another-secret); echo 嚴(yán)重問題錯(cuò)誤密鑰居然通過了\n; } catch (JwtError $e) { echo 錯(cuò)誤密鑰被攔下{$e-getMessage()}\n; }預(yù)期輸出令牌eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... 校驗(yàn)通過sub1001, roleadmin, 剩余 900 秒 篡改被攔下簽名不匹配 過期被攔下已過期 錯(cuò)誤密鑰被攔下簽名不匹配常見坑點(diǎn)1. 用或比較簽名?if ($expected $signature) { ... }—— 字符串比較會(huì)在第一個(gè)不同字節(jié)處提前返回攻擊者可以按位試探簽名這是典型的時(shí)序攻擊。 ?if (hash_equals($expected, $signature)) { ... }定長(zhǎng)比較耗時(shí)與內(nèi)容無關(guān)。2. 直接相信 header 里的alg?$alg $header[alg]; $sig hash_hmac($alg, ...);—— 攻擊者把a(bǔ)lg改成none或者把 RS256 改成 HS256 讓你用公鑰當(dāng) HMAC 密鑰去驗(yàn)簽。 ? 只認(rèn)服務(wù)端硬編碼的白名單數(shù)組isset(JWT_ALGS[$alg])不通過就直接拒。3. 用普通 base64 而不是 base64url?base64_encode($payload)—— 輸出里的/放進(jìn) URL 或 cookie 會(huì)被轉(zhuǎn)義、截?cái)嗔钆茣r(shí)靈時(shí)不靈。 ? 編碼時(shí)strtr($s, /, -_)再去掉解碼時(shí)先把-_換回來并補(bǔ)上填充。4. 把敏感信息放進(jìn) payload? 把密碼哈希、手機(jī)號(hào)、訂單金額寫進(jìn) claims —— 只做了一次 base64任何抓包的人都能讀到。 ? payload 只放標(biāo)識(shí)和授權(quán)所需的最小信息用戶 ID、角色、過期時(shí)間敏感數(shù)據(jù)按需查庫。5. 只驗(yàn)簽名不驗(yàn)exp?JWT::decode()之后直接取出sub就用。庫的校驗(yàn)再全也擋不住你自己簽出exp缺失的歷史令牌。 ? 顯式檢查exp、nbf、iss、aud把exp當(dāng)作必填字段缺失即拒。6. HMAC 密鑰是短口令?$secret myapp2024;—— 這種密鑰可以被離線爆破一旦爆破成功攻擊者就能自己簽發(fā)管理員令牌。 ? 用bin2hex(random_bytes(32))生成密鑰存進(jìn)環(huán)境變量或密鑰管理服務(wù)并支持輪換同時(shí)接受新舊兩把密鑰一段時(shí)間。7. 以為「無狀態(tài)」就等于「不能登出」? 用戶點(diǎn)了退出前端刪掉令牌就完事服務(wù)端手里的舊令牌還能一直用到過期。 ? access token 設(shè)短幾分鐘到十幾分鐘配一個(gè)可撤銷的 refresh token需要即時(shí)失效就維護(hù)jti黑名單RedisTTL 設(shè)為令牌剩余壽命。8. 令牌放 localStorage 又忘了 cookie 的安全屬性? 前端把令牌存 localStorage任何一次 XSS 都能直接偷走或者放 cookie 但不設(shè)HttpOnlySecureSameSite。 ? 放 cookie 時(shí)設(shè)HttpOnly、Secure、SameSiteLax配合 CSRF 防護(hù)堅(jiān)持用 localStorage 就必須嚴(yán)格控制 XSS??偨Y(jié)環(huán)節(jié)做法要點(diǎn)令牌格式header.payload.signature 三段 Base64URLpayload 不保密簽名HMAC-SHA256 hash_hmac()密鑰隨機(jī)且長(zhǎng)比較hash_equals()防時(shí)序攻擊算法服務(wù)端白名單防algnone與算法混淆聲明exp/nbf/iss/aud全查缺一即拒有效期access 短 refresh 可撤銷兼顧安全與體驗(yàn)傳輸cookie 加HttpOnly/Secure/SameSite防 XSS 與 CSRFJWT 本身并不復(fù)雜坑幾乎全在「校驗(yàn)環(huán)節(jié)偷懶」上。把簽名比較換成hash_equals()、把算法固化成白名單、把exp當(dāng)成必填項(xiàng)、把密鑰挪進(jìn)環(huán)境變量——這四件事做完P(guān)HP 8.1 下的 JWT 認(rèn)證就比大多數(shù)出過事的項(xiàng)目更穩(wěn)。如果你的服務(wù)沒有跨端、跨域、無狀態(tài)橫向擴(kuò)展的需求傳統(tǒng)的服務(wù)端 Session 往往更簡(jiǎn)單也更安全。