OAuth 2.0 擴充套件協議之 PKCE

微軟技術棧發表於2021-11-15

前言

閱讀本文前需要了解 OAuth 2.0 授權協議的相關內容, 可以參考我的上一篇文章 OAuth 2.0 的探險之旅

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_challengecode_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 模式的授權過程

References

https://www.rfc-editor.org/rfc/rfc6749
https://www.rfc-editor.org/rfc/rfc7636.html
https://oauth.net/2/pkce
https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-04

? 歡迎關注微信公眾號【全球技術精選】

OAuth 2.0 擴充套件協議之 PKCE OAuth 2.0 擴充套件協議之 PKCE

相關文章