基于 COLA 架构的 Spring Cloud Alibaba(九) Token 本地认证
  wgpHX6wLnA29 2023年11月02日 33 0

在上一篇中,我们介绍了如何使用 Spring Authorization Server 作为认证服务器进行认证授权,同时也介绍了如何对资源服务器中的资源进行访问保护,以及如何对访问资源服务器携带的令牌(token)进行合法性校验。在校验令牌(token)合法性的过程中,我们采用了连接认证服务器进行远程校验的方式,这种校验方式,需要发起网络请求,给资源服务器和认证服务器都增加了网络 IO 负担。本篇,我们来介绍采用本地认证的方式对令牌(token)合法性进行校验。

1. 算法及流程

1.1. 算法处理

本篇采用 RSA 算法对令牌(token)的生成和校验加以处理。处理方式就是在认证服务器生成 access_token 的时候,给 access_token 增加 rdm 、sign 两个字段,字段说明如下。

rdm:是一串使用 RSA 私钥加密过后的字符串,本篇组装的明文内容为:随机数 + 井号分隔符 + access_token 过期时间 + 井号分隔符 + 当前时间。在实际应用中,也可以根据实际需要,加入其他有意义的字段,例如:userId。

sign:是一串使用 RSA 私钥签名过后的字符串,本篇组装的明文内容为:随机数 + 井号分隔符 + 当前时间。注意:此处的随机数和当前时间,需要与 rdm 的随机数和当前时间保持相同的值。

认证服务器在生成 access_token 的时候,对 rdm 字段的明文使用 RSA 私钥进行加密,对 sign 的明文使用 RSA 私钥进行签名。资源服务器在接收到 access_token 的时候,先使用 JWT 进行解析,然后对 rdm 字段的密文使用 RSA 公钥进行解密,对 access_token 过期时间进行校验,对 sign 的密文使用 RSA 公钥进行验签。

上面的 RSA 密钥对,私钥放在认证服务器上,公钥放在资源服务器上。

1.2. 流程交互

对 access_token 加入 rdm、sign 字段后,前端应用、认证服务器、资源服务器的交互流程如下。

基于 COLA 架构的 Spring Cloud Alibaba(九) Token 本地认证_Token

2. 组件工程

上一篇中,我们对资源服务器进行资源保护和令牌(token)认证时,在资源服务工程中引入了 spring-boot-starter-oauth2-resource-server 组件,该组件需要配置认证服务器的地址进行授权委托。在本篇中,我们将去除 spring-boot-starter-oauth2-resource-server 组件,使用 spring-boot-starter-oauth2-client 组件来保护资源服务器中的资源。由于对 access_token 中 rdm、sign 字段的处理,各资源服务器都是同样的处理方法,而且在实际应用中,也经常会用到 SecurityContextHolder.getContext() 来获取认证的上下文信息, 在此,我们将 spring-boot-starter-oauth2-client 与 RSA 的工具类,打包成为一个组件工程,供各资源服务器依赖使用。

2.1. 创建组件工程

在 mall-component 工程下创建子工程,名称为:mall-component-oauth2。

pom.xml 文件内容如下。

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">

    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.example.component</groupId>
        <artifactId>mall-component</artifactId>
        <version>1.0-SNAPSHOT</version>
    </parent>
    <artifactId>mall-component-oauth2</artifactId>
    <name>mall-component-oauth2</name>
    <description>mybatis组件</description>
    <packaging>jar</packaging>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <spring-boot.version>3.0.2</spring-boot.version>
    </properties>

    <dependencies>
        <!--spring-boot-starter-oauth2-client-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-oauth2-client</artifactId>
            <version>${spring-boot.version}</version>
        </dependency>
        <dependency>
            <groupId>io.projectreactor</groupId>
            <artifactId>reactor-core</artifactId>
            <version>3.5.2</version>
            <scope>compile</scope>
        </dependency>
        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>5.8.19</version>
            <scope>compile</scope>
        </dependency>
        <dependency>
            <groupId>org.apache.tomcat.embed</groupId>
            <artifactId>tomcat-embed-core</artifactId>
            <version>10.1.10</version>
            <scope>compile</scope>
        </dependency>
    </dependencies>
</project>

2.2. 添加算法工具类

在 mall-component-oauth2 工程中创建 org.example.component.oauth2.util 包目录,在该目录下,创建 RSA 工具类 RSAUtils 和 access_token 签名、验签工具类 AccessTokenUtils。

