iT邦幫忙

2022 iThome 鐵人賽

DAY 11
2
Software Development

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

實作 Login Provider

  • 分享至 

  • xImage
  •  

昨天的最後一步,我們應用程式寫了一段程式碼,產生了身分驗證請求,然後讓瀏覽器轉導去 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

其他語言的 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": ""
}

這裡面有非常多資訊可以提供做為要如何顯示登入頁的方法,但首先先看 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 的頁面了。


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

尚未有邦友留言

立即登入留言