前言

在寫這篇文章的前兩天,原本工作正常的 Twitter 登入忽然變得異常的慢,甚至會逾時,在調查的過程中一直難以確認究竟是自己改了什麼,OS 網路層的問題,或是 Twitter API 異常所導致的,但那 Twitter API Status 卻始終顯示「沒問題 👍」:

Twitter API Status

Twitter 大爺說它沒問題,那就是我有問題…為了調查原因做了一系列工作,包括新建一個 POC 專案來驗證登入流程、改 OS(以及建立開發環境)測試等,最終證明真的是 Twitter API 異常 😙,為此付出了整整兩天時間…坑爹啊。


OAuth2 是當前公認的身份認證(authentication)與授權(authorization)標準,常見的應用場景是某個 app 讓用戶可以用既有的 Google / Facebook / Twitter / Apple / LINE 等帳號登入該 app,這樣做的好處有:

對用戶來說:

  • 用戶不用為 app 建一組以後會忘記的帳號密碼
  • 用戶可以控制要授予 app 取得哪些 Google 帳號下的資料與權限

對 app 來說:

  • 降低用戶的註冊門檻、增加用戶的使用意願
  • App 可以開發與 Google API 整合的應用,豐富 app 的使用場景

OAuth2 的那些 Flow

OAuth2 設想了多種的應用場景,除了上面提到的第三方登入,還有服務對服務的認證授權、穿戴設備(無法打帳密)的認證授權等,OAuth2 將各種授權認證流程定義出許多的 grant type,總覽如下:

OAuth Grant Types
來源:OAuth Grant Types

這些稱為 grant type 的流程,有許多稱呼,有時被稱為「scheme」更通俗的說法是「flow」,以 implicit grant type 為例,既可以叫 implicit scheme,也可以叫 implicit flow,下文皆以 flow 稱之(英文小技巧:可以用簡單的形式表達的就不用複雜的形式表達,除非你是莎士比亞)。

上圖中,被列入「Legecy」的,指的是因為流程的安全設計不夠嚴謹而不再被推薦使用的 flow。

偷偷查一下 legecy 的意思:

legecy

來源:劍橋詞典

「Legecy」之中的 password flow 講白了就是「你各位給我 Google 帳密,我去跟 Goole 做認證授權」,這種令人不安心的方式,被淘汰是理所當然的…。

而另一個也被列於「Legecy」的 implicit flow,雖然也是 legecy,但最常用的 authorization code flow 是以 implicit flow 為基礎強化的,所以往往提到 authorization code flow 的文章,也都會從 implicit flow 介紹起,本文也不例外。

OAuth2 Implicit Flow

再偷偷查一下 implicit 的意思:

implicit
來源:劍橋詞典

不懂它的明白沒關係,直接從流程圖上手:

OAuth2 Implicit Flow

看起來很簡單,因為這是簡化過的流程圖,省略了資料拋接要帶的參數,較完整的說明請見下文:

1. 用戶在 app 按下「登入」,被導入到 OAuth 服務的登入頁。

一個典型的導向 OAuth 登入頁的網址如下:

https://authorization-server.com/authorize?
  response_type=token
  &client_id=IfPrlAnhL-PhLa1qffmrRBxI
  &redirect_uri=https://ccc.app/auth
  &scope=photo
  &state=hodYP_CTNRJoIK4P

參數說明:

參數說明
response_type在 implicit flow 中,固定放 token,在其他 OAuth2 flow 中有可能是放別的值。
client_id通常是事先向 OAuth2 服務業者事先註冊取得的 app ID。如果 OAuth2 server 也是自幹,那就前後端團隊自行定義取得共識就好。(此點可能是導入 OAuth2 最困難的所在)
redirect_uriOAuth2 server 那端的認證授權完成後,要把用戶導回來的 callback URI,此 URI 用於接回用戶以及用戶的 token,在步驟四會再提到。往往實務上此 URI 也是要事先在 OAuth2 服務業者那邊先註冊過,不給你亂指定的。
scopeapp 要取得之授權範圍,內文依照 OAuth2 服務業者訂定的格式填入。如果 OAuth2 server 也是自幹,那就前後端團隊自行定義取得共識就好。(此點可能也是導入 OAuth2 最困難的所在)
state一組自定義亂數字串,在此送往 OAuth2 server,在 redirect_uri 接回用戶與參數時,OAuth2 server 也應該要帶上同樣的 state 字串,讓 app 得以透過 state 確認 session 的一致性,因為 HTTP 協議是無狀態的。也可確認回應真的是來自正確的 OAuth2 server。

上面這些參數是 implicit flow 的基本款,某些業者(例如微軟和 Google)還會根據他們的需求,要求更多的參數。

2. 用戶輸入帳密登入,被跳轉到授權同意頁。

3. 行禮如儀的問「要分享XX給 ccc app 嗎?」,取得用戶同意。

「要分享XX給 ccc app 嗎?」的XX取決於我們前面送的 scope,實際上 OAuth2 業者都會要求要事先審查,否則大多只能要求到帳號名稱和信箱這些超基本資訊。如果沒有事先通過審查,就直接在 scope 亂討授權,那被導過去的用戶會看到一個糟糕的錯誤訊息,嚴重影響用戶體驗:

Twitter Error

4. 同意後用戶被導回 app,並在導回網址上附加 token 與其他參數。

一個典型的導回網址與參數如下:

