iT邦幫忙

2022 iThome 鐵人賽

DAY 11
2
Software Development

30 天與九頭蛇先生做好朋友系列 第 11

實作 Login Provider

  • 分享至 

  • xImage
  •  

昨天的最後一步,我們應用程式寫了一段程式碼,產生了身分驗證請求,然後讓瀏覽器轉導去 Hydra。

當瀏覽器到了 Hydra 後,會檢查過去是否有之前成功登入的 session(「記住我」的功能)。除此之外,還會處理 id_token_hintpromptmax_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

其他語言的 SDK

處理登入請求

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": ""
}

這裡面有非常多資訊可以提供做為要如何顯示登入頁的方法,但首先先看 skipsubject 這兩個資訊,因為它們跟登入主流程有關:

skip 代表的意義是 Hydra 之前已成功驗證過這個使用者,如果是 false,代表使用者尚未驗證,這時 Login Provder 可以提供使用者帳號密碼輸入表單,或是其他身分驗證方法來提示使用者登入。如果是 true,則不應該顯示任何介面,而是呼叫 Hydra API 來接受登入請求,並轉導到下一關。在這個步驟可以做更新使用者登入次數或相關記錄,甚至是跟登入相關的商務邏輯。但一樣,都不應該顯示任何介面。另外,如果 skiptrue 的話,當然 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 的時效,當 remembertrue 的時候,這個設定才會有作用。
subject 驗證後的使用者 ID,如果 skiptrue 的話,這裡必須要代入 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 的頁面了。


上一篇
準備並整合登入和授權頁面
下一篇
實作 Consent Provider
系列文
30 天與九頭蛇先生做好朋友23
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言