透過實例理解OpenID身份認證

2023.12.25

在《透過實例理解OAuth2[1]》一文中,我們以實例方式講解了OAuth2授權碼模式(Authorization Code)模式的工作原理。實例中的照片沖印服務經過使用者(tonybai)的授權後,使用使用者提供的code(實則是由授權伺服器分配並透過使用者的瀏覽器重定向到照片沖印服務的)到授權伺服器換取了access token,並最終使用access token從雲端盤系統中讀取到了用戶的照片資訊。

不過,拿到了access token的照片沖印服務並不知道這個access token代表的是雲盤服務上的哪個用戶,要不是雲盤服務在照片list接口返回了用戶名(tonybai),照片沖印服務還需要自己為授權給它的使用者建立一個臨時的使用者id標識。當tonybai用戶一週後再次造訪照片沖印服務時,照片沖印服務還需要再走一次OAuth2授權流程,這對使用者的體驗並不好。

從照片沖印服務角度來說,它希望在使用者第一次使用服務並授權時,就能得到使用者識別資訊,將使用者加入到自己的使用者係統中,並透過類似基於會話的身份認證機制[2]在使用者後續使用服務時自動識別並認證使用者身分。這樣,既可以避免使用者額外單獨註冊帳號的不佳體驗,又可以避免使用者下次使用服務時繁瑣地授權流程。

然而,儘管OAuth 2.0是一個需要使用者互動的安全協議,但它終歸不是身分認證協議。但許多像照片沖印服務這樣的應用還有透過像雲端磁碟系統這一的大廠應用程式進行用戶身份認證的強烈需求,於是有很多廠商都制定了各自專用的標準,例如Facebook、Twitter、LinkedIn和GitHub等等,但這些都是專用協議,缺乏標準性,開發者要逐一開發並適配。

於是OpenID基金會[3]基於OAuth2.0制定了OpenID Connect(簡稱OIDC)[4]這樣的開放身分認證協定標準,可以在不同廠商之間通用。

在這篇文章中,我們就來介紹一下基於OpenID的身份認證原理,有了上一篇OAuth2做鋪墊,OIDC理解起來就非常容易了。

1. OpenID Connect(OIDC)簡介

OpenID Connect為開放標準,由OpenID基金會於2014年2月發布。它定義了一種使用OAuth 2.0執行使用者身分認證的互通方式。由於該協定的設計具有互通性,因此一個OpenID客戶端應用程式可以使用同一套協定語言與不同的身分提供者交互,而不需要為每個身分提供者實現一套有細微差別的協定。OpenID Connect直接基於OAuth 2.0構建,並保持了OAuth2.0的兼容性。在現實世界中,在多數情況下,OIDC都會與保護其他API的OAuth基礎架構部署在一起。

我們在學習OAuth 2.0[5]時,首先了解了該協定涉及的幾個實體,如Client、Authorization Server、Resource Server、Resource owner、Protected resouce等,以及它們的互動流程。知道了這些也就掌握了OAuth2的核心。以此為鑑,我們學習OIDC協議,也從了解都有哪些實體參與了協議交互,以及它們的具體交互流程開始。

OpenID Connect是一個協定套件(OpenID Connect Protocol Suite[6]),涉及Core、Discovery、Dynamic Client Registration等:

圖片圖片

不過這裡我們僅聚焦在OpenID Connect的core 1.0協定規格[7]。

就像OAuth2.0支援四種授權模式一樣,OIDC基於這四種模式,整合出了三種身分認證類型:

  • 使用授權碼流程進行身份驗證
  • 使用隱式流程進行身份驗證
  • 使用混合流進行身份驗證

其中Authentication using the Authorization Code Flow這種基於OAuth2授權碼流程的身份認證方案應該是使用最為廣泛的,本文也將基於這個流程對OIDC進行理解,並賦以實例。

1.1 OIDC協定中的實體與互動流程圖

以下是OIDC規範中給出的通用的身份認證流程圖,這個圖是高度抽象的,適合上面三個​​flow:

圖片圖片

透過這個圖,我們先來認識參與OIDC流程中的三個實體:

  • RP(依賴方)