RSAUtils 内容如下。

public class RSAUtils {

    /**
     * 数字签名,密钥算法
     */
    private static final String RSA_KEY_ALGORITHM = "RSA";

    /**
     * 数字签名签名/验证算法
     */
    private static final String SIGNATURE_ALGORITHM = "MD5withRSA";


    /**
     *  私钥加密
     */
    public static String encryptByPriKey(String text, String privateKey) {
        try {
            byte[] priKey = Base64Decoder.decode(privateKey);
            byte[] enSign = encryptByPriKey(text.getBytes(), priKey);
            return Base64Encoder.encode(enSign);
        } catch (Exception e) {
            throw new RuntimeException("加密字符串[" + text + "]时遇到异常", e);
        }
    }

    /**
     *  私钥加密
     */
    public static byte[] encryptByPriKey(byte[] data, byte[] priKey) throws Exception {
        PKCS8EncodedKeySpec pkcs8KeySpec = new PKCS8EncodedKeySpec(priKey);
        KeyFactory keyFactory = KeyFactory.getInstance(RSA_KEY_ALGORITHM);
        PrivateKey privateKey = keyFactory.generatePrivate(pkcs8KeySpec);
        Cipher cipher = Cipher.getInstance(keyFactory.getAlgorithm());
        cipher.init(Cipher.ENCRYPT_MODE, privateKey);
        return cipher.doFinal(data);
    }

    /**
     *  公钥解密
     */
    public static byte[] decryptByPubKey(byte[] data, byte[] pubKey) throws Exception {
        X509EncodedKeySpec x509KeySpec = new X509EncodedKeySpec(pubKey);
        KeyFactory keyFactory = KeyFactory.getInstance(RSA_KEY_ALGORITHM);
        PublicKey publicKey = keyFactory.generatePublic(x509KeySpec);
        Cipher cipher = Cipher.getInstance(keyFactory.getAlgorithm());
        cipher.init(Cipher.DECRYPT_MODE, publicKey);
        return cipher.doFinal(data);
    }

    /**
     *  公钥解密
     */
    public static String decryptByPubKey(String data, String publicKey) throws Exception {
        byte[] pubKey = Base64Decoder.decode(publicKey);;
        byte[] design = decryptByPubKey(Base64Decoder.decode(data), pubKey);
        return new String(design);
    }

    /**
     *  RSA签名
     */
    public static String sign(byte[] data, byte[] priKey) throws Exception {
        // 取得私钥
        PKCS8EncodedKeySpec pkcs8KeySpec = new PKCS8EncodedKeySpec(priKey);
        KeyFactory keyFactory = KeyFactory.getInstance(RSA_KEY_ALGORITHM);
        // 生成私钥
        PrivateKey privateKey = keyFactory.generatePrivate(pkcs8KeySpec);
        // 实例化Signature
        Signature signature = Signature.getInstance(SIGNATURE_ALGORITHM);
        // 初始化Signature
        signature.initSign(privateKey);
        // 更新
        signature.update(data);
        return Base64Encoder.encode(signature.sign());
    }

    /**
     *  RSA校验数字签名
     */
    public static boolean verify(byte[] data, byte[] sign, byte[] pubKey) throws Exception {
        // 实例化密钥工厂
        KeyFactory keyFactory = KeyFactory.getInstance(RSA_KEY_ALGORITHM);
        // 初始化公钥
        X509EncodedKeySpec x509KeySpec = new X509EncodedKeySpec(pubKey);
        // 产生公钥
        PublicKey publicKey = keyFactory.generatePublic(x509KeySpec);
        // 实例化Signature
        Signature signature = Signature.getInstance(SIGNATURE_ALGORITHM);
        // 初始化Signature
        signature.initVerify(publicKey);
        // 更新
        signature.update(data);
        // 验证
        return signature.verify(sign);
    }
}

AccessTokenUtils 内容如下。

public class AccessTokenUtils {

    private final static Log logger = LogFactory.getLog(AccessTokenUtils.class);

    /**
     * 随机数分隔符
     */
    public static final String RDM_SEP = "#";

