昨天的最後一步,我們應用程式寫了一段程式碼,產生了身分驗證請求,然後讓瀏覽器轉導去 Hydra。
當瀏覽器到了 Hydra 後,會檢查過去是否有之前成功登入的 session(「記住我」的功能)。除此之外,還會處理 id_token_hint
、prompt
、max_age
等相關參數。
如果是第一次登入的話,使用者會被轉導到 Login Provider。這個路徑將會採用 Hydra 設定檔的 urls.login
。舉例來說,下面是設定與導頁的實際例子:
urls:
login: http://127.0.0.1:8000/oauth2/login
# ---
http://127.0.0.1:8000/oauth2/login?login_challenge=1234
要特別注意的是,不管使用者過去有沒有成功登入的 session,Hydra 都會一律將使用者轉導至 Login Provider,這個原因是為了讓開發者有辦法在這個節點做對應的記錄或處理。
因為接下來會需要呼叫 Hydra 的 API,都是單純的 HTTP request,不過官方有提供 SDK,用起來會比較方便,從 SDK 也比較能清楚了解 Hydra 的流程。
PHP + Composer 可以使用下面的指令安裝 SDK:
composer require ory/hydra-client
Hydra 的登入請求會多帶一個參數為 login_challenge
。這個參數是用來讓 Login Provider 控制登入流程的,包括啟動登入、同意登入(Accept Login)、拒絕登入(Reject Login)這三個行為。
啟動登入是由 Hydra 觸發並轉導至 Login Provider,接著 Login Provider 可以使用 login_challenge
的值,來呼叫 Hydra 所提供的 API,取得身分驗證請求相關的資訊。
Laravel + Hydra SDK 程式碼範例如下:
Route::get('/oauth2/login', function (Request $request, AdminApi $adminApi) {
// ...
$loginChallenge = $request->input('login_challenge');
$loginRequest = $adminApi->getLoginRequest($loginChallenge);
// ...
return view('auth.login');
})->name('oauth2.login');
這裡為求版面簡單,所以有省略了很多程式碼。
$loginRequest
是一個資料物件,是用 JSON 轉換過來的,內容如下:
{
"challenge": "4ceb44c9aacf4aaa8eae367449c9419c",
"client": {"client_id": "my-rp", ... },
"oidc_context": [],
"request_url": "http://127.0.0.1:4444/oauth2/auth?client_id=my-rp&...",
"requested_access_token_audience": [],
"requested_scope": ["openid"],
"session_id": "e2feafe8-d3b4-40b4-87cc-c7dbd7e65e4b",
"skip": false,
"subject": ""
}
這裡面有非常多資訊可以提供做為要如何顯示登入頁的方法,但首先先看 skip
與 subject
這兩個資訊,因為它們跟登入主流程有關:
skip
代表的意義是 Hydra 之前已成功驗證過這個使用者,如果是 false
,代表使用者尚未驗證,這時 Login Provder 可以提供使用者帳號密碼輸入表單,或是其他身分驗證方法來提示使用者登入。如果是 true
,則不應該顯示任何介面,而是呼叫 Hydra API 來接受登入請求,並轉導到下一關。在這個步驟可以做更新使用者登入次數或相關記錄,甚至是跟登入相關的商務邏輯。但一樣,都不應該顯示任何介面。另外,如果 skip
是 true
的話,當然 subject
就會有值,是使用者 ID。
subject
是 OpenID Connect 裡的術語,指操作登入行為的主體。
再來就是其他欄位,分別詳述如下:
client
指的是 OAuth 2.0 的應用程式,裡面存放應用程式的相關資訊,Login Provider 可以根據這個資訊實作介面呈現。像使用 Facebook 第三方應用程式登入的時候,就會看到 Facebook 頁面會提醒是哪個應用程式要取得使用者資訊。
request_url
是應用程式最一開始發出身分驗證請求的內容。
oidc_context
指的是 OpenID Connect Core 1.0 - 3.1.2.1. Authentication Request 所定義的相關欄位內容,如 ui_locales
等。這些內容都可以從 request_url
解析出來,不過 LoginRequest
物件已經預先解析完放在這個欄位裡了。但 Hydra 也不是所有欄位都會解析,可以參考原始碼的 struct 設計。
requested_access_token_audience
這個是 RFC 8693 - OAuth 2.0 Token Exchange 2.1. Request 所定義的欄位。Token Exchange 是定義如何取得 Security Token 的機制,而 audience
指的是 Token 的接受者。
requested_scope
是應用程式請求授權的範圍,也就是 OAuth 2.0 所提到的授權範圍,這在之後實作 Consent Provider 將會使用到。
session_id
是每次啟動登入時,都會有一個唯一識別碼來代表這個 session,未來在同個使用者授權不同的應用程式時,session_id
的值都會是相同的,除非執行登出後再換一個新的使用者。
通常只會有接受登入請求的情境,比較少有拒絕登入請求的情境。因為身分驗證系統一般都能允許多次嘗試密碼,或是執行忘記密碼流程等,因此一個登入請求通常是能完成身分驗證到接受請求的。
這裡就不討論如何做身分驗證了,假設當系統對使用者做完身分驗證,確認使用者的唯一識別碼之後,接著 Login Provider 就能將 login_challenge
設定為已登入,並進入下一個流程。
下面是接受登入請求的程式碼片段:
// 參考原本 Breeze 的身分驗證程式碼後,可以改寫出下面的內容
if (!Auth::once($request->only('email', 'password'))) {
return Redirect::back();
}
$user = Auth::user();
// 帳密正確並取得使用者的資訊後,執行下面的程式碼
$acceptLoginRequest = new AcceptLoginRequest([
'context' => new stdClass(),
'remember' => $request->boolean('remember'),
'rememberFor' => 0,
'subject' => (string)$user->getAuthIdentifier(),
]);
$completedRequest = $adminApi->acceptLoginRequest($loginChallenge, $acceptLoginRequest);
return Redirect::away($completedRequest->getRedirectTo());
裡面有四個欄位,說明如下:
欄位 | 說明 |
---|---|
context |
這是什麼呢?先賣個關子。 |
remember |
如果設定為 true,這次驗證成功的 session 將會由 Hydra 的 HTTP Response 來要求瀏覽器儲存 cookie 資訊。未來相同的使用者請求,skip 將會是 true 。 |
remember_for |
Cookie 的時效,當 remember 是 true 的時候,這個設定才會有作用。 |
subject |
驗證後的使用者 ID,如果 skip 是 true 的話,這裡必須要代入 challenge 所拿到的 subject 。 |
注意這裡有個地方,會跟開發者預期不同:PHP SDK 的 Model 欄位是 camelCase,如
rememberFor
但實際上 API 是 snake_case,如remember_for
。這在看 Swagger 的時候,有可能會搞錯然後把錯誤的值輸入到 Model,造成執行結果與預期不同。後續這個狀況,在說明欄位的時候一律以 API 上面的欄位為準,方便不同語言的人可
下面則是拒絕登入的範例:
$rejectRequest = new RejectRequest([
// 這裡要帶入 OAuth 2.0 所定義的 ERROR ID,如 `login_required` 或是 `invalid_request`
'error' => '...',
// 錯誤的詳細內容
'errorDescription' => '...',
]);
$completedRequest = $adminApi->acceptLoginRequest($loginChallenge, $rejectRequest);
return Redirect::away($completedRequest->getRedirectTo());
RejectRequest
的內容會回傳到應用程式的 callback,欄位定義跟 RFC 6749 - The OAuth 2.0 Authorization Framework 4.1.2.1 Error Response 欄位定義相同,因此要注意 error
欄位必須要填入 OAuth 2.0 所定義的 ERROR ID,而其他欄位則可以自由發揮。
額外一提的注意事項是,acceptLoginRequest()
或 rejectLoginRequest()
的 API,一個 login_challenge
只會接受一次,當發生重送攻擊(Replay Attack)的時候,Hydra 會回應 410 Gone
,代表這個 Login Request 曾經存在過,但現在不能再使用了。(過去的版本是回 409 Conflict
請求衝突,原因可以看 PR 說明。)
這次的程式碼調整比較複雜,可以參考 GitHub Commit。程式碼完成後,可以再重新把服務起動並輸入帳號密碼,成功後應該就會看到 Consent Provider 的頁面了。