本週新做一個需求,爲IOS APP接入蘋果第三方登錄。查看官方文檔,發現其在IOS端操作描述是非常細緻的,但在用戶服務端的講解實在是不知所云,讓人頭大。只能藉助廣大網友的智慧。本文並非完全原創,因爲無論概念解讀,還是操作方式,都是從各個文章處抄來的。本文最大的作用,在於針對自己和團隊內部的開發記錄,防止多次踩坑。
Sign in with Apple,對IOS和其它平臺的處理方式是有很大差別的。本文只針對IOS平臺,其它平臺可以參考這篇文章,說得非常詳細
說明
本文主要以官方文檔爲主線,輔以自己的理解。依賴於Sign in with Apple REST API頁面,其分爲兩部分
-
用戶授權和驗證 - Authentication and Verification of Users
-
獲取公鑰和生成&驗證token的API介紹
IOS上的蘋果登錄與一般的第三方登錄最大的區別,在於IOS在客戶端已經獲取了必要的用戶信息,以加密的形式發送給服務端,服務端需要做的驗證並應用即可;而一般的第三方登錄流程是需要在服務端請求用戶信息的。秉持這一基礎認識很重要,否則會雲裏霧裏。
客戶端
該部分對應 Authenticating Users with Sign in with Apple
客戶端授權流程如上主要有如下幾步
- 調用API發起授權請求
- 設備彈出授權框,請求用戶授權
- 用戶授權成功,API調用Apple ID 服務,請求用戶信息
- 請求成功,Apple ID服務以返回三個字段:identity token、authorization code、user identifier
identity token
identity token是一個JWT,使用解析工具解開後如下,包含了基本的用戶信息。
{
kid: "86D88Kf",
alg: "RS256"
}.
{
iss: "https://appleid.apple.com",
aud: "com.mampod.enlighten",
exp: 1585110701,
iat: 1585110101,
sub: "001230.15f855de99ef4b788a18d18b7b45b053.0400",
nonce: "123",
c_hash: "lGYaArOB6z6IFuCOx2Z64A",
email: "[email protected]",
email_verified: "true",
is_private_email: "true",
auth_time: 1585110101,
nonce_supported: true
}.
[signature]
每個字段解讀如下
頭部:
kid: key id,在token驗證時用於選取公鑰的ID
alg: algorithm,算法
載荷部分:
- iss: isser的縮寫,即JWT發佈人
- aud: audience的縮寫,客戶,對應APP的開發者。對應Apple開發者賬戶中的client_id
- exp: expire的縮寫,即過期時間
- iat: issue at的縮寫,即該token的發佈時間
- sub: subject,即加密對象,這是關鍵,它是用戶的唯一標識符,很重要。
- nonce: 即隨機字符串。用於綁定客戶端會話和token的字符串值,用於防止重複攻擊。
- nonce_supported: 指示對待nonce的方式
- true: 如果授權請求時有給nonce,但返回的token不含nonce,則說明此次請求失敗
- false: 不支持nonce,忽略nonce
- c_hash: authorizationCode的hash值,用於驗證authorizationCode
- email: 用戶郵箱
- email_verified: 郵箱是否經過驗證,總是爲true
- is_private_email: 是否是加密郵箱,即上面說的[email protected]
- auth_time: 請求授權的時間
authorization code
authorization code用於和Apple ID服務交互,這裏暫時用不到,忽略。
user identifier
JWT中的sub字段,對應了用戶唯一標識符,即identifier,它具有如下特性
- 唯一且穩定
- 同一個蘋果開發賬戶的所有APP對應的同一個用戶的identifier是唯一的
- 不同的開發賬戶的APP對應一個用戶的不同identifier
- 對於用戶從APP註銷,再登錄,identifier是不會變的
- 可以用於唯一標示用戶,即應該用它而不是郵箱嵌入我們的業務數據庫
Private email
授權時,用戶可以選擇隱藏真是郵箱,於是我們會獲取到一個[email protected]格式的用戶郵箱,該郵箱具有一定限制
- 郵箱具有全局唯一性
- 發往該郵箱的信息將會被轉發到真實的用戶郵箱
- 對於同一個開發者的所有APP,一個用戶對應一個郵箱;對於不同開發這的不同APP,一個用戶對應多個郵箱
- 該郵箱一旦生成,會一直生效,無論用戶是否有登錄你的APP,或已經刪除APP
- 要想向該郵箱發送信息,需要在Apple註冊發送郵箱的郵箱域名,否則不會發送成功。
客戶端最後一步
爲了將用戶與我方APP服務端用戶系統綁定,需要將上面獲取的的identity token、authorization code、user identifier等信息發送APP服務端。
服務端
這部分對應 Verifying a User,也是最令人頭大的一部分。相同地,他也提供了一個流程圖,描述如何使用authorization code換取用戶信息和refresh token。對IOS登錄的後臺驗證毫無幫助,相反會起到混淆視聽的作用。請直接忽略。
對於IOS登錄,由於IOS客戶端已經通過客戶端API獲取了必要的用戶信息:唯一標識符、name、email等,我們已經沒有必要再次獲取這些信息,只需要驗證他們的真實性即可。
而對於其它平臺的登錄,如果你覺得看不懂這裏的文檔,強烈建議你按照這篇文章的方式操作——Sign in with Apple Tutorial, Part 4: Web and Other Platforms
驗證identity token
identity token的簽名是Apple ID服務使用私鑰加密的,需要從這裏獲取公鑰解密驗證。取得的公鑰以JWKS的形式呈現,如下。
{
"keys": [
{
"kty": "RSA",
"kid": "86D88Kf",
"use": "sig",
"alg": "RS256",
"n": "iGaLqP6y-SJCCBq5Hv6pGDbG_SQ11MNjH7rWHcCFYz4hGwHC4lcSurTlV8u3avoVNM8jXevG1Iu1SY11qInqUvjJur--hghr1b56OPJu6H1iKulSxGjEIyDP6c5BdE1uwprYyr4IO9th8fOwCPygjLFrh44XEGbDIFeImwvBAGOhmMB2AD1n1KviyNsH0bEB7phQtiLk-ILjv1bORSRl8AK677-1T8isGfHKXGZ_ZGtStDe7Lu0Ihp8zoUt59kx2o9uWpROkzF56ypresiIl4WprClRCjz8x6cPZXU2qNWhu71TQvUFwvIvbkE1oYaJMb0jcOTmBRZA2QuYw-zHLwQ",
"e": "AQAB"
},
{
"kty": "RSA",
"kid": "eXaunmL",
"use": "sig",
"alg": "RS256",
"n": "4dGQ7bQK8LgILOdLsYzfZjkEAoQeVC_aqyc8GC6RX7dq_KvRAQAWPvkam8VQv4GK5T4ogklEKEvj5ISBamdDNq1n52TpxQwI2EqxSk7I9fKPKhRt4F8-2yETlYvye-2s6NeWJim0KBtOVrk0gWvEDgd6WOqJl_yt5WBISvILNyVg1qAAM8JeX6dRPosahRVDjA52G2X-Tip84wqwyRpUlq2ybzcLh3zyhCitBOebiRWDQfG26EH9lTlJhll-p_Dg8vAXxJLIJ4SNLcqgFeZe4OfHLgdzMvxXZJnPp_VgmkcpUdRotazKZumj6dBPcXI_XID4Z4Z3OM1KrZPJNdUhxw",
"e": "AQAB"
}
]
}
公鑰不止一個,需要取kid字段與JWT頭部的kid字段匹配的那個。
這裏我們使用了使用人數較多的庫java-jwt、jwks-rsa-java。
val jwt = JWT.decode(/*客戶端上傳的JWT*/)
val jwk = new UrlJwkProvider("https://appleid.apple.com/auth/keys").get(jwt.getKeyId)
val algorithm = Algorithm.RSA256(jwk.getPublicKey.asInstanceOf[RSAPublicKey], null)
Try {
algorithm.verify()
} match {
case Success(_) => // 驗證通過
case Failure(e) => // 驗證不通過
}
驗證其它內容
根據官方手冊,總計需要驗證如下內容
- 使用公鑰驗證JWT簽名,上一步已驗證
- 驗證nonce,可選,這裏不驗證
- 驗證iss,必須爲apple簽發,即必須包含https://appleid.apple.com字符串
- 驗證aud,必須爲開發者賬戶的client_id
- 驗證exp,即過期時間
使用現成庫的好處之一是可以直接獲取JWT的標準字段,如下
Try {
assert(jwt.getAudience.get(0) == clientId, "aud incorrect")
assert(jwt.getIssuer.contains("https://appleid.apple.com"), "iss must contains https://appleid.apple.com")
assert(jwt.getSubject == request.identifier, "identifier invalid")
assert(jwt.getExpiresAt.getTime > System.currentTimeMillis(), "Identity token expired")
} match {
case Success(_) => // 驗證通過
case Failure(e) => // 驗證不通過
}
優化 - 緩存Algorithm實例
上述驗證步驟中,在獲取公鑰和構建Algorithm實例時耗費較長時間——超過1秒
val jwk = new UrlJwkProvider("https://appleid.apple.com/auth/keys").get(jwt.getKeyId)
val algorithm = Algorithm.RSA256(jwk.getPublicKey.asInstanceOf[RSAPublicKey], null)
爲了加快響應速度,可以緩存Algorithm實例,但由於apple提供的公鑰可能變化,因此需要使用一定的策略兼顧效率和正確性。我們使用如下策略。
實際操作如下
def verifySignature(force: Boolean = false) = {
if (force) jwkCache.remove(jwt.getKeyId)
jwkCache.get(jwt.getKeyId, () => {
val jwk = new UrlJwkProvider("https://appleid.apple.com/auth/keys").get(jwt.getKeyId)
Algorithm.RSA256(jwk.getPublicKey.asInstanceOf[RSAPublicKey], null)
}).map(algorithm => Try {
algorithm.verify(jwt)
} match {
case Success(_) => Unit
case Failure(e) => Future.failed(e)
})
}
verifySignature()
.recoverWith[Any] { case _: Throwable => verifySignature(force = true) }
總結
相對於傳統第三方登錄,IOS的登錄流程略有不同
一般流程
客戶端
- 用戶被導入登錄服務提供商,如微信、支付寶等
- 用戶掃碼或輸入賬號密碼授權
- 服務提供商將用戶重定向回客戶端,並附帶token
- 客戶端憑藉該token進行登錄
服務端
- 應用服務端憑藉該token向服務提供商驗證登錄真實性並獲取用戶信息
- 使用獲取到的用戶信息綁定自建的用戶系統
IOS登錄流程
客戶端
- IOS客戶端上彈出授權框
- 用戶刷臉授權
- Apple同樣將用戶重定向迴應用,附帶token,但同時使用客戶端API想Apple Server獲取用戶信息,以JWT形式提供
- 客戶端將JWT發送給應用服務端
服務端
- 驗證JWT
- 使用JWT附帶的用戶信息綁定自建的用戶系統
可以看到,其最主要的不同還是在於IOS客戶端已經獲取了用戶信息,在服務端僅需要驗證JWT即可,與一般流程相比,IOS登錄的應用後端少了一步請求用戶信息的步驟。
沒涉及的部分
文章到這裏也只介紹了在IOS客戶端接入Sign in with apple的後端操作步驟,並沒有設計到IOS之外平臺的處理方式,對此,可以參考這篇文章,它有很好的講解。
更多
關於更多授權相關知識,這裏列舉了一些學習資源