    /**
     *  AccessToken 签名
     */
    public static JwtClaimsSet.Builder signAccessToken(JwtClaimsSet.Builder claims,String privateKey){

        String uuIdStr = UUID.randomUUID().toString();
        long currentTimeMillis = System.currentTimeMillis();

        String rdmSource = uuIdStr + RDM_SEP + claims.build().getExpiresAt().getEpochSecond() + RDM_SEP + currentTimeMillis;
        String rdmTarget = RSAUtils.encryptByPriKey(rdmSource,privateKey);
        String signSource = uuIdStr + RDM_SEP + currentTimeMillis;;
        String signature = "";
        try {
            signature = RSAUtils.sign(signSource.getBytes(), Base64Decoder.decode(privateKey));
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
        claims.claim("rdm",rdmTarget);
        claims.claim("sign",signature);
        
        return claims;
    }

    /**
     * 验签
     */
    public static boolean verifyAccessToken(String authorizationToken,String publicKey){

        String accessToken = authorizationToken.replace("Bearer ","");
        JWT jwtToken = JWTUtil.parseToken(accessToken);
        JWTPayload jwtPayload = jwtToken.getPayload();

        String rdm = jwtPayload.getClaim("rdm")+"";
        String sign = jwtPayload.getClaim("sign")+"";
        //解密
        String rdmSource = "";
        try {
            rdmSource = RSAUtils.decryptByPubKey(rdm,publicKey);
        } catch (Exception e) {
            logger.info("rdm解密失败,rdm="+rdm);
        }
        String[] rdmData =rdmSource.split(RDM_SEP);
        long exp = Long.parseLong(rdmData[1]);
        long now = Instant.now().getEpochSecond();
        if(exp<now){
            logger.info("accessToken已过期,exp="+exp);
            return false;
        }
        String signSource = rdmData[0] + RDM_SEP + rdmData[2];;
        //验签
        boolean verifyResult = false;
        try {
            verifyResult = RSAUtils.verify(signSource.getBytes(), Base64Decoder.decode(sign),Base64Decoder.decode(publicKey));
        } catch (Exception e) {
            logger.info("accessToken验签异常,accessToken="+accessToken,e);
        }
        if(!verifyResult){
            logger.info("accessToken验签不通过,accessToken="+accessToken);
            return false;
        }
        
        return true;
    }
}

2.3. 添加鉴权管理器

在 mall-component-oauth2 工程中创建 org.example.component.oauth2.authorization 包目录,在该目录下,分别创建网关服务鉴权管理器 WebFluxAuthorizationManager、应用服务的鉴权管理器 WebMvcAuthorizationManager。

WebFluxAuthorizationManager 内容如下。

public class WebFluxAuthorizationManager implements ReactiveAuthorizationManager<AuthorizationContext> {

    @Value("${authorization.accesstoken.rsa.key.public}")
    private String publicKey;
    private final Log logger = LogFactory.getLog(getClass());

    @Override
    public Mono<AuthorizationDecision> check(Mono<Authentication> mono, AuthorizationContext authorizationContext) {

        ServerWebExchange exchange = authorizationContext.getExchange();

        String authorizationToken = exchange.getRequest().getHeaders().getFirst(HttpHeaders.AUTHORIZATION); // 从Header里取出token的值
        if (!StringUtils.hasText(authorizationToken)) {
            logger.warn("当前请求头Authorization中的值不存在");
            return Mono.just(new AuthorizationDecision(false));
        }
        boolean verifyResult = AccessTokenUtils.verifyAccessToken(authorizationToken,publicKey);
        if(!verifyResult){
            return Mono.just(new AuthorizationDecision(false));
        }

        return Mono.just(new AuthorizationDecision(true));
    }
}

WebMvcAuthorizationManager 内容如下。

public class WebMvcAuthorizationManager implements AuthorizationManager<RequestAuthorizationContext> {

    @Value("${authorization.accesstoken.rsa.key.public}")
    private String publicKey;
    private final Log logger = LogFactory.getLog(getClass());
    @Override
    public AuthorizationDecision check(Supplier<Authentication> authentication, RequestAuthorizationContext object) {
        HttpServletRequest request = object.getRequest();
        String authorizationToken = request.getHeader(HttpHeaders.AUTHORIZATION);
        if(!StringUtils.hasText(authorizationToken)){
            return new AuthorizationDecision(false);
        }

        boolean verifyResult = AccessTokenUtils.verifyAccessToken(authorizationToken,publicKey);
        if(!verifyResult){
            return new AuthorizationDecision(false);
        }

        return new AuthorizationDecision(true);
    }
}

2.4. 工程结构

mall-component-oauth2 工程结构如下所示。

基于 COLA 架构的 Spring Cloud Alibaba(九) Token 本地认证_Spring Boot3_02

3. 服务改造

首先,我们先查看一下,改造前的 access_token 字段组成,使用密码模式获取 token,如下所示。

基于 COLA 架构的 Spring Cloud Alibaba(九) Token 本地认证_COLA_03

使用 JWT 工具对 access_token 进行解析,结果如下。

基于 COLA 架构的 Spring Cloud Alibaba(九) Token 本地认证_Token_04

然后,生成 RSA 密钥对(认证服务器 AuthorizationServerConfig 有密钥对生成方法) PrivateKey 和 PublicKey,PrivateKey 给认证服务器使用,PublicKey 资源服务器使用。

我们对服务的改造,需要给 access_token 加入 rdm、sign 字段。关于 rdm、sign 字段加密、解密、签名、验签的方法,都封装在 mall-component-oauth2 组件中,认证服务器、资源服务器只需添加如下依赖即可使用。

<dependency>
  <groupId>org.example.component</groupId>
  <artifactId>mall-component-oauth2</artifactId>
  <version>1.0-SNAPSHOT</version>
  <scope>compile</scope>
</dependency>

3.1. 认证服务改造

添加 mall-component-oauth2 依赖,在 AuthorizationServerConfig 中添加 RSA 私钥注入,如下。

@Value("${authorization.accesstoken.rsa.key.private}")
private String accessTokenRsaPrivateKey;

然后在 AuthorizationServerConfig 的 jwtCustomizer 方法,添加”AccessTokenUtils.signAccessToken(claims,accessTokenRsaPrivateKey);“一行代码即可。 jwtCustomizer 方法代码如下。

@Bean
    public OAuth2TokenCustomizer<JwtEncodingContext> jwtCustomizer(MyOidcUserInfoService myOidcUserInfoService) {

        return context -> {
            JwsHeader.Builder headers = context.getJwsHeader();
            JwtClaimsSet.Builder claims = context.getClaims();
            if (context.getTokenType().equals(OAuth2TokenType.ACCESS_TOKEN)) {
                //客户端模式不参与用户权限信息处理
                if(!AuthorizationGrantType.CLIENT_CREDENTIALS.equals(context.getAuthorizationGrantType())){
                    // Customize headers/claims for access_token
                    claims.claims(claimsConsumer->{
                        UserDetails userDetails = userDetailsService.loadUserByUsername(context.getPrincipal().getName());
                        claimsConsumer.merge("scope",userDetails.getAuthorities(),(scope,authorities)->{
                            Set<String> scopeSet = (Set<String>)scope;
                            Set<String> cloneSet = scopeSet.stream().map(String::new).collect(Collectors.toSet());
                            Collection<SimpleGrantedAuthority> simpleGrantedAuthorities = ( Collection<SimpleGrantedAuthority>)authorities;
                            simpleGrantedAuthorities.stream().forEach(simpleGrantedAuthority -> {
                                if(!cloneSet.contains(simpleGrantedAuthority.getAuthority())){
                                    cloneSet.add(simpleGrantedAuthority.getAuthority());
                                }
                            });
                            return cloneSet;
                        });
                    });
                }
                //给 AccessToken 添加签名信息
                AccessTokenUtils.signAccessToken(claims,accessTokenRsaPrivateKey);
            } else if (context.getTokenType().getValue().equals(OidcParameterNames.ID_TOKEN)) {
                // Customize headers/claims for id_token
                claims.claim(IdTokenClaimNames.AUTH_TIME, Date.from(Instant.now()));
                StandardSessionIdGenerator standardSessionIdGenerator = new StandardSessionIdGenerator();
                claims.claim("sid", standardSessionIdGenerator.generateSessionId());
            }
        };
    }

3.2. 资源服务改造

3.2.1. 网关服务改造

网关服务,移除 spring-boot-starter-oauth2-resource-server 依赖,添加 mall-component-oauth2 依赖,在 AuthorizationClientConfig 中对 WebFluxAuthorizationManager 鉴权管理进行注入并使用即可,如下所示。

@Configuration
@EnableWebFluxSecurity
@EnableReactiveMethodSecurity
public class AuthorizationClientConfig {

    @Bean
    WebFluxAuthorizationManager webFluxAuthorizationManager(){
        return new WebFluxAuthorizationManager();
    }

    @Bean
    public SecurityWebFilterChain authorizationClientSecurityFilterChain(ServerHttpSecurity http) throws Exception {

        //uri放行
        String[] ignoreUrls = new String[]{"/oauth2/**","/*.html","/favicon.ico","/webjars/**","/v3/api-docs/swagger-config","/*/v3/api-docs**"};
        //禁用csrf与cors
        http.csrf().disable();
        http.cors().disable();
        //客户端设置
        http
                .authorizeExchange(authorize ->
                        authorize.pathMatchers(ignoreUrls).permitAll()
                                //.anyExchange().authenticated()
                                // 鉴权管理器配置
                                .anyExchange().access(webFluxAuthorizationManager())
                );

        return http.build();
    }
}

3.2.2. 应用服务改造

账户服务、商品服务、订单服务,移除 spring-boot-starter-oauth2-resource-server 依赖,添加 mall-component-oauth2 依赖,在 AuthorizationClientConfig 中对 WebMvcAuthorizationManager 鉴权管理进行注入并使用即可,如下所示。

@Configuration
@EnableWebSecurity
@EnableMethodSecurity(jsr250Enabled = true, securedEnabled = true)
public class AuthorizationClientConfig {

	@Bean
	WebMvcAuthorizationManager webMvcAuthorizationManager(){
		return new WebMvcAuthorizationManager();
	}

	@Bean
	public SecurityFilterChain securityFilterChain(HttpSecurity http)
			throws Exception {
		//uri放行
		String[] ignoreUrls = new String[]{"/*.html","/favicon.ico","/webjars/**","/*/v3/api-docs**","/v3/api-docs/**"};
		http.authorizeHttpRequests(authorize ->
						authorize.requestMatchers(ignoreUrls).permitAll()
								// 鉴权管理器配置
                				.anyRequest().access(webMvcAuthorizationManager())
				);
		return http.build();
	}
}

4. 流程测试

启动认证服务、网关服务、账户服务、商品服务、订单服务,对前后端交互流程进行测试。以账户服务为例,我们来测试一下”根据 id 查询账户信息“接口。

4.1. 检查 access_token

使用密码模式获取 token 如下。

基于 COLA 架构的 Spring Cloud Alibaba(九) Token 本地认证_Spring Boot3_05

使用 JWT 工具对 access_token 进行解析,结果如下。

基于 COLA 架构的 Spring Cloud Alibaba(九) Token 本地认证_Spring Boot3_06

可以看到,access_token 已经加入了 rdm、sign 字段。

4.2. 不传 access_token

4.2.1. 通过网关访问

在 postman 中输入地址:http://localhost:7020/web/v1/account/queryById/1693333896005545986,不传 token,向网关服务发起接口请求,结果如下。

基于 COLA 架构的 Spring Cloud Alibaba(九) Token 本地认证_Spring Boot3_07

可以看到,返回 401 状态。

4.2.2. 直接访问应用

在 postman 中输入地址:http://localhost:7030/web/v1/account/queryById/1693333896005545986,不传 token,向账户服务发起接口请求,结果如下。

基于 COLA 架构的 Spring Cloud Alibaba(九) Token 本地认证_Token_08

可以看到,返回的是 403 状态,也是拒绝访问。

4.3. 传入 access_token

4.3.1. 通过网关访问

在 postman 中输入地址:http://localhost:7020/web/v1/account/queryById/1693333896005545986,传入 token,向网关服务发起接口请求,结果如下。

基于 COLA 架构的 Spring Cloud Alibaba(九) Token 本地认证_Spring Cloud_09

可以看到,正常返回接口数据。

4.3.2. 直接访问应用

在 postman 中输入地址:http://localhost:7030/web/v1/account/queryById/1693333896005545986,传入 token,向账户服务发起接口请求,结果如下。

基于 COLA 架构的 Spring Cloud Alibaba(九) Token 本地认证_Spring Cloud_10

可以看到,同样正常返回接口数据。

5. 总结

本篇先介绍了 Token 本地认证的算法和流程。接着介绍了算法工具类和鉴权管理器的组件封装。然后介绍了认证服务、资源服务的改造。最后对前端应用、认证服务器、资源服务器的交互流程进行了测试验证。


基础篇项目代码:链接地址

【版权声明】本文内容来自摩杜云社区用户原创、第三方投稿、转载,内容版权归原作者所有。本网站的目的在于传递更多信息,不拥有版权,亦不承担相应法律责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@moduyun.com

  1. 分享:
最后一次编辑于 2023年11月08日 0

暂无评论

推荐阅读
wgpHX6wLnA29