前言
PKCE 全稱是 Proof Key for Code Exchange, 在2015年發布, 它是 OAuth 2.0 核心的一個擴展協議, 所以可以和現有的授權模式結合使用,比如 Authorization Code + PKCE, 這也是最佳實踐,PKCE 最初是為移動設備應用和本地應用創建的, 主要是為了減少公共客戶端的授權碼攔截攻擊。
在最新的 OAuth 2.1 規范中(草案), 推薦所有客戶端都使用 PKCE, 而不僅僅是公共客戶端, 並且移除了 Implicit 隱式和 Password 模式, 那之前使用這兩種模式的客戶端怎么辦? 是的, 您現在都可以嘗試使用 Authorization Code + PKCE 的授權模式。那 PKCE 為什么有這種魔力呢? 實際上它的原理是客戶端提供一個自創建的證明給授權服務器, 授權服務器通過它來驗證客戶端,把訪問令牌(access_token) 頒發給真實的客戶端而不是偽造的。
客戶端類型
上面說到了 PKCE 主要是為了減少公共客戶端的授權碼攔截攻擊, 那就有必要介紹下兩種客戶端類型了。
OAuth 2.0 核心規范定義了兩種客戶端類型, confidential 機密的, 和 public 公開的, 區分這兩種類型的方法是, 判斷這個客戶端是否有能力維護自己的機密性憑據 client_secret。
-
confidential
對於一個普通的web站點來說,雖然用戶可以訪問到前端頁面, 但是數據都來自服務器的后端api服務, 前端只是獲取授權碼code, 通過 code 換取access_token 這一步是在后端的api完成的, 由於是內部的服務器, 客戶端有能力維護密碼或者密鑰信息, 這種是機密的的客戶端。 -
public
客戶端本身沒有能力保存密鑰信息, 比如桌面軟件, 手機App, 單頁面程序(SPA), 因為這些應用是發布出去的, 實際上也就沒有安全可言, 惡意攻擊者可以通過反編譯等手段查看到客戶端的密鑰, 這種是公開的客戶端。
在 OAuth 2.0 授權碼模式(Authorization Code)中, 客戶端通過授權碼code向授權服務器獲取訪問令牌(access_token) 時,同時還需要在請求中攜帶客戶端密鑰(client_secret), 授權服務器對其進行驗證, 保證 access_token 頒發給了合法的客戶端, 對於公開的客戶端來說, 本身就有密鑰泄露的風險, 所以就不能使用常規 OAuth 2.0 的授權碼模式, 於是就針對這種不能使用 client_secret 的場景, 衍生出了 Implicit 隱式模式, 這種模式從一開始就是不安全的。在經過一段時間之后, PKCE 擴展協議推出, 就是為了解決公開客戶端的授權安全問題。
授權碼攔截攻擊
上面是OAuth 2.0 授權碼模式的完整流程, 授權碼攔截攻擊就是圖中的C步驟發生的, 也就是授權服務器返回給客戶端授權碼的時候, 這么多步驟中為什么 C 步驟是不安全的呢? 在 OAuth 2.0 核心規范中, 要求授權服務器的 anthorize endpoint 和 token endpoint 必須使用 TLS(安全傳輸層協議)保護, 但是授權服務器攜帶授權碼code返回到客戶端的回調地址時, 有可能不受TLS 的保護, 惡意程序就可以在這個過程中攔截授權碼code, 拿到 code 之后, 接下來就是通過 code 向授權服務器換取訪問令牌 access_token , 對於機密的客戶端來說, 請求 access_token 時需要攜帶客戶端的密鑰 client_secret , 而密鑰保存在后端服務器上, 所以惡意程序通過攔截拿到授權碼code 也沒有用, 而對於公開的客戶端(手機App, 桌面應用)來說, 本身沒有能力保護 client_secret, 因為可以通過反編譯等手段, 拿到客戶端 client_secret, 也就可以通過授權碼 code 換取 access_token, 到這一步,惡意應用就可以拿着 token 請求資源服務器了。
state 參數, 在 OAuth 2.0 核心協議中, 通過 code 換取 token 步驟中, 推薦使用 state 參數, 把請求和響應關聯起來, 可以防止跨站點請求偽造-CSRF攻擊, 但是 state 並不能防止上面的授權碼攔截攻擊,因為請求和響應並沒有被偽造, 而是響應的授權碼被惡意程序攔截。
PKCE 協議流程
PKCE 協議本身是對 OAuth 2.0 的擴展, 它和之前的授權碼流程大體上是一致的, 區別在於, 在向授權服務器的 authorize endpoint 請求時,需要額外的 code_challenge
和 code_challenge_method
參數, 向 token endpoint 請求時, 需要額外的 code_verifier
參數, 最后授權服務器會對這三個參數進行對比驗證, 通過后頒發令牌。
code_verifier
對於每一個OAuth 授權請求, 客戶端會先創建一個代碼驗證器 code_verifier, 這是一個高熵加密的隨機字符串, 使用URI 非保留字符 (Unreserved characters), 范圍 [A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~"
, 因為非保留字符在傳遞時不需要進行 URL 編碼, 並且 code_verifier 的長度最小是 43, 最大是 128, code_verifier 要具有足夠的熵它是難以猜測的。
code_verifier 的擴充巴科斯范式 (ABNF) 如下:
code-verifier = 43*128unreserved unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" ALPHA = %x41-5A / %x61-7A DIGIT = %x30-39
簡單點說就是在 [A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~"
范圍內,生成43-128位的隨機字符串。
javascript 示例
// Required: Node.js crypto module // https://nodejs.org/api/crypto.html#crypto_crypto function base64URLEncode(str) { return str.toString('base64') .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=/g, ''); } var verifier = base64URLEncode(crypto.randomBytes(32));
java 示例
// Required: Apache Commons Codec // https://commons.apache.org/proper/commons-codec/ // Import the Base64 class. // import org.apache.commons.codec.binary.Base64; SecureRandom sr = new SecureRandom(); byte[] code = new byte[32]; sr.nextBytes(code); String verifier = Base64.getUrlEncoder().withoutPadding().encodeToString(code);
c# 示例
public static string randomDataBase64url(int length) { RNGCryptoServiceProvider rng = new RNGCryptoServiceProvider(); byte[] bytes = new byte[length]; rng.GetBytes(bytes); return base64urlencodeNoPadding(bytes); } public static string base64urlencodeNoPadding(byte[] buffer) { string base64 = Convert.ToBase64String(buffer); base64 = base64.Replace("+", "-"); base64 = base64.Replace("/", "_"); base64 = base64.Replace("=", ""); return base64; } string code_verifier = randomDataBase64url(32);
code_challenge_method
對 code_verifier 進行轉換的方法, 這個參數會傳給授權服務器, 並且授權服務器會記住這個參數, 頒發令牌的時候進行對比, code_challenge == code_challenge_method(code_verifier)
, 若一致則頒發令牌。
code_challenge_method 可以設置為 plain (原始值) 或者 S256 (sha256哈希)。
code_challenge
使用 code_challenge_method 對 code_verifier 進行轉換得到 code_challenge, 可以使用下面的方式進行轉換
-
plain
code_challenge = code_verifier -
S256
code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))
客戶端應該首先考慮使用 S256 進行轉換, 如果不支持,才使用 plain , 此時 code_challenge 和 code_verifier 的值相等。
javascript 示例
// Required: Node.js crypto module // https://nodejs.org/api/crypto.html#crypto_crypto function sha256(buffer) { return crypto.createHash('sha256').update(buffer).digest(); } var challenge = base64URLEncode(sha256(verifier));
java 示例
// Dependency: Apache Commons Codec // https://commons.apache.org/proper/commons-codec/ // Import the Base64 class. // import org.apache.commons.codec.binary.Base64; byte[] bytes = verifier.getBytes("US-ASCII"); MessageDigest md = MessageDigest.getInstance("SHA-256"); md.update(bytes, 0, bytes.length); byte[] digest = md.digest(); String challenge = Base64.encodeBase64URLSafeString(digest);
C# 示例
public static string base64urlencodeNoPadding(byte[] buffer) { string base64 = Convert.ToBase64String(buffer); base64 = base64.Replace("+", "-"); base64 = base64.Replace("/", "_"); base64 = base64.Replace("=", ""); return base64; } string code_challenge = base64urlencodeNoPadding(sha256(code_verifier));
原理分析
上面我們說了授權碼攔截攻擊, 它是指在整個授權流程中, 只需要攔截到從授權服務器回調給客戶端的授權碼 code, 就可以去授權服務器申請令牌了, 因為客戶端是公開的, 就算有密鑰 client_secret 也是形同虛設, 惡意程序拿到訪問令牌后, 就可以光明正大的請求資源服務器了。
PKCE 是怎么做的呢? 既然固定的 client_secret 是不安全的, 那就每次請求生成一個隨機的密鑰(code_verifier), 第一次請求到授權服務器的 authorize endpoint時, 攜帶 code_challenge 和 code_challenge_method, 也就是 code_verifier 轉換后的值和轉換方法, 然后授權服務器需要把這兩個參數緩存起來, 第二次請求到 token endpoint 時, 攜帶生成的隨機密鑰的原始值 (code_verifier) , 然后授權服務器使用下面的方法進行驗證:
-
plain
code_challenge = code_verifier -
S256
code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))
通過后才頒發令牌, 那向授權服務器 authorize endpoint 和 token endpoint 發起的這兩次請求,該如何關聯起來呢? 通過 授權碼 code 即可, 所以就算惡意程序攔截到了授權碼 code, 但是沒有 code_verifier, 也是不能獲取訪問令牌的, 當然 PKCE 也可以用在機密(confidential)的客戶端, 那就是 client_secret + code_verifier 雙重密鑰了。
最后看一下請求參數的示例:
GET /oauth2/authorize
https://www.authorization-server.com/oauth2/authorize? response_type=code &client_id=s6BhdRkqt3 &scope=user &state=8b815ab1d177f5c8e &redirect_uri=https://www.client.com/callback &code_challenge_method=S256 &code_challenge=FWOeBX6Qw_krhUE2M0lOIH3jcxaZzfs5J4jtai5hOX4
POST /oauth2/token
Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW Content-Type: application/x-www-form-urlencoded https://www.authorization-server.com/oauth2/token? grant_type=authorization_code &code=d8c2afe6ecca004eb4bd7024 &redirect_uri=https://www.client.com/callback &code_verifier=2D9RWc5iTdtejle7GTMzQ9Mg15InNmqk3GZL-Hg5Iz0
下邊使用 Postman 演示了使用 PKCE 模式的授權過程