圖的最左端是一個叫RP的實體,如果對應到OAuth2.0那篇文章中的範例,這個RP對應的就是範例中的照片沖印服務,也就是OAuth2.0中的Client,也就是需要使用者(EU )授權的那個實體。

  • OP(OpenID 提供者)

OP對應的是OAuth2.0中的Authorization Server+Resource Server,不同的是在OIDC這個特殊場景下,Resource Server中儲存的resource就是使用者的識別資訊。

  • 歐盟(最終用戶)

EU,顧名思義就是使用RP服務的用戶,它對應OAuth2.0中的Resource Owner。

結合這些實體、上面的抽象流程圖以及OAuth2授權碼模式的交互圖,我畫一下OIDC基於授權碼模式進行身份認證的實體間的交互圖,這裡我們依舊以用戶使用照片沖印服務為例:

圖片圖片

上圖就是一個基於授權碼流程的OIDC協定流程,是不是趕腳跟OAuth 2.0中的授權碼模式的流程幾乎完全一致啊!

唯一的差別就是授權伺服器(OP)在回傳access_token的同時,還多回傳了一個ID_TOKEN,我們稱這個ID_TOKEN為ID令牌,這個令牌是OIDC身分認證的關鍵。

1.2 ID_TOKEN的組成

從上圖中,我們看到ID_TOKEN與普通的OAuth access_token一起提供給Client(RP)使用,與access_token不同的是,RP是需要對ID_TOKEN進行解析的。那麼這個ID_TOKEN究竟是什麼呢?在OIDC協定中,ID_TOKEN是一個經過簽署的JWT[8],

OIDC協議規範規定了該jwt應該包含的字段信息,包括必選的(REQUIRED)與可選的(OPTIONAL),在這裡我們了解下面的必選字段信息即可:

  • 國際太空站

令牌的頒發者,其值就是身分認證服務(OP)的URL,例如:http://open.my-yunpan.com:8081/oauth/token,不包含問號作為前綴的查詢參數等。

令牌的主題標識符,其值是最終使用者(EU)在身分認證服務(OP)內部的唯一且永不重新分配的標識符。

  • 音訊

令牌的目標受眾,其值是Client(RP)的標識,必須包含RP的OAuth 2.0客戶端ID(client_id),也可以包含其他受眾的識別碼。

  • 經驗值

過期時間,過期後ID_TOKEN將會失效。其值是一個JSON number,表示從1970-01-01T0:0:0Z開始(以UTC 度量)到過期日期/時間為止的秒數。

  • 我在

認證時間,即版本ID_TOKEN的時間,其值是一個JSON number,表示從1970-01-01T0:0:0Z開始(以UTC 度量)到認證日期/時間為止的秒數。

附註:如果客戶端(RP)向身分認證伺服器(OP)註冊公鑰,則可以使用客戶端公鑰對該JWT進行非對稱簽章校驗,或可使用客戶端金鑰對該JWT進行對稱簽名。這種方式可以提高客戶端的安全等級,因為可以避免在網路上傳遞金鑰。

在圖中使用access_token取得user_info的環節中,RP可以透過ID_TOKEN中的sub(EU唯一識別碼)到授權伺服器的userinfo端點換取使用者的基本訊息,這樣在RP自己的頁面上展示EU的識別時就不可以不用9XDF-AABB-001ACFE這樣的唯一識別碼(sub),而是用TonyBai這樣的可理解的字串了。

註:OpenID Connect使用一個特殊的權限範圍值openid來控制對UserInfo端點的存取。OpenID Connect定義了一組標準化的OAuth權限範圍,對應於使用者屬性的子集,例如profile 、email 、phone 、address等。

了解了OIDC的身份認證流程以及ID_TOKEN的組成後,我們就算對OIDC有個直覺的認知了,接下來我們用一個實例來加深一下對OIDC身份認證的理解。

2. OIDC實例

如果你了解《透過實例理解OAuth2[9]》一文中的實例,那麼理解這篇文章中的OIDC實例將是輕而易舉的事。前面說過,OIDC建構在OAuth2之上,與OAuth2相容,因此,這裡的OIDC實例也改自OAuth2一文中的實例。

