Google Oauth 2.0 OpenID Connect (OIDC) 第三方登入實作

Elvin.C
13 min readDec 14, 2020

--

本文參考官方的 OIDC 指南,從應用服務提供者 (Client) 的角度記錄一下自己幫助理解的口語化筆記。

什麼是 OAuth 2.0

某天,你下載了一個新的 APP。這個 APP 要求你註冊會員,但也提供了 Google 及 Facebook 登入的選項。你點擊了 Facebook 登入,畫面跳轉到 FB同意頁面,並和你確認要提供給 APP 的資訊。你同意後,系統就自動幫你創建會員並完成登入了。

透過 Facebook 進行的 Social Login ,便建立在 OAuth 2.0 的規範之上。這套流程定義了整套互動中的角色,以及為了完成驗證與授權,每個角色所要做的事情。

而 OAuth 2.0 最主要的功能,就是提供一個便利且合理的授權與驗證方法,避免提供第三方過大的授權,以及防止第三方取得用戶不想分享的資訊。

前置動作

要實作任何一家 Social Login Provider 的登入前,都必須先在該網站註冊要使用登入功能的應用軟體。

Google 會透過 Google Cloud Platform (GCP) console 讓我們註冊用戶同意頁面與憑證。註冊完成後,GCP 會發給我們 Client ID & Client Secret,用來在 OAuth 2.0 的流程中證明我們服務的身份。

首先,我們來設定 OAuth 同意畫面:

  1. 進入專案頁後,在上方的「搜尋產品和資源」列中,查詢 「OAuth 同意畫面」並點擊。
  2. 進入頁面後,根據情境做設定。我的情境是要給外部使用者進行第三方登入的服務,所以申請外部使用者的同意頁面。
  3. 選取完後進入「OAuth 同意畫面」設定頁,需要填一些三方應用服務的基本資訊,必填部分包括應用名稱、用戶支援信箱與開發人員信箱三項,其他項目大多是呈現給用戶看的補充資訊,有需要再填寫即可。
  4. 儲存後進入「範圍」設定頁,我們需要在這邊設定要和用戶要什麼資料或權限,之後才能根據取得的 Access Token 操作對應的服務,或利用 IdToken 取得想要的用戶資料。在這裡我選擇了 OpenId 和 https://www.googleapis.com/auth/userinfo.profile 兩個權限,以便之後識別用戶並取得用戶的姓名等資料。
    (那個網址就是 google 的 scope 格式沒錯,我也很困惑為什麼要寫成這樣)
  5. 儲存後進入「測試使用者頁面」,這邊可以新增一些 Google 帳號作為測試用戶,他們可以在測試階段進行 OpenId 的操作,可以先新增自己和其他可能會一起測試的人。儲存後確認資訊,完成設定。

至此,Oauth 同意畫面設定完成!

接下來是憑證部分:

  1. 點擊 GCP console 左邊的「憑證」部分,點擊「建立憑證」>「建立 OAuth 用戶端 ID」,這部分我們會建立之後 API 需要的 Client ID & Client Secret。
  2. 選擇應用類型,我是以 Web Front-end + Backend Server 做開發,因此選擇 Web Application。選擇後設定自己辨識憑證組用的名稱,並設定 Redirect URI*。官方建議 Redirect URI 可以填一個打到 Server 的 Endpoint ,由後端取得 Authorization Code 或 Access token 後,再將 Request 重新轉發到不帶有資料的頁面。
  3. 設定完成後,就會跳出彈窗顯示 Client Id & Client Secret,可以先複製下來存到 .env ,或是之後再打開憑證頁存取。

*用戶同意授權後,Google 會將請求重定向,並以 Query 傳送 Authorization Code 的網址。

到這邊前置動作就算完成了,接下來進入實作部分。

取得 Authorization Code ( 以下簡稱 Code)

Google 提供的 Web Server OAuth 2.0 流程圖

Code 是 Google 確認用戶本人同意授權後,發給我們兌換Access Token 的兌換碼。Code 除了用來換 Token 外沒有任何功能,而且無論有沒有兌換成功都會失效。

我們需要用 Google 指定的格式組成 URI ,將點擊按鈕的用戶跳轉至同意頁面,才能在 Redirect URI 收到 Google 回傳的 Code,繼續後續的認證步驟。

Google 有提供 API 文件,說明發起取得 Code 的請求時,需要提供的 Query Parameters。以下用粗體標示並簡單說明四個必填項:

https://accounts.google.com/o/oauth2/v2/auth?
client_id={client_id}&
redirect_uri={redirect_uri}&
response_type={code}&
scope=https://www.googleapis.com/auth/userinfo.profile openid&
include_granted_scopes=true&
state=pass-through value&
  • client_id:在 GCP 註冊同意畫面後取得。
  • redirect_uri:Google 回傳 Code 的 URI,注意 redirect_uri 在 GCP console 設定同意畫面時換取 CodeAccess Token 的請求時,三次的 redirect_uri 必須填寫相同值,否則請求一定會失敗。
  • response_type:指定回傳的 response,我們的認證流程適用 code。
  • scope:需要存取的資料或權限範圍,scope 列表請參考這裏

用戶被成功跳轉到頁面,並同意授權後,Code 就會作為 Query 發送到我們指定的 Redirect URI。其中 code 的部分就是我們需要的資料,請求如下:

https://redirect_url?
state=pass-through+value&
code={code}&
scope=email+profile+openid+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.email+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.profile&
authuser=0&
prompt=none#

從 URI 擷取出 Code 後,就可以拿去兌換 Access Token了。

兌換 Access Token & IdToken ( 以下簡稱 Token )

