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