與OAuth2一文實例相比,OIDC實例中去掉了雲端磁碟服務(my-yunpan),只保留了下面結構:

$tree -L 2 -F oidc-examples 
oidc-examples
├── my-photo-print/
│   ├── go.mod
│   ├── go.sum
│   ├── home.html
│   ├── main.go
│   └── profile.html
└── open-my-yunpan/
    ├── go.mod
    ├── go.sum
    ├── main.go
    └── portal.html
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.

其中my-photo-print是照片沖印服務,也是oidc實例中的RP實體,而open-my-yunpan則扮演雲端磁碟授權服務,是oidc實例中的OP實體。在編寫和運行服務之前,我們同樣要先修改一下本機(MacOS或Linux)的/etc/hosts檔案:

127.0.0.1 my-photo-print.com
127.0.0.1 open.my-yunpan.com
  • 1.
  • 2.

註:在示範下方步驟前,請先進入到oidc-examples的兩個目錄下,透過go run main.go啟動各個服務程式(每個程式一個終端視窗)。

2.1 使用者使用my-photo-print.com照片沖印服務

依照流程,使用者先透過瀏覽器開啟照片沖印服務的首頁:http://my-photo-print.com:8080,如下圖:

圖片圖片

這與OAuth2一文中的實例並無什麼差別,該頁面也是由my-photo-print/main.go中的homeHandler提供的,它的home.html渲染模板也基本沒有變化,因此這裡就不贅述了。