Token 簡單來說,就是用戶授權證明,身為第三方服務的我們,可以拿著 Token 向 Google 等發行機構存取用戶許可的行為與資源。

兌換 Token 時,我們一樣依 Google 要求的格式,組成 URI 向 Google API 發送 POST Request。以下提供 Sample ,重複的部分就不說明了:

https://oauth2.googleapis.com/token?
code={code}&
client_id={client_id}&
client_secret={client_secret}&
grant_type=authorization_code&
redirect_uri={redirect_uri}
  • client_secret:和 client_id 一組的 client_secret,建議用 .env 或其他比較機密性的作法存取
  • grant_type:我們使用 Code 換取 Token,因此這邊照範例填入即可

沒有錯誤的話會拿到類似這樣的 API 回應,其中標示粗體的 Token 就是我們所需要的資料:

{
"access_token": {access_token},
"expires_in": 3578,
"scope": "https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile openid",
"token_type": "Bearer",
"id_token": {id_token}
}

檢驗 IdToken 真實性

Google 的 IdToken 是一種 JWT Token,因此在使用 IdToken 前,我們必須先驗證 Token 沒有經過竄改,才能相信這個 Token 所記載的資料。

在驗證開始前,我們需要先取得 Google 用來簽署 IdToken 所用的 Keys。Google 的建議是,把取得 Google Config 的 URI 直接寫在常數中,再由取回的 Config 中取得鑰匙的 URI,再把取回的鑰匙記錄在變數,檢驗 Token 時存取即可。

是不是很繞口?來看看 Golang Sample Code:

package google

import (
"encoding/json"
"io/ioutil"
"net/http"
"sync"
)

const (
googleOpenIdConfigURI = "https://accounts.google.com/.well-known/openid-configuration" // google Config URI
)

var (
once sync.Once
client *Client
googleConfig = new(GoogleOpenIdConfiguration)
googleKeys *[]GoogleAuthKey
)

func NewClient(clientId, secret, host string) *Client {
once.Do(func() {
client = &Client{
ClientId: clientId,
Secret: secret,
Host: host,
}

keys := struct {
Keys []GoogleAuthKey `json:"keys"`
}{}

if resp, err := http.Get(googleOpenIdConfigurationURI); err != nil { // 取得 Google Config
return
} else if b, err := ioutil.ReadAll(resp.Body); err != nil {
return
} else if err = json.Unmarshal(b, googleConfig); err != nil {
return
} else if resp, err = http.Get(googleConfig.JwksURI); err != nil { // 取得加密鑰匙
return
} else if b, err = ioutil.ReadAll(resp.Body); err != nil {
return
} else if err = json.Unmarshal(b, &keys); err != nil {
return
} else {
googleKeys = &keys.Keys // 把鑰匙存在變數中
}
})

return client
}

取得公鑰後,我們根據 Google 提供的檢驗流程搭配 dgrijalva/jwt-go 實作程式碼:

1. Verify that the ID token is properly signed by the issuer.

要檢查 Token Payload 的部分有沒有被竄改,就是用 Token Header 紀錄的加密碼+鑰匙,對 Header+Payload 的部分做加密,看看和 Token Sign 有沒有相同的結果。

t, _ := jwt.Parse(tokenString, nil)
kid := t.Header["kid"].(string) // 取得鑰匙 Id

var key string
for _, k := range *googleKeys { // 從鑰匙串中找出 Id 正確的鑰匙
if k.Kid == kid {
key = k.N
break
}
}

token, err := jwt.ParseWithClaims(tokenString, &GoogleClaim{}, func(token *jwt.Token) (interface{}, error) {
return []byte(key), nil
})

// Verify that the ID token is properly signed by the issuer.
if v, ok := err.(*jwt.ValidationError); ok && v.Errors == jwt.ValidationErrorMalformed {
fmt.Println("Token is malformed: ", err.Error())
}

如果沒有發生錯誤,就代表 Payload 通過檢驗,我們就可以繼續確認後續的資料:

2. Verify that the value of the iss claim in the ID token is equal to https://accounts.google.com or accounts.google.com.
3. Verify that the value of the aud claim in the ID token is equal to your app’s client ID.
4. Verify that the expiry time (exp claim) of the ID token has not passed.

以下檢驗濃縮成程式碼如下:

// 接續上一段程式碼
if v, ok := err.(*jwt.ValidationError); ok && v.Errors == jwt.ValidationErrorMalformed {
fmt.Println("Token is malformed: ", err.Error())
} else if claim, ok := token.Claims.(*GoogleClaim); ok == false {
fmt.Println("Unable to get claims")
} else if time.Now().Unix() > claim.ExpiresAt {
fmt.Println("Token expired")
} else if claim.Issuer != "https://accounts.google.com" && claim.Issuer != "accounts.google.com"{
fmt.Println("Invalid Issuer")
} else if claim.Audience != client.ClientId {
fmt.Println("Invalid Audience")
} else {
fmt.Println("Valid Success")
}

如果順利完成以上認證的話,就代表是顆合法的 IdToken,我們就可以利用裡面的資料了!

總結

經過取得 Code、換取 Token 並驗證的流程,身為第三方服務提供者的我們,終於得到可用的 IdToken 了。

接下來可以直接使用 IdToken 作為驗證工具,也可以利用 IdToken 的資料繼續更詳盡的用戶註冊,或改發自己的 IdToken … 等。以上就是 Google OAuth 2.0 的 Client 實作。

--

--

Elvin.C

後端工程師/藥師,主要語言是 Golang 和 Node.js,喜歡打拳和看電影,天生勞碌命體質,最大的願望是能做出新一代的醫學應用軟體。