https://ccc.app/auth#
  state=hodYP_CTNRJoIK4P
  &access_token=Ll8MyHHO3G6DRs5cHMyErDH0zrBtPG-JIehnvnJ84cpvMgIbW_YdZr9Ov-aByi4doGmEgVLi
  &token_type=Bearer
  &expires_in=86400
  &scope=photos

要稍微注意的是,參數皆是位於 # 起頭的 fragment 區段內,而非常見的以 ? 起頭的 query 區段內。

參數說明:

參數說明
state意義同前述。此時我們的 ccc app 應該檢查兩次往來的 state 是否一致。
access_token代表用戶的 token,此 token 為往後 app 發往後端 API 時做為令牌之用。對後端來說,如果是自幹 OAuth2 server,發行此 token 時建議以 JWT 格式發行,才可以直接對 app 送來的 token 做時間與簽章驗證,否則又要回歸到比對 session ID 的老路了。
token_type在 implicit flow 中,固定是放 Bearer,在其他 OAuth2 flow 中有可能是放別的值。
expires_intoken 的有效秒數,過期後,後端就不認可此 token 了,得重新要過,重新要的機制,在 implicit flow 的設計上,是不允許在有效期內「拿 token 換 token」的(refresh token,此處的「refresh」是動詞),得搭配其他認證機制才可以拿到新 token,最擾民的作法當然是登出用戶把流程從頭來過,但應該是沒人會這麼做啦…。(其他的 OAuth2 flow 另有 refresh_token(此處的「refresh_token」是名詞)的存在,在此不表。)
scope意義同前述。

5. App 透過網址上附帶的參數取得 token,對 token 做驗證,把 token 存到 local storage 備用。

此處的驗證即前項之 state 一致性驗證。

6. 往後 app 就可以用 token 當令箭向後端其他的 API 存取該用戶授權的資料。

App 可以在向 API 端點發送請求時在 HTTP header 的 Authorizatoin 欄位填入 access token 作為認證之用:

Authorization: Bearer Ll8MyHHO3G6DRs5cHMyErDH0zrBtPG-JIehnvnJ84cpvMgIbW_YdZr9Ov-aByi4doGmEgVLi

Implicit Flow 的安全問題

Implicit flow 最大的問題是在上面的第四步,access_token 是直接放在網址內,而網址有許多人或物都讀得到:

  • 😇 用戶讀得到
  • 😈 隔壁老王斜眼也讀得到
  • 😇 瀏覽器讀得到
  • 😈 瀏覽器的 add-on / extension 也讀得到
  • 😇 我們的 web app 讀得到
  • 😈 我們的 web app 的前端套件也讀得到

在瀏覽器,隨便一行 JavaScript 就讀得到:

let access_token = new URLSearchParams(window.location.hash).get('access_token');

我們可以採取一些措施彌補這個缺陷:

  • 發送有效期超短的 token,例如一個小時,搭配不擾民的刷新機制,就算偷到了也不能幹嘛
  • 在資料拋接加入更多的防盜欄位,微軟好像就是這樣做的
  • 在環境可控的範圍內使用,本文多次提到「自幹 OAuth2 server」是其來有自

以上都是治標不治本,因為 implicit flow 不夠安全,現在都建議改用 authorization code flow,關於 authorization code flow,請敬待後續分享。

客戶端實做

在前面的六大步驟中,有幾個必須是由我們的 web app 實做的,瀏覽器這邊當然有現成的套件可以使用,我們選用的是 Mulesoft Labs 的 client-oauth2 套件。

安裝

npm install client-oauth2

建立 OAuth2 Client 實例

針對一個特定的 OAuth2 server 建立 OAuth2 client 與填入基本端點資料:

import ClientOAuth2 from "client-oauth2";

const implicitAuth = new ClientOAuth2( {
	clientId: 'IfPrlAnhL-PhLa1qffmrRBxI',
	authorizationUri: 'https://authorization-server.com/authorize',
	redirectUri: 'https://ccc.app/auth',
	scopes: ['photo'],
	state: 'hodYP_CTNRJoIK4P',
} );

這個 OAuth2 client 實例可以幫我們組出上節中第一步驟中要給用戶登入的連結:

const implicitAuthUri = implicitAuth.token.getUri();

把這個 implicitAuthUri 綁定到一個按鈕上讓用戶點過去即可。

取得用戶 Token

用戶在 authorization-server.com 登入成功後,會跳轉到我們指定的 redirectiUri 頁面,我們在此往頁面內呼叫實例的 getToken() 函式取得 token:

const uri = window.location.href // https://ccc.app/auth
let token;

window.onload = async () => {
  token = await implicitAuth.token.getToken( uri );
}

getToken() 會幫我們檢查 redirectUristate 是否與前後一致。

保持 State

用戶在登入的流程中經歷了網域的跳轉(ccc.app → authorization-server.com → ccc.app),session 已斷,如何保持 state 值是個問題,開新視窗或許是個方法,但瀏覽器會擋,或者是把 state 的值存在用戶的 local storage 內也是一招。

保存 Token

取得 token 後,把 token 保存在 local storage 備用:

localStorage.setItem("token", token.accessToken);

使用 Token

往後想要調用 authorization-server.com 上面其他的 API,就得在 header 附上 token 作為令牌:

const token = localStorage.getItem("token");
let userName;

window.onLoad = async () => {
  userName = await fetch(
    'https://authorization-server.com/users/name',
    { headers: { Authorization: `Bearer ${token}` } }    
  );

  localStorage.setItem("userName", await userName.json());
};

OAuth2 Playground

想要玩玩 OAuth2 的朋友可以用下面的幾個 playground 試試: