在前面的篇章中,我们讲到过授权码模式,也提到了授权模式中存在薄弱的环节,这个环节就是用户在授权页面确认授权的时候,容易受到恶意程序的攻击,从而导致授权码被恶意程序窃取,进而通过授权码窃取令牌(token)。那么本篇就来介绍一下如何使用PKCE(Proof Key for Code Exchange 的缩写)来降低令牌(token)被窃取的风险。

令牌窃取风险

我们在使用授权码模式获取令牌(token)的时候,如果PC或手机被植入了恶意程序,那么恶意程序窃就有可能在下面第(4)点拿到授权码后,进而窃取令牌(token)

Spring Authorization Server (九)授权码+PKCE_OAuth2.1

(1)客户端向认证服务器发起获取授权码请求时,跳转至授权确认页面,用户通过用浏览器在授权页面进行授权确认。

(2)授权页面向认证服务器提交客户端参数和授权范围。

(3)认证服务器将授权码拼接在客户端注册的回调地址中返回给客户端。

(4)在步骤(3)认证服务器返回授权码的过程中,如果恶意程序截取到授权码,那么他接下来就可以继续操作步骤(5)、步骤(6)了。


PKCE解决方案

OAuth 2.0授权码模式中,为了降低令牌(token)被窃取的风险,官方增加了PKCE扩展,交互图如下。

Spring Authorization Server (九)授权码+PKCE_Authorization Server_02

A. 客户端通过“/oauth2/authorize”地址向认证服务器发起获取授权码请求的时候增加两个参数,即code_challenge和code_challenge_method,其中,code_challenge_method是加密方法(例如:S256或plain),code_challenge是使用code_challenge_method加密方法加密后的值。

B. 认证服务器给客户端返回授权码,同时记录下code_challenge、code_challenge_method的值。

C. 客户端使用code向认证服务器获取令牌的时候,带上code_verifier参数,其值为步骤A加密前的初始值。

D. 认证服务器收到步骤C的请求时,将code_verifier的值使用code_challenge_method的方法进行加密,然后将加密后的值与步骤A中的code_challenge进行比较,看看是否一致。

上面步骤A、B是在浏览器中操作的,在认证服务器回调客户端传输授权码(code)的时候,恶意程序可能窃取到授权码,但是步骤C、D是使用api进行操作的,由于恶意程序没有code_verifier值,因此,如果恶意程序也拿着授权码(code)向认证服务器获取令牌(token)的话,将会被拒绝。

对于如何创建code_challenge的值,官网给出了下面两种对应的方法。

plain code_challenge = code_verifier

S256 code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))

在Spring Authorization Server 0.3.0版本中,已经移除了PKCE中code_challenge_method=plain的类型支持,那么当前Spring Authorization Server 也只有code_challenge_method=S256类型了。

Spring Authorization Server (九)授权码+PKCE_Spring Boot3_03

在框架代码中,我们也看到,当code_challenge不为空的时候,校验code_challenge_method类型是否为S256。

Spring Authorization Server (九)授权码+PKCE_Authorization Server_04

S256参数生成

在PKCE中使用S256算法,我们使用生成code_verifier、code_challenge这两个参数,生成的java代码如下(注意:在实际应用中由前端生成)。

package org.oauth.server.utils;

import com.nimbusds.jose.util.Base64URL;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.UUID;

/**
 * @author Rommel
 * @version 1.0
 * @date 2023/7/30-21:45
 * @description TODO
 */
public class PKCEUtils {


    /**
     *
     * @return
     * @author  Rommel
     * @date    2023/7/30-21:49
     * @version 1.0
     * @description  生成 code_verifier
     */
    public static String codeVerifierGenerator(){
        return  Base64URL.encode(UUID.randomUUID().toString()).toString();
    }

    /**
     *
     * @return
     * @author  Rommel
     * @date    2023/7/30-21:47
     * @version 1.0
     * @description  生成 code_challenge
     */
    public static String codeChallengeGenerator(String codeVerifier) throws NoSuchAlgorithmException {
        MessageDigest messageDigest = MessageDigest.getInstance("SHA-256");
        byte[] digestCodeVerifier = messageDigest.digest(codeVerifier.getBytes(StandardCharsets.US_ASCII));
        return Base64URL.encode(digestCodeVerifier).toString();
    }


    public static void main(String[] args) throws NoSuchAlgorithmException {

        //生成code_verifier
        String codeVerifier = codeVerifierGenerator();
        //生成code_challenge
        String codeChallenge = codeChallengeGenerator(codeVerifier);

        System.out.println("code_verifier:"+codeVerifier);
        System.out.println("code_challenge:"+codeChallenge);

    }

}

运行PKCEUtils类的main方法后,得到一组code_verifier、code_challenge的随机值,如下。

code_verifier:ZGJhMjA3ODEtNzE5Zi00OTM5LWE2MzEtNjQwZGMxZjBlNjcw

code_challenge:9V8OP25aaVss2uiXHdADoFBbZXyp4Popb05ec1eCQCA

PKCE测试

oauth2_registered_client表增加一条公共客户端(client_authentication_methods=none)记录如下。

INSERT INTO `oauth-server`.`oauth2_registered_client` (`id`, `client_id`, `client_id_issued_at`, `client_secret`, `client_secret_expires_at`, `client_name`, `client_authentication_methods`, `authorization_grant_types`, `redirect_uris`, `post_logout_redirect_uris`, `scopes`, `client_settings`, `token_settings`) VALUES ('d84e9e7c-abb1-46f7-bb0f-4511af362ca9', 'pkce-client-id', '2023-07-30 10:49:59', '', NULL, 'pkce客户端', 'none', 'refresh_token,authorization_code', 'http://www.baidu.com', '', 'openid,profile', '{\"@class\":\"java.util.Collections$UnmodifiableMap\",\"settings.client.require-proof-key\":true,\"settings.client.require-authorization-consent\":true}', '{\"@class\":\"java.util.Collections$UnmodifiableMap\",\"settings.token.reuse-refresh-tokens\":true,\"settings.token.id-token-signature-algorithm\":[\"org.springframework.security.oauth2.jose.jws.SignatureAlgorithm\",\"RS256\"],\"settings.token.access-token-time-to-live\":[\"java.time.Duration\",3000.000000000],\"settings.token.access-token-format\":{\"@class\":\"org.springframework.security.oauth2.server.authorization.settings.OAuth2TokenFormat\",\"value\":\"self-contained\"},\"settings.token.refresh-token-time-to-live\":[\"java.time.Duration\",36000.000000000],\"settings.token.authorization-code-time-to-live\":[\"java.time.Duration\",3000.000000000],\"settings.token.device-code-time-to-live\":[\"java.time.Duration\",3000.000000000]}');

浏览器输入如下地址。

http://localhost:9000/oauth2/authorize?response_type=code&client_id=pkce-client-id&scope=profile openid&redirect_uri=http://www.baidu.com&code_challenge=hKYZTa6Bx9Vl383qeIUjFpnj3It7IoeIKnNAYv00Vog&code_challenge_method=S256

则跳转到登录页面。

Spring Authorization Server (九)授权码+PKCE_OAuth2.1_05

输入用户名:user、密码:123456,则跳转到授权确认页面。

Spring Authorization Server (九)授权码+PKCE_Authorization Server_06

勾选授权范围,提交确认授权,则返回授权码。

Spring Authorization Server (九)授权码+PKCE_Authorization Server_07

将上面获取到的授权码复制到postman,暂不加入code_verifier参数,postman参数如下。

Spring Authorization Server (九)授权码+PKCE_PKCE_08

则返回下面结果,获取token失败。

Spring Authorization Server (九)授权码+PKCE_PKCE_09

控制台报错如下。

Spring Authorization Server (九)授权码+PKCE_Spring Security_10

postman加入code_verifier参数,则成功返回token。

Spring Authorization Server (九)授权码+PKCE_Spring Boot3_11

总结

本篇先是介绍了授权码模式令牌被窃取的风险,接着介绍了PKCE如何降低令牌被窃取的风险的解决方案,然后介绍了如何使用S256算法生成code_verifier、code_challenge的例子,最后对PKCE中code_challenge_method=S256的类型进行了测试和效果展示。