今天先補充 ID Token 的驗證實作程式。
驗證核心原則,是參考 RFC 7519 - JSON Web Token (JWT),雖然規範上都有完整說明該如何做事,但建議還是直接參考其他人寫好的套件比較好。jwt.io 上提供有各語言的套件資訊可以參考。
以下會使用 PHP 語言 PHP JWT Framework 來實作,首先先使用 composer 安裝:
composer require web-token/jwt-framework
安裝好後,先把 Token 準備好如下:
eyJhbGciOiJSUzI1NiIsImtpZCI6InB1YmxpYzpoeWRyYS5vcGVuaWQuaWQtdG9rZW4iLCJ0eXAiOiJKV1QifQ.eyJhdF9oYXNoIjoiVTMxN2xqVE5zMTBBV3Z3bmgwUW9IZyIsImF1ZCI6WyJteS1ycCJdLCJhdXRoX3RpbWUiOjE2NjQzNTE0MzcsImV4cCI6MTY2NDM1NTAzOSwiaWF0IjoxNjY0MzUxNDM5LCJpc3MiOiJodHRwOi8vMTI3LjAuMC4xOjQ0NDQvIiwianRpIjoiMjEwZWRhNDMtZjhjNy00NWEzLWEyNWItYTJiZDkyMjcyOTcxIiwicmF0IjoxNjY0MzUxNDMxLCJzaWQiOiI3MTViNjNjNC0wZjJhLTRmZTUtOGQyOC1lODMwZGQzNjEzNDkiLCJzdWIiOiIxIn0.sM3A5wLTUBE1DlDAqfcF8pSUNU74AaP2GDaJ_gLLEV_uh-tG6b6B8e58fxH1vRmjtTE6aJG-chG98di12paDD8tV6JXAcuyk3p5Tw_OZnCcFVEvAYoP_m8BYhwGK7DfmiWCZVMTHLX2AFd3J93XR7tems-xV1ponXQQaPDIVKjEVe3k9aoPkgKFj-8CabMwq-aVzKmq0Zr940tynWUTbu_ylSdnnvw68q74zmL6mwhGaDQ0DKTza-fDy_dp_Ht1QujIa-aSrLLkWm9MP0zzPCiZ1YYv-cpx3xb-tpoJm3bNu1h6XMGCjsoPtBOgQzESqojea0H2Cc7DsnDlezIVKM2OphF0_REy5ljBIA3zmbN0arbVVozN2p68YHX8YVp121yWpLycw1DA9eXdu2xp4I8N2Vmng9sez7KT8vKT9vCzFJgqTNG5Jhrv2VH7F4IXzjmbLD3bQW9HAtI_SGwSZ9FsQFUifxxgs1L9FFGb_pyc9arfoTqJnIaZgfsPMOQ6_4S4GlNwvX9YugOugecQfnfXX17ddNHtU7_YOkAAccZVaG5xQZEDtnggrtOVhBKebt5oHFLNDk44eK6GzD1yV9RtO0g0IK6OTt_IFN6gcEPo5d-QTphLxpYecLSvcYciI5k_wx-RpwZdJM1IU-JHqMUdHHm-DpOPCHzTAuXu894w
因為驗證流程會需要通過一連串的使用者操作流程,在最後才能確認 JWT 是否合法,這是很不方便的,因此會建議使用指令驗證。
php artisan make:command JwtCheck
它會產生一個 PHP 檔,放在 app/Console/Commands/JwtCheck.php
這裡,接著在編輯內容:
class JwtCheck extends Command
{
protected $signature = 'jwt:check {jwt}';
protected $description = 'Check JWT';
public function handle()
{
$jwt = $this->argument('jwt');
$this->line('HELLO!! ' . $jwt);
return 0;
}
}
實際執行結果如下:
❯ php artisan jwt:check iamtoken
HELLO!! iamtoken
接著我們就能在 handle()
裡使用 $jwt
變數來測試驗證了。
PHP 這個套件有將 RFC 上所提的很多任務抽象化,因此使用起來會有點麻煩,但如果對著 RFC 看的話,就會理解為什麼會這麼設計。
首先 JWT 的標準分做五個部分如下:
JWT 定義了抽象內容與使用的方法,也就是我們平常在討論的 JWT。而下面有兩種實作形式為 JWS 簽章版與 JWE 加密版,這兩個標準是在說明要如何把 Claim 和金鑰組合成簽章或密文,絕大多數的使用情境都是用 JWS,等下也是要使用這個。最後 JWK 是定義金鑰的格式,以及 JWA 定義演算法要在金鑰上如何呈現的客製化格式。
從這裡,可以知道我們需要使用的主要是 JWS 與 JWK 相關的程式,相關的 class 如下:
Jose\Component\Signature\JWS
Jose\Component\Core\JWK
剛好也對應到我們目前手上的資訊,一個是 Token,可以產生對應的 JWS 物件;另一個是公鑰,可以產生對應的 JWK。
首先我們先準備 JWK,先把之前取到的 JSON 拿出來,然後產生JWK 物件
$jwkJson = '{
"use": "sig",
"kty": "RSA",
"kid": "public:hydra.openid.id-token",
"alg": "RS256",
"n": "3fLBH5AZuoJurOEDA8_MAodU9slUs7AQaeus3C6C7JdSpo7JjgyNMgNV5Fnu53gQlY3Pr5ZyWpfmzJwIFRLrfvT-iQcktXjnZIcFvkX67nAwoUiqBoppprQyTju56ZxrAZnLLr8CYpaDKIjrJkFQw5BWX2X00DIo_YjG_2AJkdlxGuCtFhaUl0VpPr7PmVTxroscagtWdRbb6bitwlkcyc-0ESP2NRIWp2erQ5FJeigPtyGfqSpXUAFbgfz3-koTBpcyf73FRc3BqkuOmAsUJWHl-7s9u8pDK_H9dq-Cg_hWqGohWc_oaA0_01-um647xkMvm4FLA4UH-h1pOiZoL5hyqNGF3FRcBoOLJcFqb4P3zq22sW28dluEEht2_WV3nxAHttHD3Sxbq4uMtjVucBjTwS8x4EVUvipqQ8z-jV386v9bG2xvx6KgUEMyPOsSAYI6ww6HDrlDHBXi1Fr0x7b9bPvlJe9MtLEvFTMe8UgmrcXOJO-xu4EN5HwH6wtnnnsYuw-0duiLL0mvE0AeXZurQy_u_vbh-thkTLkdQFBY93cY3yLcp0sll2FpXSrGNtZddX3x4yIDMQLbYqUzybiVbsohhu7xSYowTX77xIZobGxnuNpbGa857RD9zox9ugSh59Yq9qr4TC2DLAunXQEaalijUjr4sYIV6NCtrRk",
"e": "AQAB"
}';
$jwk = JWK::createFromJson($jwkJson);
再來 JWS 就比較麻煩了,從 JWS 的建構子裡,看起來並不像是直接把 Token 轉換成 JWS 物件:
public function __construct(
private readonly ?string $payload,
private readonly ?string $encodedPayload = null,
private readonly bool $isPayloadDetached = false
)
這代表,它有別的類別負責實作產生 JWS 物件,是下面這兩個:
Jose\Component\Signature\JWSBuilder
Jose\Component\Signature\JWSLoader
這兩個的差異在於,Builder 是發 Token 用的,因為它有個方法跟產生簽章有關 addSignature();Loader 是收 Token 用的,因為它有個方法跟驗證簽章相關 loadAndVerifyWithKeySet()。再來繼續看它的建構子:
public function __construct(
private readonly JWSSerializerManager $serializerManager,
private readonly JWSVerifier $jwsVerifier,
private readonly ?HeaderCheckerManager $headerCheckerManager
)
其中 SerializerManager 存放序列化的方法,也就是如何把 JWT 裡面一大串資訊轉換成字串。至於為什麼要 Manager?因為 JWT 有定義三種序列化方法,這也是比較少人知道的冷知識。
Verrifier 的用途就很明確,而它依賴了 AlgorithmManager,裡面放了各種驗證簽章的方法。為什麼這個也是 Manager?因為 JWT Header 上有定義演算法,在這裡就可以根據 Token 類型選擇適當的演算法,以這次的 ID Token 為例,是 RS256
。
HeaderChecker 指的是要額外檢查固定 Claim 或其他中繼資訊的內容;而第二個參數 TokenTypeSupport 則是指要支援 JWS 還是 JWE,這裡得填 JWS。這次的場景我們不需要這個參數,因此給 null 即可。
了解了這些後,我們就可以開始來寫程式:
$loader = new JWSLoader(
new JWSSerializerManager([
new CompactSerializer(),
]),
new JWSVerifier(new AlgorithmManager([
new RS256()
])),
null,
);
$jwk = JWK::createFromJson($jwkJson);
$jws = $loader->loadAndVerifyWithKey($jwt, $jwk, $signature);
到這裡為止,已經完成了驗證簽章。再來要開始驗證 Claim 內容,可以使用 ClaimCheckerManager 來處理,這裡使用 ClaimCheckerManagerFactory 來處理:
$claimCheckerManagerFactory = new ClaimCheckerManagerFactory();
$claimCheckerManagerFactory->add('aud', new AudienceChecker('my-rp'));
$claimCheckerManagerFactory->add('exp', new ExpirationTimeChecker(10));
$claimCheckerManagerFactory->add('iat', new IssuedAtChecker(10));
$claimCheckerManagerFactory->add('iss', new IssuerChecker(['http://127.0.0.1:4444/']));
$claimCheckerManager = $claimCheckerManagerFactory->create(['aud', 'exp', 'iat', 'iss']);
$claimCheckerManager->check(json_decode($jws->getPayload(), true));
程式完成之後,指令執行就會出現錯誤:
Jose\Component\Checker\InvalidClaimException
The token expired.
這是因為最一開始提供的 Token 是 3 天前產的,但 Token 時效預設只有 1 小時,所以出現這個錯誤是正常的。我們試著把指令調整一下
protected $signature = 'jwt:check {--ignore-time-checker} {jwt}';
然後把產生 $claimCheckerManager 的方法調整如下:
if ($this->option('ignore-time-checker')) {
$claimCheckerManager = $claimCheckerManagerFactory->create(['aud', 'iss']);
} else {
$claimCheckerManager = $claimCheckerManagerFactory->create(['aud', 'exp', 'iat', 'iss']);
}
接著執行指令的時候加上這個參數,即可忽略時間的檢查:
php artisan jwt:check --ignore-time-checker iamtoken
執行完只要沒噴錯,就算完成了。
這段程式可以知道,關鍵的簽章驗證和 Claim 驗證都有寫在裡面了,再來就是將上面的程式模組化並在接受身分驗證回應的時候再檢查即可。這段屬於重構的過程,就不特別說明了,請大家參考原始碼 Commit。
感謝麥哥, 我會學習的
好多番號(疑