通過實例理解OpenID身份認證
在《通過實例理解OAuth2[1]》一文中,我們以實例方式講解了OAuth2授權(quán)碼模式(Authorization Code)模式的工作原理。實例中的照片沖印服務經(jīng)過用戶(tonybai)的授權(quán)后,使用用戶提供的code(實則是由授權(quán)服務器分配并通過用戶的瀏覽器重定向到照片沖印服務的)到授權(quán)服務器換取了access token,并最終使用access token從云盤系統(tǒng)中讀取到了用戶的照片信息。
不過,拿到了access token的照片沖印服務并不知道這個access token代表的是云盤服務上的哪個用戶,要不是云盤服務在照片list接口返回了用戶名(tonybai),照片沖印服務還需要自己為授權(quán)給它的用戶創(chuàng)建一個臨時的用戶id標識。當tonybai用戶一周后再次訪問照片沖印服務時,照片沖印服務還需要再走一次OAuth2授權(quán)流程,這對用戶的體驗并不好。
從照片沖印服務角度來說,它希望在用戶第一次使用服務并授權(quán)時,就能得到用戶身份信息,將用戶加入到自己的用戶體系中,并通過類似基于會話的身份認證機制[2]在用戶后續(xù)使用服務時自動識別并認證用戶身份。這樣,既可以避免用戶額外單獨注冊賬號的不佳體驗,又可以避免用戶下次使用服務時繁瑣地授權(quán)過程。
然而,盡管OAuth 2.0是一個需要用戶交互的安全協(xié)議,但它終歸不是身份認證協(xié)議。但很多像照片沖印服務這樣的應用還有通過像云盤系統(tǒng)這一的大廠應用進行用戶身份認證的強烈需求,于是有很多廠商都制定了各自專用的標準,比如Facebook、Twitter、LinkedIn和GitHub等,但這些都是專用協(xié)議,缺乏標準性,開發(fā)者要逐一開發(fā)和適配。
于是OpenID基金會[3]基于OAuth2.0制定了OpenID Connect(簡稱OIDC)[4]這樣的開放身份認證協(xié)議標準,可以在不同廠商之間通用。
在這篇文章中,我們就來介紹一下基于OpenID的身份認證原理,有了上一篇OAuth2做鋪墊,OIDC理解起來就非常容易了。
1. OpenID Connect(OIDC)簡介
OpenID Connect是一個開放標準,由OpenID基金會于2014年2月發(fā)布。它定義了一種使用OAuth 2.0執(zhí)行用戶身份認證的互通方式。由于該協(xié)議的設計具有互通性,一個OpenID客戶端應用可以使用同一套協(xié)議語言與不同的身份提供者交互,而不需要為每一個身份提供者實現(xiàn)一套有細微差別的協(xié)議。OpenID Connect直接基于OAuth 2.0構(gòu)建,并保持了OAuth2.0的兼容性。現(xiàn)實世界中,在多數(shù)情況下,OIDC都會與保護其他API的OAuth基礎架構(gòu)部署在一起。
我們在學習OAuth 2.0[5]時,首先了解了該協(xié)議涉及的幾個實體,如Client、Authorization Server、Resource Server、Resource owner、Protected resouce等,以及它們的交互流程。知道了這些也就掌握了OAuth2的內(nèi)核。以此為鑒,我們學習OIDC協(xié)議,也從了解都有哪些實體參與了協(xié)議交互,以及它們的具體交互流程開始。
OpenID Connect是一個協(xié)議套件(OpenID Connect Protocol Suite[6]),涉及Core、Discovery、Dynamic Client Registration等:
圖片
不過這里我們僅聚焦OpenID Connect的core 1.0協(xié)議規(guī)范[7]。
就像OAuth2.0支持四種授權(quán)模式一樣,OIDC基于這四種模式,整合出了三種身份認證類型:
- Authentication using the Authorization Code Flow
- Authentication using the Implicit Flow
- Authentication using the Hybrid Flow
其中Authentication using the Authorization Code Flow這種基于OAuth2授權(quán)碼流程的身份認證方案應該是使用最為廣泛的,本文也將基于這個流程對OIDC進行理解,并賦以實例。
1.1 OIDC協(xié)議中的實體與交互流程圖
下面是OIDC規(guī)范中給出的通用的身份認證流程圖,這個圖是高度抽象的,適合上面三個flow:
圖片
通過這個圖,我們先來認識參與OIDC流程中的三個實體:
- RP(Relying Party)
圖的最左端是一個叫RP的實體,如果對應到OAuth2.0那篇文章中的示例,這個RP對應的就是示例中的照片沖印服務,也就是OAuth2.0中的Client,即需要用戶(EU)授權(quán)的那個實體。
- OP(OpenID Provider)
OP對應的是OAuth2.0中的Authorization Server+Resource Server,不同的是在OIDC這個特殊場景下,Resource Server中存儲的resource就是用戶的身份信息。
- EU(End User)
EU,顧名思義就是使用RP服務的用戶,它對應OAuth2.0中的Resource Owner。
結(jié)合這些實體、上面的抽象流程圖以及OAuth2授權(quán)碼模式的交互圖,我畫一下OIDC基于授權(quán)碼模式進行身份認證的實體間的交互圖,這里我們依舊以用戶使用照片沖印服務為例:
圖片
上圖就是一個基于授權(quán)碼流程的OIDC協(xié)議流程,是不是趕腳跟OAuth 2.0中的授權(quán)碼模式的流程幾乎完全一致啊!
唯一的區(qū)別就是授權(quán)服務器(OP)在返回access_token的同時,還多返回了一個ID_TOKEN,我們稱這個ID_TOKEN為ID令牌,這個令牌是OIDC身份認證的關(guān)鍵。
1.2 ID_TOKEN的組成
從上圖中,我們看到ID_TOKEN與普通的OAuth access_token一起提供給Client(RP)使用,與access_token不同的是,RP是需要對ID_TOKEN進行解析的。那么這個ID_TOKEN究竟是什么呢?在OIDC協(xié)議中,ID_TOKEN是一個經(jīng)過簽名的JWT[8],
OIDC協(xié)議規(guī)范規(guī)定了該jwt應該包含的字段信息,包括必選的(REQUIRED)與可選的(OPTIONAL),在這里我們了解下面的必選字段信息即可:
- iss
令牌的頒發(fā)者,其值就是身份認證服務(OP)的URL,比如:http://open.my-yunpan.com:8081/oauth/token,不包含問號作為前綴的查詢參數(shù)等。
- sub
令牌的主題標識符,其值是最終用戶(EU)在身份認證服務(OP)內(nèi)部的唯一且永不重新分配的標識符。
- aud
令牌的目標受眾,其值是Client(RP)的標識,必須包含RP的OAuth 2.0客戶端ID(client_id),也可以包含其他受眾的標識符。
- exp
過期時間,過期后ID_TOKEN將會失效。其值是一個JSON number,表示從1970-01-01T0:0:0Z開始(以 UTC 度量)到過期日期/時間為止的秒數(shù)。
- iat
認證時間,即版本ID_TOKEN的時間,其值是一個JSON number,表示從1970-01-01T0:0:0Z開始(以 UTC 度量)到認證日期/時間為止的秒數(shù)。
注:如果客戶端(RP)向身份認證服務器(OP)注冊過公鑰,則可以使用客戶端公鑰對該JWT進行非對稱簽名校驗,或者可以使用客戶端密鑰對該JWT進行對稱簽名。這種方式可以提高客戶端的安全等級,因為可以避免在網(wǎng)絡上傳遞密鑰。
在上面圖中使用access_token獲取user_info的環(huán)節(jié)中,RP可以通過ID_TOKEN中的sub(EU唯一標識符)到授權(quán)服務器的userinfo端點換取用戶的基本信息,這樣在RP自己的頁面上展示EU的標識時就不可以不用9XDF-AABB-001ACFE這樣的唯一標識符(sub),而是用TonyBai這樣的可理解的字符串了。
注:OpenID Connect使用一個特殊的權(quán)限范圍值openid來控制對UserInfo端點的訪問。OpenID Connect定義了一組標準化的OAuth權(quán)限范圍,對 應于用戶屬性的子集,比如profile 、email 、phone 、address等。
了解了OIDC的身份認證流程以及ID_TOKEN的組成后,我們就算對OIDC有個直觀的認知了,接下來我們用一個實例來加深一下對OIDC身份認證的理解。
2. OIDC實例
如果你理解了《通過實例理解OAuth2[9]》一文中的實例,那么理解本篇文章中的OIDC實例將是輕而易舉的事情。前面說過,OIDC建構(gòu)在OAuth2之上,與OAuth2兼容,因此,這里的OIDC實例也改自OAuth2一文中的實例。
與OAuth2一文實例相比,OIDC實例中去掉了云盤服務(my-yunpan),僅保留了下面結(jié)構(gòu):
$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
其中my-photo-print是照片沖印服務,也是oidc實例中的RP實體,而open-my-yunpan扮演著云盤授權(quán)服務,是oidc實例中的OP實體。在編寫和運行服務之前,我們同樣要先修改一下本機(MacOS或Linux)的/etc/hosts文件:
127.0.0.1 my-photo-print.com
127.0.0.1 open.my-yunpan.com
注:在演示下面步驟前,請先進入到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渲染模板也基本沒有變化,因此這里就不贅述了。
當用戶選擇并點擊“使用云盤賬號登錄”時,瀏覽器將打開云盤授權(quán)服務(OP)的首頁(http://open.my-yunpan.com:8081/oauth/portal)。
2.2 使用open.my-yunpan.com進行授權(quán),包括openid權(quán)限
云盤授權(quán)服務的首頁還是“老樣子”,唯一的差別就是請求的權(quán)限包含了一項openid(有my-photo-print的home.html帶過來的):
圖片
這個頁面同樣由open.my-yunpan.com的portalHandler提供,它的邏輯與oauth2的實例相比沒有變化,這里也羅列其代碼了。
當用戶(EU)填寫用戶名和密碼后,點擊“授權(quán)”,瀏覽器便會向云盤授權(quán)服務的"/oauth/authorize"發(fā)起post請求以獲取code,負責"/oauth/authorize"端點的authorizeHandler會對用戶進行身份認證,通過后,它會分配code并向瀏覽器返回重定向的應答,重定向的地址就是照片沖印服務的回調(diào)地址: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發(fā)起請求,為照片沖印服務提供code,該請求由my-photo-print的oauthCallbackHandler處理:
// oidc-examples/my-photo-print/main.go
// callback handler,用戶(EU)拿到code后調(diào)用該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
}
這個handler中做了很多工作。首先是使用code像授權(quán)服務器換取access token和id_token,授權(quán)服務器負責頒發(fā)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)
}
我們看到tokenHandler先是對客戶端(client)憑據(jù)做了校驗,接下來驗證code,如果code通過驗證,則會分配access_token,并根據(jù)scope中是否包含openid決定是否分配id_token,這里我們的權(quán)限授權(quán)中包含了openid,于是tokenHandler將id_token(一個jwt)一并生成并返回給client。
而拿到access_token和id_token的my-photo-print的oauthCallbackHandler會解析id_token,提取其中的有效信息,比如subject等,并用access_token和id_token中的subject(用戶的唯一ID)去授權(quán)服務獲取用戶(EU)的基礎身份信息(姓名、主頁、郵箱等),并將用戶的唯一ID作為cookie存入用戶的瀏覽器。最后讓瀏覽器重定向到my-photo-print的profile頁面。
請注意:這里僅是為了簡便起見,生產(chǎn)環(huán)境請考慮更為安全的會話機制。
profile頁面的處理函數(shù)為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)
// 跳轉(zhuǎn)到首頁
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
mu.Unlock()
// 渲染照片頁面模板
tmpl := template.Must(template.ParseFiles("profile.html"))
tmpl.Execute(w, pf)
}
我們看到:該handler首先查找cookie中是否存在用戶ID,如果不存在,則重定向到登錄頁面,如果存在,則取出用戶唯一ID,并使用該ID查找用戶profile信息,最后展示到web頁面上:
到這里,我們看到:這種委托云盤授權(quán)服務對my-photo-print的用戶進行身份認證并拿到該用戶基本信息的機制,就是oidc。
注:一旦拿到云盤授權(quán)服務身份認證后的用戶信息,RP便可以使用各種身份認證機制來管理EU用戶,比如RP可以使用會話管理技術(shù)(例如使用會話標識符或瀏覽器cookie)來跟蹤EU的會話狀態(tài)。如果EU在同一會話期間訪問RP應用,RP可以通過會話標識符來識別EU,而無需再次進行身份驗證。
3. 小結(jié)
通過上面的內(nèi)容,我們對OpenID Connect(OIDC)有了更直觀的理解,這里做一個小結(jié):
- OIDC是一套身份認證的開放標準協(xié)議,基于OAuth 2.0構(gòu)建,與OAuth 2.0兼容。
- OIDC協(xié)議中主要涉及三個角色:RP(依賴方)、OP(身份提供方)、EU(最終用戶)。
- EU通過RP使用OP進行身份認證后,RP可以獲得EU的身份信息。整個流程與OAuth 2.0的授權(quán)碼流程高度相似。
- 關(guān)鍵的差別在于: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) Specification[11] - https://openid.net/specs/openid-connect-core-1_0.html
- 利用OAuth 2.0實現(xiàn)一個OpenID Connect用戶身份認證協(xié)議[12] - https://time.geekbang.org/column/article/262672