大家好,我是本期的实验室研究员——等天黑。今天我们的研究对象是OAuth扩展协议PKCE,其中在OAuth 2.1草案中, 推荐使用Authorization Code + PKCE的授权模式, PKCE为什么如此重要? 接下来就让我们一起到实验室中一探究竟吧!
前言
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模式的授权过程。
参考文献
-
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
微软最有价值专家(MVP)
微软最有价值专家是微软公司授予第三方技术专业人士的一个全球奖项。28年来,世界各地的技术社区领导者,因其在线上和线下的技术社区中分享专业知识和经验而获得此奖项。
MVP是经过严格挑选的专家团队,他们代表着技术最精湛且最具智慧的人,是对社区投入极大的热情并乐于助人的专家。MVP致力于通过演讲、论坛问答、创建网站、撰写博客、分享视频、开源项目、组织会议等方式来帮助他人,并最大程度地帮助微软技术社区用户使用Microsoft技术。
更多详情请登录官方网站:
Microsoft MVP Award