昨天實作的內容是登出流程,這個指的是使用者「主動」按下登出的過程。因為登入應用程式時,是透過 Session ID 來標識相同裝置的,因此即便使用者登入很多不同的應用程式,Hydra 還是能透過 Session ID 查到這些資訊。
但,不同裝置就無法透過這個方法來實現了。比方說使用者有 Mac 與 Windows 兩台電腦,則兩台電腦都完成登入後,Mac 登出是不會影響 Windows 的,因為這兩台裝置所記錄的 Session ID 是不同的。
假想下面幾個情境
兩個問題很相似,但有點不同,一個是針對特定裝置的登入狀態移除,另一個是移除所有登入狀態。這次 30 天鐵人賽只說明後者,把所有登入狀態清除。而移除特定裝置的登入狀態,就看還有沒有力氣寫第 31 天了。
昨天的 Backchannel 其實就是清除應用程式端的登入狀態的方法了,只是昨天的情境是 Hydra 主動發出,實際上 Back-Channel Logout 有定義另一個欄位是 sub
。
這個欄位跟 sid 是互有關係的,下表是這兩個欄位相互組合的意義:
sub | sid | 說明 |
---|---|---|
沒有 | 沒有 | 不合法的 Token |
沒有 | 有 | 清除指定 Session 的狀態 |
有 | 沒有 | 清除指定使用者的狀態 |
有 | 有 | 清除指定使用者,且指定 Session 的狀態。 |
從這個表可以了解,其實如果要清除應用程式端的狀態,只要丟一個帶有 sub
欄位的 Logout Token 就行了。但因為 Hydra 沒實作這件事,所以只能自己來產 JWT,還好 Hydra 有提供取 JWK 的 API:PUT /keys/{set}/{kid}
,從 DB table hydra_jwk
可以查得到,set 是指 hydra.openid.id-token
,而 kid 就是指 private:hydra.openid.id-token
了。會這麼選擇的原因是,Back-Channel Logout 協定裡有提到驗證簽章的方法跟 ID Token 一樣;另外就是,等下是要實作簽章而不是驗證,因此需要私鑰。
$response = $admin->getJsonWebKey('private:hydra.openid.id-token', 'hydra.openid.id-token');
$privateKey = $response->getKeys()[0]->jsonSerialize();
再來是實作簽章的程式,之前驗證的套件有大概看過,這次要用 JWSBuilder。整體寫起來如下:
private function buildLogoutToken(array $privateKey, string $client, string $sub): string
{
$jwsBuilder = new JWSBuilder(new AlgorithmManager([
new RS256(),
]));
$jws = $jwsBuilder
->withPayload(json_encode([
'aud' => [
$client,
],
'events' => [
'http://schemas.openid.net/event/backchannel-logout' => new stdClass(),
],
'iat' => time(),
'iss' => 'http://127.0.0.1:4444/',
'jti' => Str::uuid()->toString(),
'sub' => $sub,
]))
->addSignature(new JWK($privateKey), ['alg' => 'RS256',])
->build();
$serializer = new CompactSerializer();
return $serializer->serialize($jws);
}
JWSBuilder 要小心一個問題是,它是有狀態的物件,因此如果要產生新的 JWS 時,需要再使用 create()
方法清除狀態。上面因為 function 結束後,它的生命週期就結束了,所以沒有這個問題。
接著要取得所有應用程式,這個 Hydra 也有提供 API 了:
$clients = $admin->listOAuth2Clients()
最後就是每個應用程式都送出清除使用者狀態的請求就行了。
collect($clients)
->filter(fn(OAuth2Client $client) => !empty($client->getBackchannelLogoutUri()))
->each(function (OAuth2Client $client) use ($sub, $privateKey) {
$logoutToken = $this->buildLogoutToken($privateKey, $client->getClientId(), $sub);
$uri = $client->getBackchannelLogoutUri();
Http::post($uri, [
'logout_token' => $logoutToken,
]);
});
授權伺服器端的實作不難,困難的地方在應用程式端的實作。目前覺得最適合的作法如下:
sid
對應用程式 Session 的 mapping(以 Redis 來說,使用 Key Value 即可)sub
對 sid
的 mapping(以 Redis 來說,可以使用 SET)當清除 sid 的時候,除了第一個記錄要清外,第二個記錄也要清。
當清除 sub 的時候,使用第二個記錄把相關的 sid 找出來,就能把第一個記錄清掉。
當兩個資訊都有的時候,先從 sub 找 sid,有找到的話就照清除 sid 的步驟處理。
應用程式清除 Session 程式碼就先偷懶不實作了。
即便把應用程式的狀態清除,因為 Hydra 的登入狀態還在(remember 功能),因此在按下應用程式登入的時候,依然會被 skip 而跳過身分驗證流程,因此需要再呼叫 Hydra 的一個 API 來清除登入狀態:
$admin->revokeAuthenticationSession($sub)
程式碼可以參考 GitHub Commit。