當使用者選擇並點選「使用雲端磁碟帳號登入」時,瀏覽器會開啟雲端磁碟授權服務(OP)的首頁(http://open.my-yunpan.com:8081/oauth/portal)。

2.2 使用open.my-yunpan.com進行授權,包括openid權限

雲端磁碟授權服務的首頁還是“老樣子”,唯一的差別就是請求的權限包含了一項openid(有my-photo-print的home.html帶過來的):

圖片圖片

這個頁面同樣由open.my-yunpan.com的portalHandler提供,它的邏輯與oauth2的實例相比沒有變化,這裡也羅列其程式碼了。

當用戶(EU)填寫用戶名和密碼後,點擊“授權”,瀏覽器便會向雲盤授權服務的"/oauth/authorize"發起post請求以獲取code,負責"/oauth/authorize"端點的authorizeHandler會對使用者進行身份認證,通過後,它會分配code並向瀏覽器回傳重定向的應答,重定向的位址就是照片沖印服務的回呼位址:http://my-photo-print.com:8080/cb ?code=xxx&state=yyy。

2.3 取得access token以及id_token,並用使用者唯一標識取得使用者基本資訊(profile)

這個重定向相當於使用者瀏覽器向http://my-photo-print.com:8080/cb?code=xxx&state=yyy發起請求,為照片沖印服務提供code,該請求由my-photo-print的oauthCallbackHandler處理:

// oidc-examples/my-photo-print/main.go

// callback handler,用户(EU)拿到code后调用该handler
func oauthCallbackHandler(w http.ResponseWriter, r *http.Request) {
 fmt.Println("oauthCallbackHandler:", *r)

 code := r.FormValue("code")
 state := r.FormValue("state")

 // check state
 mu.Lock()
 _, ok := stateCache[state]
 if !ok {
  mu.Unlock()
  fmt.Println("not found state:", state)
  w.WriteHeader(http.StatusBadRequest)
  return
 }
 delete(stateCache, state)
 mu.Unlock()

 // fetch access_token and id_token with code
 accessToken, idToken, err := fetchAccessTokenAndIDToken(code)
 if err != nil {
  fmt.Println("fetch access_token error:", err)
  return
 }
 fmt.Println("fetch access_token ok:", accessToken)

 // parse id_token
 mySigningKey := []byte("iamtonybai")
 claims := jwt.RegisteredClaims{}
 _, err = jwt.ParseWithClaims(idToken, &claims, func(token *jwt.Token) (interface{}, error) {
  return mySigningKey, nil
 })
 if err != nil {
  fmt.Println("parse id_token error:", err)
  return
 }

 // use access_token and userID to get user info
 up, err := getUserInfo(accessToken, claims.Subject)
 if err != nil {
  fmt.Println("get user info error:", err)
  return
 }
 fmt.Println("get user info ok:", up)

 mu.Lock()
 userProfile[claims.Subject] = up
 mu.Unlock()

 // 设置cookie
 cookie := http.Cookie{
  Name:   "my-photo-print.com-session",
  Value:  claims.Subject,
  Domain: "my-photo-print.com",
  Path:   "/profile",
 }
 http.SetCookie(w, &cookie)
 w.Header().Add("Location", "/profile")
 w.WriteHeader(http.StatusFound) // redirect to /profile
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
  • 36.
  • 37.
  • 38.
  • 39.
  • 40.
  • 41.
  • 42.
  • 43.
  • 44.
  • 45.
  • 46.
  • 47.
  • 48.
  • 49.
  • 50.
  • 51.
  • 52.
  • 53.
  • 54.
  • 55.
  • 56.
  • 57.
  • 58.
  • 59.
  • 60.
  • 61.
  • 62.
  • 63.

這個handler中做了很多工作。首先是使用code像授權伺服器換取access token和id_token,授權伺服器負責頒發token的是tokenHandler:

// oidc-examples/open-yunpan/main.go

func tokenHandler(w http.ResponseWriter, r *http.Request) {
 fmt.Println("tokenHandler:", *r)

 // check client_id and client_secret
 user, password, ok := r.BasicAuth()
 if !ok {
  fmt.Println("no authorization header")
  w.WriteHeader(http.StatusNonAuthoritativeInfo)
  return
 }

 mu.Lock()
 v, ok := validClients[user]
 if !ok {
  fmt.Println("not found user:", user)
  mu.Unlock()
  w.WriteHeader(http.StatusNonAuthoritativeInfo)
  return
 }
 mu.Unlock()

 if v != password {
  fmt.Println("invalid password")
  w.WriteHeader(http.StatusNonAuthoritativeInfo)
  return
 }

 // check code and redirect_uri
 code := r.FormValue("code")
 redirect_uri := r.FormValue("redirect_uri")

 mu.Lock()
 ac, ok := codeCache[code]
 if !ok {
  fmt.Println("not found code:", code)
  mu.Unlock()
  w.WriteHeader(http.StatusNotFound)
  return
 }
 mu.Unlock()

 if ac.redirectURI != redirect_uri {
  fmt.Println("invalid redirect_uri:", redirect_uri)
  w.WriteHeader(http.StatusBadRequest)
  return
 }

 var authResponse struct {
  AccessToken string `json:"access_token"`
  IDToken     string `json:"id_token,omitempty"`
  ExpireIn    int    `json:"expires_in"`
 }

 // generate access_token
 authResponse.AccessToken = randString(16)
 authResponse.ExpireIn = 3600
 now := time.Now()
 expired := now.Add(10 * time.Minute)
 claims := jwt.RegisteredClaims{
  Issuer:    "http://open.my-yunpan.com:8091/oauth/token",
  Subject:   ac.userID,
  Audience:  jwt.ClaimStrings{user}, //client_id
  IssuedAt:  &jwt.NumericDate{now},
  ExpiresAt: &jwt.NumericDate{expired},
 }

 if strings.Contains(ac.scopeTxt, "openid") {
  // generate id_token if contains openid
  mySigningKey := []byte("iamtonybai")
  jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
  authResponse.IDToken, _ = jwtToken.SignedString(mySigningKey)
 }

 respData, _ := json.Marshal(&authResponse)
 w.Write(respData)
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
  • 36.
  • 37.
  • 38.
  • 39.
  • 40.
  • 41.
  • 42.
  • 43.
  • 44.
  • 45.
  • 46.
  • 47.
  • 48.
  • 49.
  • 50.
  • 51.
  • 52.
  • 53.
  • 54.
  • 55.
  • 56.
  • 57.
  • 58.
  • 59.
  • 60.
  • 61.
  • 62.
  • 63.
  • 64.
  • 65.
  • 66.
  • 67.
  • 68.
  • 69.
  • 70.
  • 71.
  • 72.
  • 73.
  • 74.
  • 75.
  • 76.
  • 77.
  • 78.

我們看到tokenHandler先是對客戶端(client)憑證做了校驗,接下來驗證code,如果code通過驗證,則會分配access_token,並根據scope中是否包含openid決定是否分配id_token,這裡我們的權限授權中包含了openid,於是tokenHandler將id_token(一個jwt)一併產生並傳回給client。

而拿到access_token和id_token的my-photo-print的oauthCallbackHandler會解析id_token,提取其中的有效信息,比如subject等,並用access_token和id_token中的subject(用戶的唯一ID)去授權服務獲取用戶(EU)的基礎身分資訊(姓名、首頁、信箱等),並將使用者的唯一ID作為cookie存入使用者的瀏覽器。最後讓瀏覽器重定向到my-photo-print的profile頁面。

請注意:這裡僅是為了簡單起見,生產環境請考慮更安全的會話機制。

profile頁面的處理函數為profileHandler:

// oidc-examples/my-photo-print/main.go

// user profile页面
func profileHandler(w http.ResponseWriter, r *http.Request) {
 fmt.Println("profileHandler:", *r)

 cookie, err := r.Cookie("my-photo-print.com-session")
 if err != nil {
  http.Error(w, "找不到cookie,请重新登录", 401)
  return
 }
 fmt.Printf("found cookie: %#v\n", cookie)

 mu.Lock()
 pf, ok := userProfile[cookie.Value]
 if !ok {
  mu.Unlock()
  fmt.Println("not found user:", cookie.Value)
  // 跳转到首页
  http.Redirect(w, r, "/", http.StatusSeeOther)
  return
 }
 mu.Unlock()

 // 渲染照片页面模板
 tmpl := template.Must(template.ParseFiles("profile.html"))
 tmpl.Execute(w, pf)
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.

我們看到:該handler首先查找cookie中是否存在用戶ID,如果不存在,則重定向到登錄頁面,如果存在,則取出用戶唯一ID,並使用該ID查找用戶profile信息,最後展示到web頁面上:

到這裡,我們看到:這個委託雲端硬碟授權服務對my-photo-print的使用者進行身分認證並拿到該使用者基本資訊的機制,就是oidc。

附註:一旦拿到雲端磁碟授權服務認證後的用戶訊息,RP便可以使用各種身分認證機制來管理EU用戶,例如RP可以使用會話管理技術(例如使用會話識別碼或瀏覽器cookie)來追蹤EU的會話狀態。如果EU在同一會話期間存取RP應用,RP可以透過會話標識符來識別EU,而無需再次進行身份驗證。

3. 小結

透過上面的內容,我們對OpenID Connect(OIDC)有了更直觀的理解,這裡做一個小結:

  • OIDC是一套身份認證的開放標準協議,基於OAuth 2.0構建,與OAuth 2.0相容。
  • OIDC協定中主要涉及三個角色:RP(依賴方)、OP(身分提供者)、EU(最終使用者)。
  • EU通過RP使用OP進行身份認證後,RP可以獲得EU的身份資訊。整個流程與OAuth 2.0的授權碼流程高度相似。
  • 關鍵的差異在於:OP回傳的token除了access_token外,還包含一個ID_TOKEN(JWT格式)。
  • RP透過解析ID_TOKEN可以獲得EU的唯一識別等信息,並透過access_token進一步獲取EU的詳細身份資訊。
  • RP取得EU身分資訊後,可以透過各種機制識別和管理EU,無需EU重複身份驗證。

總的來說,OIDC利用OAuth 2.0流程進行身份認證,透過額外返回的ID_TOKEN提供EU身份信息,很好地滿足了RP對EU身份管理的需求。

文本涉及的源碼可以在這裡[10]下載。

4. 參考資料

  • OIDC(OpenID Connect) 規格[11] - https://openid.net/specs/openid-connect-core-1_0.html
  • 利用OAuth 2.0實作一個OpenID Connect使用者身分認證協定[12] - https://time.geekbang.org/column/article/262672