17.Oauth2-微服务认证

1.Oauth2

OAuth 2.0授权框架支持第三方支持访问有限的HTTP服务,通过在资源所有者和HTTP服务之间进行一个批准交互来代表资源者去访问这些资源,或者通过允许第三方应用程序以自己的名义获取访问权限。

image-20220506121118022

为了方便理解,可以想象OAuth2.0就是在用户资源和第三方应用之间的一个中间层,它把资源和第三方应用隔开,使得第三方应用无法直接访问资源,从而起到保护资源的作用。

为了访问这种受保护的资源,第三方应用(客户端)在访问的时候需要提供凭证。即,需要告诉OAuth2.0你是谁你要做什么。

用户可以将用户名和密码告诉第三方应用,让第三方应用直接以你的名义去访问,也可以授权第三方应用去访问。

例如,微信公众平台开发,在微信公众平台开发过程中当我们访问某个页面,页面可能弹出一个提示框应用需要获取我们的个人信息问是否允许,点确认其实就是授权第三方应用获取我们在微信公众平台的个人信息,这里微信网页授权就是使用的OAuth2.0。

  • 第三方应用程序(Third-party application): 又称之为客户端(client),我们自己开发的各种客户端,对我们自己的项目来说,QQ、微信、支付宝等是第三方应用程序。

  • HTTP 服务提供商(HTTP service): 我们开发的项目以及 QQ、微信、支付宝、钉钉等都可以称之为“服务提供商”。

  • 资源所有者(Resource Owner): 又称之为用户(user),拥有账号密码的人。

  • 用户代理(User Agent): 用来访问资源,比如浏览器,代替用户去访问这些资源。

  • 认证服务器(Authorization server): 即服务提供商专门用来处理认证的服务器,主要就是实现登录、授权功能。

  • 资源服务器(Resource server): 即服务提供商存放用户生成的资源的服务器,比如电商中的商品模块、订单模块等,是用来处理具体业务的服务器。

        OAuth2.0协议流程描述了四种角色之间的交互过程,如下图所示。

image-20220506121307806

        简单说,OAuth 就是一种授权机制。数据的所有者告诉系统,同意授权第三方应用进入系统,获取这些数据。系统从而产生一个短期的进入令牌(token),用来代替密码,供第三方应用使用。

令牌(token)与密码(password)的作用是一样的,都可以进入系统,但是有三点差异。

  • 令牌是短期的,到期会自动失效,用户自己无法修改。密码一般长期有效,用户不修改,就不会发生变化。

  • 令牌可以被数据所有者撤销,会立即失效。

  • 令牌有权限范围(scope),对于网络服务来说,只读令牌就比读写令牌更安全。密码一般是完整权限。

上面这些设计,保证了令牌既可以让第三方应用获得权限,同时又随时可控,不会危及系统安全。这就是 OAuth 2.0 的优点。

注意,只要知道了令牌,就能进入系统。系统一般不会再次确认身份,所以令牌必须保密,泄漏令牌与泄漏密码的后果是一样的。 这也是为什么令牌的有效期,一般都设置得很短的原因。

1.1 开放平台

        开放平台(Open Platform)在软件行业和网络中,开放平台是指软件系统通过公开其应用程序编程接口(API)或函数(function)来使外部的程序可以增加该软件系统的功能或使用该软件系统的资源,而不需要更改该软件系统的源代码。

        在互联网时代,把网站的服务封装成一系列计算机易识别的数据接口开放出去,供第三方开发者使用,这种行为就叫做Open API,提供开放API的平台本身就被称为开放平台。

        第一种是技术性的开放,例如百度、腾讯、阿里巴巴等,例如阿里可以提供标准化的应用软件,但是数百万形形色色的卖家对于个性化要求的软件,并不是一个公司的力量可以满足的,所以就把这些需求开放给众多的第三方开发者的方式。再例如google的基于Linux平台的开源手机操作系统就被认为会很快打败Nokia塞班系统。这一种技术性开放平台虽然目前来看跟B2C企业的开放平台关系不大,但是也能从一定程度上说明开放平台是互联网企业的趋势。

        第二种开放平台是指软件系统通过公开其应用程序编程接口(API)或函数(function)来使外部的程序可以增加该软件系统的功能或使用该软件系统的资源,而不需要更改该软件系统的源代码。B2C企业开放平台又包含两种形式,A:淘宝商城、日本乐天这种纯平台的模式,即自己不碰商品的进销存,全部由入驻商家来做;B:美国亚马逊、当当网、京东商城这种“自营+联营”的模式。

1.2 开放平台交互模型

三个角色:

  • 资源拥有者:用户

  • 客户端:各种app、浏览器

  • 服务提供方:包含两个角色

    认证服务器

    资源服务器

1.2.1 认证服务器

认证服务器负责对用户进行认证,并授权给客户端权限。一般的认证都是通过对账号密码进行验证实现,而难点在于怎么进行授权。比如我们使用第三方登录 "哔哩哔哩",可以看到如使用 QQ 登录的授权页面上有 "哔哩哔哩将获取以下权限" 的字样以及权限信息

image-20220506121510936

image-20220506121520612

认证服务器需要知道请求授权的客户端的身份以及该客户端请求的权限。常见的做法是为每一个客户端预先分配一个 id,并给每个 id 对应一个名称以及权限信息。这些信息可以写在认证服务器上的配置文件里,今后客户端每次打开授权页面的时候,客户端需要将该id发送到认证服务器,0Auth2.0就可以用来自动给客户端分配id,同时完成配置文件的自动更新。

1.3 OAuth2 开放平台

开放平台是由 OAuth2.0 协议发展而来的一个产品,它的作用是让客户端自己去这上面进行注册、申请,通过之后系统自动分配 客户端id ,并完成配置的自动更新。

客户端要完成申请,通常需要申请人填写客户端程序的类型(Web、App、微信小程序、支付宝小程序等等)、企业信息、营业执照、法人信息以及想要获取权限等信息,申请需要得到得到服务提供上的审核通过之后,开发平台才会自动分配一个客户端id给客户端。

在通过审核之后,第三方应用在进行认证时,就会想需要获取到的权限信息展示到页面上,例如哔哩哔哩获取QQ权限。授权成功之后认证服务器需要把产生的 access_token 发送给客户端,客户端才能访问具体的资源(头像、性别之类的),大致过程如下:

  • 让客户端在开放平台提交申请时候,填写一个 网址,例如:www.baidu.com,此网址主要用来获取认证码。

  • 当有用户授权成功之后,认证服务器将页面重定向到这个网址,并将生成的 access_token拼接到该网址后面,例如:www.baidu.com?access_token=123 

  • 客户端接收到access_token,之后客户端就可以拿着这个token去获取需要的数据了

1.3.1 令牌

传统项目向服务端请求数据,服务端需要频繁的去数据库查询用户名和密码并进行对比,判断用户名和密码正确与否,并作出相应提示,这样效率非常低下,怎么提高效率呢?Token便应运而生。

Token是服务端生成的一串字符串,以作客户端进行请求的一个令牌,当第一次登录后,服务器生成一个Token便将此Token返回给客户端,以后客户端只需带上这个Token前来请求数据即可,无需再次带上用户名和密码。减轻服务器的压力,减少频繁的查询数据库,使服务器更加健壮。

1.3.2 Access Token

Access Token 是客户端访问资源服务器的令牌。拥有这个令牌代表着得到用户的授权,即具备了访问资源的权限。同时这个授权应该是临时的,只能在一定期限内使用。主要原因是因为Access Token 在使用的过程中很有可能会泄露,被不法分子利用获取我们的数据。所以Access Token应该只能在某个期限内使用,这样可以降低因 Access Token 泄露而带来的风险。

1.4 认证模式

OAuth2.0中定义了四种授权模式:

  • authorization code 授权码模式

  • implicit 简化模式

  • resource owner password credentials 密码模式

  • client credentials 客户端模式

常见模式:授权码、密码模式

1.4.1 授权码模式

授权码模式(authorization code)是功能最完整、流程最严密的授权模式,code保证了token的安全性,即使code被拦截,由于没有secret,也是无法通过code获得token的。

角色行为与功能
  • 资源所有者

    只需要允许或拒绝第三方应用获得授权

  • 第三方应用

    申请成为资源服务器的第三方应用

    获取资源服务器提供的资源

  • 授权服务器

    提供授权许可code、令牌token等

  • 资源服务器

    提供给第三方应用开放资源的接口

image-20230423093957585

时序图

image-20230423094544416

环境搭建

创建父项目

image-20220506141039544

image-20220506141533483

指定打包方式为pom

<?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><groupId>com.woniuxy</groupId><artifactId>oauth2</artifactId><version>1.0-SNAPSHOT</version><packaging>pom</packaging>
</project>

创建auth-server认证服务器模块

image-20220506141646384

image-20220506141740947

导入依赖

image-20220506141849136

导入依赖版本如下

<properties><java.version>1.8</java.version><project.build.sourceEncoding>UTF-8</project.build.sourceEncoding><project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding><spring-boot.version>2.3.7.RELEASE</spring-boot.version><spring-cloud-alibaba.version>2.2.2.RELEASE</spring-cloud-alibaba.version>
</properties>

oauth2依赖

<dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-oauth2</artifactId><version>2.2.4.RELEASE</version>
</dependency>

创建用户信息配置类

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;@Configuration
@EnableWebSecurity
public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter{//密码编码器@Beanpublic BCryptPasswordEncoder passwordEncoder() {return new BCryptPasswordEncoder();}// 基于内存的用户信息@Overrideprotected void configure(AuthenticationManagerBuilder auth) throws Exception {auth.inMemoryAuthentication()	//内存认证.withUser("zhangsan")		//用户名.password(passwordEncoder().encode("123"))	//密码.authorities("ROLE_ADMIN");	//角色}
}

创建客户端配置类,配置客户端信息

import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;import javax.annotation.Resource;@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfiguration extends AuthorizationServerConfigurerAdapter{@Resourceprivate BCryptPasswordEncoder passwordEncoder;@Overridepublic void configure(ClientDetailsServiceConfigurer clients) throws Exception {//配置客户端clients.inMemory()		//内存方式.withClient("client")	//客户端名字.secret(passwordEncoder.encode("secret"))	//客户端秘钥.authorizedGrantTypes("authorization_code")//授权类型.scopes("all")	//授权范围.redirectUris("http://www.baidu.com");	//回调网址,携带授权码}
}

在application.yml文件中配置以下信息

server:port: 8000
spring:application:name: oauth

启动项目进行登录

localhost:8000/login

进入登录页面,输入账号:zhangsan,密码:123进行登录

image-20220506143057117

登录成功之后向服务器发送请求获取授权码,在地址栏上输入以下内容回车

http://localhost:8080/oauth/authorize?client_id=client&response_type=code

可以看到一个授权页面,询问用户是否进行授权

image-20220506143330317

授权成功之后会重定向到AuthorizationServerConfiguration配置类中指定的地址,并以参数的方式携带授权码

通过postman发送请求向服务器获取token

地址栏填写:http://client:secret@localhost:8000/oauth/token

填写客户端账号密码

image-20230831110955586

填写授权类型、授权码,发送请求

image-20220506145021696

成功之后在postman上可以看到以下信息

image-20220506145131136

表示成功

注意:每个授权码只能使用一次

1.4.2 密码模式

密码模式(Resource Owner Password Credentials Grant)中,用户向客户端提供自己的用户名和密码。客户端使用这些信息,向"服务商提供商"索要授权。

在这种模式中,用户必须把自己的密码给客户端,但是客户端不得储存密码。这通常用在用户对客户端高度信任的情况下,比如客户端是操作系统的一部分,或者由一个著名公司出品。而认证服务器只有在其他授权模式无法执行的情况下,才能考虑使用这种模式。

修改AuthorizationServerConfiguration配置类,添加密码模式

@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {//配置客户端clients.inMemory()		.withClient("client").secret(passwordEncoder.encode("secret")).authorizedGrantTypes("authorization_code","password") //添加密码授权模式.scopes("all")	//授权范围.redirectUris("http://www.woniuxy.com");
}

在postman中新开一个请求,地址栏中填写:http://localhost:8080/oauth/token

密码授权模式要求以请求头的方式提交客户端账号密码,并且需要对账号密码进行base64加密,因此选择Authorization选项卡,设置TYPE为"Basic Auth",并填写客户端账号密码

image-20220506151829604

在请求体中设置授权类型、用户账号密码参数

image-20220506151940710

发送请求测试

image-20220506152019225

可以发现此时并不支持密码模式,即使在AuthorizationServerConfiguration配置类中指定了密码模式。

原因是此时代码中缺少对密码模式的支持,在oauth2中需要添加AuthenticationManager对象对密码模式进行支持。

在WebSecurityConfiguration配置类中配置 AuthenticationManager

// 配置 AuthenticationManager(密码模式需要该对象进行账号密码校验)
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {return super.authenticationManagerBean();
}

在AuthorizationServerConfiguration类中注入AuthenticationManager,并重写以下方法

// 认证管理器
@Autowired
private AuthenticationManager authenticationManager;//配置使用的 AuthenticationManager 实现用户认证的功能
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {endpoints.authenticationManager(authenticationManager);
}

重启项目再次发送请求获取token

image-20220506152551071

整合JWT

导入了oauth2依赖就自动导入的JWT相关依赖,因此不用单独导入JWT,只需要进行设置就行

创建TokenConfiguration配置类

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
import org.springframework.security.oauth2.provider.token.store.JwtTokenStore;@Configuration
public class TokenConfiguration {// 密码private static String SIGNING_KEY="www.woniuxy.com";// token转换器@Beanpublic JwtAccessTokenConverter accessTokenConverter() {JwtAccessTokenConverter jwtAccessTokenConverter = new JwtAccessTokenConverter();jwtAccessTokenConverter.setSigningKey(SIGNING_KEY);return jwtAccessTokenConverter;}// 令牌存储策略:jwt方式@Beanpublic TokenStore tokenStore(){return new JwtTokenStore(accessTokenConverter());}
}

在AuthorizationServerConfiguration配置类中注入相关对象

@Resource
private TokenStore tokenStore;@Resource
private JwtAccessTokenConverter jwtAccessTokenConverter;@Resource
private ClientDetailsService clientDetailsService;

在AuthorizationServerConfiguration配置类中编写token服务方法,该方法主要用来设置

private AuthorizationServerTokenServices tokenServices(){// 创建服务对象DefaultTokenServices services = new DefaultTokenServices();// 设置客户端详情服务services.setClientDetailsService(clientDetailsService);// 支持刷新令牌services.setSupportRefreshToken(true);// 不重复使用refreshtoken,每次刷新之后只能用新的refreshtoken才能继续刷新services.setReuseRefreshToken(false);// 设置令牌存储策略services.setTokenStore(tokenStore);// 设置令牌增强TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();tokenEnhancerChain.setTokenEnhancers(Arrays.asList(jwtAccessTokenConverter));services.setTokenEnhancer(tokenEnhancerChain);// 设置令牌过期时间services.setAccessTokenValiditySeconds(600);services.setRefreshTokenValiditySeconds(6000);return services;
}

修改configure(AuthorizationServerEndpointsConfigurer endpoints)方法,添加token服务

//配置使用的 AuthenticationManager 实现用户认证的功能
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {endpoints.authenticationManager(authenticationManager) // 认证管理器.tokenServices(tokenServices());	// 配置token服务
}

重启项目发送请求获取token

image-20220506160230848

如果想要获取到refreshtoken,可以修改AuthorizationServerConfiguration配置类,添加refresh_token授权方式

@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {//配置客户端clients.inMemory()		.withClient("client").secret(passwordEncoder.encode("secret"))	.authorizedGrantTypes("authorization_code","password","refresh_token") .scopes("all").redirectUris("http://www.woniuxy.com");
}

重启项目测试

image-20220506161053399

image-20220506161135294

整合数据库(user)

建表SQL

create database sc default character set=utf8;DROP TABLE IF EXISTS `perms`;
CREATE TABLE `perms` (`id` int(11) DEFAULT NULL,`name` varchar(20) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8;INSERT INTO `perms` VALUES (3001,'user:add'),(3002,'user:del'),(3003,'user:find'),(3004,'user:update'),(3005,'goods:add'),(3006,'goods:find'),(3007,'goods:del'),(3008,'goods:update');DROP TABLE IF EXISTS `role`;
CREATE TABLE `role` (`id` int(11) DEFAULT NULL,`name` varchar(20) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8;INSERT INTO `role` VALUES (2001,'ROLE_ADMIN'),(2002,'ROLE_USER');DROP TABLE IF EXISTS `role_perms`;
CREATE TABLE `role_perms` (`rid` int(11) DEFAULT NULL,`pid` int(11) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8;INSERT INTO `role_perms` VALUES (2001,3001),(2001,3003),(2001,3004),(2002,3005),(2002,3006),(2002,3007),(2002,3008);DROP TABLE IF EXISTS `user`;
CREATE TABLE `user` (`id` int(11) DEFAULT NULL,`username` varchar(20) DEFAULT NULL,`password` varchar(64) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8;INSERT INTO `user` VALUES (1001,'zhangsan','$2a$10$pINVnd8.cXScFXCxI2x4cem4fOexA2J5TNY/Mx2CjN6mJuYGBNG0m'),(1002,'wangwu','wangwu');DROP TABLE IF EXISTS `user_role`;
CREATE TABLE `user_role` (`uid` int(11) DEFAULT NULL,`rid` int(11) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8;INSERT INTO `user_role` VALUES (1001,2001),(1002,2002),(1003,2002);

auth-server的pom.xml中引入mybatis

<!--mybatis-->
<dependency><groupId>org.mybatis.spring.boot</groupId><artifactId>mybatis-spring-boot-starter</artifactId><version>2.1.4</version>
</dependency>

在application.yml中配置mybatis参数

mybatis:type-aliases-package: com.woniuxy.authserver.entitymapper-locations: classpath:/mapper/*.xml

创建Perms、Role、User实体类,注意:实体类必须实现序列化接口,不然运行过程中可能会报Failed to find access token for token错误

import lombok.Data;@Data
public class Perms implements Serializable {private static final long serialVersionUID = 1L;private int id;private String name;
}
import lombok.Data;
import java.util.List;@Data
public class Role implements Serializable {private static final long serialVersionUID = 1L;private int id;private String name;private List<Perms> perms;
}
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;import java.io.Serializable;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;@Slf4j
@Data
@NoArgsConstructor
@AllArgsConstructor
public class User implements UserDetails, Serializable {private static final long serialVersionUID = 1L;private int id;private String username;private String password;private List<Role> roles;// 返回当前用户的所有角色、权限信息@Overridepublic Collection<? extends GrantedAuthority> getAuthorities() {log.debug("获取用户角色权限信息");// 新建集合List<GrantedAuthority> grantedAuthorities = new ArrayList<>();// 遍历rolefor(Role role : this.roles){// 放入角色信息grantedAuthorities.add(new SimpleGrantedAuthority(role.getName()));// 遍历当前角色的所有权限信息for(Perms perms : role.getPerms()){grantedAuthorities.add(new SimpleGrantedAuthority(perms.getName()));}}log.debug(grantedAuthorities.toString());return grantedAuthorities;}// 获取用户名@Overridepublic String getUsername() {return this.username;}// 账号是否过期    true表示未过期   false表示过期@Overridepublic boolean isAccountNonExpired() {return true;}// 账号是否被锁定  true表示未锁定   false表示锁定@Overridepublic boolean isAccountNonLocked() {return true;}// 凭证是否过期  true表示未过期   false表示过期@Overridepublic boolean isCredentialsNonExpired() {return true;}// 用户是否被禁用  true表示未禁用   false表示禁用@Overridepublic boolean isEnabled() {return true;}
}

创建UerMapper接口

import com.woniuxy.springsecurity.entity.User;
import org.apache.ibatis.annotations.Mapper;@Mapper
public interface UserMapper {public User findByName(String username);
}

在resources目录下创建mapper文件夹,并在该文件夹下创建Mapper文件

image-20220416173702294

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.woniuxy.authserver.mapper.UserMapper" ><select id="findByName" resultMap="user_map">select * from user where username = #{username}</select><resultMap id="user_map" type="User"><id column="id" property="id"></id><result column="username" property="username"></result><result column="password" property="password"></result><collection property="roles" ofType="Role" column="id" select="findRolesByUid"></collection></resultMap><select id="findRolesByUid" resultMap="role_map">select r.id,r.name from user_role ur,role r where ur.rid = r.id and ur.uid = #{id}</select><resultMap id="role_map" type="Role"><id column="id" property="id"></id><result column="name" property="name"></result><collection property="perms" ofType="Perms" column="id" select="findPermsByRid"></collection></resultMap><select id="findPermsByRid" resultType="Perms">select p.id,p.name from role_perms rp,perms p where rp.pid = p.id and rp.rid = #{rid}</select>
</mapper>

创建CustomUserDetailsServiceImpl类实现UserDetailsService接口

import com.woniuxy.authserver.entity.User;
import com.woniuxy.authserver.mapper.UserMapper;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;import javax.annotation.Resource;@Service
public class CustomUserDetailsServiceImpl implements UserDetailsService {@Resourceprivate UserMapper userMapper;@Overridepublic UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {//1.查询用户User user = userMapper.findByName(username);//2.判断if (user == null) throw new UsernameNotFoundException("用户不存在");//3.返回用户信息return user;}
}

在配置类WebSecurityConfiguration中注入UserDetailsService对象,并修改configure(AuthenticationManagerBuilder auth)反方指定用户信息从数据库中获取

@Resource
private UserDetailsService userDetailsService;@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {//auth//.inMemoryAuthentication()	//内存认证//.withUser("zhangsan")		//用户名//.password(passwordEncoder().encode("123"))	//密码//.authorities("ROLE_ADMIN");	//角色auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
}

重启auth-server服务,进行认证

封装用户id

在生成token时可以将用户id封装到token中,以便后期使用

修改TokenConfiguration类中的accessTokenConverter()方法,在创建转换器时重写enhance方法

// token转换器
@Bean
public JwtAccessTokenConverter accessTokenConverter() {JwtAccessTokenConverter jwtAccessTokenConverter = new JwtAccessTokenConverter(){@Overridepublic OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {final Map<String,Object> map = new HashMap<>();// 从认证对象中得到用户信息User user = (User) authentication.getUserAuthentication().getPrincipal();// 将用户id放到token中map.put("uid", user.getId());((DefaultOAuth2AccessToken)accessToken).setAdditionalInformation(map);// 返回return super.enhance(accessToken, authentication);}};jwtAccessTokenConverter.setSigningKey(SIGNING_KEY);return jwtAccessTokenConverter;
}

利用postman进行测试

image-20220726160937503

返回的结果中可以看到用户id,token中也包含了用户id

检验token是否过期

在org.springframework.security.oauth2.provider.endpoint.CheckTokenEndpoint类中定义了校验token的接口/oauth/check_token,该接口可以用来校验token是否合法、是否过期、是否是伪造的

@RequestMapping(value = "/oauth/check_token")
@ResponseBody
public Map<String, ?> checkToken(@RequestParam("token") String value) {OAuth2AccessToken token = resourceServerTokenServices.readAccessToken(value);if (token == null) {throw new InvalidTokenException("Token was not recognised");}if (token.isExpired()) {throw new InvalidTokenException("Token has expired");}OAuth2Authentication authentication = resourceServerTokenServices.loadAuthentication(token.getValue());Map<String, Object> response = (Map<String, Object>)accessTokenConverter.convertAccessToken(token, authentication);// gh-1070response.put("active", true);	// Always true if token exists and not expiredreturn response;
}

只是该接口oauth2默认情况下是不对外公开的,如果要使用该接口那就必须手动配置开启,在AuthorizationServerConfiguration配置类中重写以下方法

//设置 /oauth/check_token 端点,通过认证后可访问。
//该端点对应 CheckTokenEndpoint类,用于校验访问令牌的有效性。
//在客户端访问资源服务器时,会在请求中带上访问令牌。
//在资源服务器收到客户端的请求时,会使用请求中的访问令牌,找授权服务器确认该访问令牌的有效性。
@Override
public void configure(AuthorizationServerSecurityConfigurer oauthServer) throws Exception {// 默认是denyAll():拒绝所有oauthServer.checkTokenAccess("permitAll()");
}

checkTokenAccess常用值有三种:

  • denyAll():拒绝所有请求,不开放该接口

  • isAuthenticated():只对完成认证之后的请求开放

  • permitAll():对所有请求开放

测试:登录成功之后在Postman中发送请求进行测试

接口url:http://localhost:8080/oauth/check_token

image-20220726114103407

返回的结果中包含了用户的用户名、权限等信息,还包括了token是否可用的信息

如果返回以下信息表示token已经过期

image-20230831142542647

而如果返回以下信息表示token非法

image-20230831142606770

通过refresh_token获取新token

获取token和刷新token使用的是同一个接口,所以地址栏url还是

http://local:8080/oauth/token

只是grant_type需要换成refresh_token,然后将之前的refresh token作为参数传递给后台

image-20220507100748397

还是需要将客户端id、密码以base64编码放到请求头中

image-20220507100833359

发送请求得到结果

image-20220507101406277

根据结果可以知道,token和refresh_token都会自动刷新,这样做的好处是当token过期时通过程序调用刷新接口,获取到新的token和refresh_token,实现自动续期。

refresh_token如果过期会得到以下结果

image-20220726115252787

refresh_token过期就需要重新登录

1.5 资源服务器

创建resource子模块,导入相关依赖

image-20220507112247458

设置父子关系

创建OAuth2ResourceServerConfig配置类

import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer;
import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter;@Configuration
@EnableResourceServer
public class OAuth2ResourceServerConfig extends ResourceServerConfigurerAdapter {@Overridepublic void configure(HttpSecurity http) throws Exception {http.authorizeRequests()// 设置请求,需要认证后访问.anyRequest().authenticated();}
}

创建controller

import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;@RestController
@RequestMapping("/resource")
public class ResourceController {@RequestMapping("/info")public String info(){return "success";}
}

配置application.yml

server:port: 8001
spring:application:name: resource
security:oauth2:# OAuth2 Client 配置,对应 OAuth2ClientProperties 类client:client-id: clientclient-secret: secret# OAuth2 Resource 配置,对应 ResourceServerProperties 类resource:token-info-uri: http://127.0.0.1:8000/oauth/check_token # 获得 Token 信息的 URL# 访问令牌获取 URL,自定义的access-token-uri: http://127.0.0.1:8000/oauth/token
management:endpoints:web:exposure:include: '*'

启动resource资源服务器

先进行认证,得到token和refresh_token

localhost:8000/oauth/token

image-20220507115312984

然后将得到的token放到请求资源服务器的请求头中

image-20220507115927410

发送请求后可以发现报500错误,查看resource控制台可以发现以下信息

org.springframework.web.client.HttpClientErrorException$Forbidden: 403 : [{"timestamp":"2022-05-07T03:40:14.063+00:00","status":403,"error":"Forbidden","message":"","path":"/oauth/check_token"}]

根据信息提示:没有权限访问 /oauth/check_token,该URL是认证服务器用来校验token是否合法的接口。资源服务器在接收到请求时会获取到token,然后调用认证服务器的/oauth/check_token接口去检验token,但是此时认证服务器还没有开放该端口(默认关闭),所以造成了403无法访问。

到认证服务器的AuthorizationServerConfiguration配置类中开启/oauth/check_token

//设置 /oauth/check_token 端点,通过认证后可访问。
//该端点对应 CheckTokenEndpoint类,用于校验访问令牌的有效性。
//在客户端访问资源服务器时,会在请求中带上访问令牌。
//在资源服务器收到客户端的请求时,会使用请求中的访问令牌,找授权服务器确认该访问令牌的有效性。
@Override
public void configure(AuthorizationServerSecurityConfigurer oauthServer) throws Exception {// 默认是denyAll():拒绝所有oauthServer.checkTokenAccess("isAuthenticated()");
}

重启认证服务器

重新进行认证得到token,然后用新的token再访问资源服务器

image-20220507120732391

看到success表明成功

角色权限管理

在资源服务器主启动类上添加@EnableGlobalMethodSecurity注解,开启spring security权限注解的支持

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;@SpringBootApplication
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class ResourceApplication {public static void main(String[] args) {SpringApplication.run(ResourceApplication.class, args);}
}

在resource/info接口方法上添加注解@PreAuthorize并指定角色或权限

@RequestMapping("/info")
@PreAuthorize("hasRole('USER')")
public String info(){return "success";
}

利用postman再次访问该接口

image-20220507150406869

得到不允许访问的结果,表明角色权限管理生效

1.6 整合数据库(client)

建表SQL

CREATE TABLE `clientdetails` (`appId` VARCHAR(128) NOT NULL,`resourceIds` VARCHAR(256) DEFAULT NULL,`appSecret` VARCHAR(256) DEFAULT NULL,`scope` VARCHAR(256) DEFAULT NULL,`grantTypes` VARCHAR(256) DEFAULT NULL,`redirectUrl` VARCHAR(256) DEFAULT NULL,`authorities` VARCHAR(256) DEFAULT NULL,`access_token_validity` INT(11) DEFAULT NULL,`refresh_token_validity` INT(11) DEFAULT NULL,`additionalInformation` VARCHAR(4096) DEFAULT NULL,`autoApproveScopes` VARCHAR(256) DEFAULT NULL,PRIMARY KEY (`appId`)
) ENGINE=INNODB DEFAULT CHARSET=utf8;CREATE TABLE `oauth_access_token` (`token_id` VARCHAR(256) DEFAULT NULL,`token` BLOB,`authentication_id` VARCHAR(128) NOT NULL,`user_name` VARCHAR(256) DEFAULT NULL,`client_id` VARCHAR(256) DEFAULT NULL,`authentication` BLOB,`refresh_token` VARCHAR(256) DEFAULT NULL,PRIMARY KEY (`authentication_id`)
) ENGINE=INNODB DEFAULT CHARSET=utf8;CREATE TABLE `oauth_approvals` (`userId` VARCHAR(256) DEFAULT NULL,`clientId` VARCHAR(256) DEFAULT NULL,`scope` VARCHAR(256) DEFAULT NULL,`status` VARCHAR(10) DEFAULT NULL,`expiresAt` TIMESTAMP NULL DEFAULT NULL,`lastModifiedAt` TIMESTAMP NULL DEFAULT NULL
) ENGINE=INNODB DEFAULT CHARSET=utf8;CREATE TABLE `oauth_client_details` (`client_id` VARCHAR(128) NOT NULL,`resource_ids` VARCHAR(256) DEFAULT NULL,`client_secret` VARCHAR(256) DEFAULT NULL,`scope` VARCHAR(256) DEFAULT NULL,`authorized_grant_types` VARCHAR(256) DEFAULT NULL,`web_server_redirect_uri` VARCHAR(256) DEFAULT NULL,`authorities` VARCHAR(256) DEFAULT NULL,`access_token_validity` INT(11) DEFAULT NULL,`refresh_token_validity` INT(11) DEFAULT NULL,`additional_information` VARCHAR(4096) DEFAULT NULL,`autoapprove` VARCHAR(256) DEFAULT NULL,PRIMARY KEY (`client_id`)
) ENGINE=INNODB DEFAULT CHARSET=utf8;CREATE TABLE `oauth_client_token` (`token_id` VARCHAR(256) DEFAULT NULL,`token` BLOB,`authentication_id` VARCHAR(128) NOT NULL,`user_name` VARCHAR(256) DEFAULT NULL,`client_id` VARCHAR(256) DEFAULT NULL,PRIMARY KEY (`authentication_id`)
) ENGINE=INNODB DEFAULT CHARSET=utf8;CREATE TABLE `oauth_code` (`code` VARCHAR(256) DEFAULT NULL,`authentication` BLOB
) ENGINE=INNODB DEFAULT CHARSET=utf8;CREATE TABLE `oauth_refresh_token` (`token_id` VARCHAR(256) DEFAULT NULL,`token` BLOB,`authentication` BLOB
) ENGINE=INNODB DEFAULT CHARSET=utf8;

在表 oauth_client_details 中增加一条客户端配置记录,在填入时可以按照AuthorizationServerConfiguration配置类中的客户端配置进行配置

配置的效果如下:

image-20220507161710501

注:各字段解释说明

  • client_id:客户端标识

  • client_secret:客户端安全码。注意安全码不能是明文需要加密,此处可以写一段程序,然后使用BCryptPasswordEncoder为客户端安全码加密,得到加密之后的安全码,再写入到数据库中,例如:

  • System.out.println(new BCryptPasswordEncoder().encode("secret"));

  • scope:客户端授权范围

  • authorized_grant_types:客户端授权类型,支持多种类型,多种类型之间用逗号隔开

  • web_server_redirect_uri:服务器回调地址

创建实体类User、Role、Perms

在auth-server模块的pom.xml中引入mybatis相关依赖

<!-- spring-boot-starter-jdbc 内置了HikariCP 连接池,所以使用该连接池连接数据库 -->
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<!-- mysql -->
<dependency><groupId>mysql</groupId><artifactId>mysql-connector-java</artifactId>
</dependency>
<!-- 获取application.yml文件中的配置 -->
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-configuration-processor</artifactId><optional>true</optional>
</dependency>

application.yml文件中添加数据库相关配置

server:port: 8000
spring:application:name: oauthdatasource:type: com.zaxxer.hikari.HikariDataSourcedriver-class-name: com.mysql.cj.jdbc.Driverjdbc-url: jdbc:mysql://localhost:3306/sc?useUnicode=true&characterEncoding=utf8&serverTimezone=UTCusername: rootpassword: roothikari:minimum-idle: 5maximum-pool-size: 10auto-commit: true #自动提交pool-name: MYHIKARICPconnection-test-query: SELECT 1 #测试是否能连接上数据库的SQL语句main:#true,后定义的bean会覆盖之前定义的相同名称的bean,生成dataSource替换掉原生的dataSourceallow-bean-definition-overriding: true

创建数据库配置类DataSourceConfiguration,主要配置用到的数据源,用HikariCP连接池的数据源替换到spring内置的数据源。

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;import javax.sql.DataSource;@Configuration
public class DataSourceConfiguration {@Bean   @Primary    //根据application.yml中的配置信息创建dataSource@ConfigurationProperties(prefix = "spring.datasource")//import javax.sql.DataSource;public DataSource dataSource() {//创建dataSourcereturn DataSourceBuilder.create().build();}
}

在TokenConfiguration配置类中把token存储策略改成JDBC方式,将jwt存放到数据库中DataSource

@Resource
private DataSource dataSource;// 令牌存储策略:jwt方式
@Bean
public TokenStore tokenStore(DataSource dataSource){//return new JwtTokenStore(accessTokenConverter());return new JdbcTokenStore(dataSource);
}

修改AuthorizationServerConfiguration配置类,添加ClientDetailsService clientDetailsService(DataSource dataSource)方法,让程序通过DataSource从数据库中获取到客户端信息

@Bean
public ClientDetailsService clientDetailsService(DataSource dataSource) {//在数据库中去获取客户端信息(oauth_client_details表)return new JdbcClientDetailsService(dataSource);
}

修改configure(ClientDetailsServiceConfigurer clients)方法指定到数据库获取客户端信息

@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {//配置客户端//clients//.inMemory()		//内存方式//.withClient("client")	//客户端名字//.secret(passwordEncoder.encode("secret"))	//客户端秘钥//.authorizedGrantTypes("authorization_code","password","refresh_token")//.scopes("all")	//授权范围//.redirectUris("http://www.woniuxy.com");	//回调网址clients.withClientDetails(clientDetailsService);
}

完成之后重启项目,再次进行认证测试

正常情况下,测试完毕之后会在数据库的oauth_access_token 表中会增加一个记录,这个记录就是浏览器获取到的token和refresh token

image-20220509101431817

角色、权限管理测试

在resource服务的controller中添加以下方法

@RequestMapping("/message")
@PreAuthorize("hasRole('ADMIN')")
public String message(){return "message";
}@RequestMapping("/data")
@PreAuthorize("hasAuthority('user:add')")
public String data(){return "data";
}
@RequestMapping("/test")
@PreAuthorize("hasAuthority('user:del')")
public String test(){return "test";
}

启动resource服务,通过postman分别测试info、message、data、test接口,如果只有message、data接口可以访问,那么说明角色、权限管理成功。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/67018.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

实现 Entity实例生命周期和vue组件生命周期融合

场景解决方案实现方案index.vue方案解决效果 场景 ceisum中Entity实例的生成和销毁&#xff0c;大部分逻辑和vue代码分离&#xff0c;导致不好阅读和维护 解决方案 ceisum 中实例 Entity 的生命周期&#xff0c;和vue的生命周期’相似’&#xff0c;把两个生命周期结合(把en…

Jmete+Grafana+Prometheus+Influxdb+Nginx+Docker架构搭建压测体系/监控体系/实时压测数据展示平台+遇到问题总结

背景 需要大批量压测时&#xff0c;单机发出的压力能力有限&#xff0c;需要多台jmeter来同时进行压测&#xff1b;发压机资源不够&#xff0c;被压测系统没到瓶颈之前&#xff0c;发压机难免先发生资源不足的情形&#xff1b;反复压测时候也需要在不同机器中启动压测脚本&…

比较opencv,pillow,matplotlib,skimage读取图像的速度比

上面这些库都被广泛用于图像处理和计算机视觉任务&#xff1b; 不同的图像读取库&#xff08;OpenCV&#xff0c;Pillow&#xff0c;matplotlib和skimage&#xff09;的读取速度&#xff0c;是怎么样的一个情况&#xff1f; 下面分别从读取速度&#xff0c;以及转换到RGB通道…

《虚拟仿真实验教学平台》三项团体标准启动会在 ALVA 举办

8 月 11 日&#xff0c;《虚拟仿真实验教学平台》三项团体标准启动会&#xff08;下以“启动会”简称&#xff09;以线下线上相结合的会议形式在 ALVA Systems 北京总部举办。 启动会上&#xff0c;ALVA 与专家组、编写组成员和企业代表围绕《虚拟仿真实验教学平台建设指南》、…

不同写法的性能差异

“ 达到相同目的,可以有多种写法,每种写法有性能、可读性方面的区别,本文旨在探讨不同写法之间的性能差异 len(str) vs str "" 本部分参考自: [问个 Go 问题&#xff0c;字符串 len 0 和 字符串 "" &#xff0c;有啥区别&#xff1f;](https://segmentf…

基础算法-递推算法-学习

现象&#xff1a; 基础算法-递推算法-学习 方法&#xff1a; 这就是一种递推的算法思想。递推思想的核心就是从已知条件出发&#xff0c;逐步推算出问题的解 最常见案例&#xff1a; 一&#xff1a;正向递推案例&#xff1a; 弹力球回弹问题&#xff1a; * 弹力球从100米高…

OpenLayers7官方文档翻译,OpenLayers7中文文档,OpenLayers快速入门

快速入门 这个入门文档向您展示如何放一张地图在web网页上。 开发设置使用 NodeJS&#xff08;至少需要Nodejs 14 或更高版本&#xff09;&#xff0c;并要求安装 git。 设置新项目 开始使用OpenLayers构建项目的最简单方法是运行&#xff1a;npm create ol-app npm create…

Go 面向对象(匿名字段)

概述 严格意义上说&#xff0c;GO语言中没有类(class)的概念,但是我们可以将结构体比作为类&#xff0c;因为在结构体中可以添加属性&#xff08;成员&#xff09;&#xff0c;方法&#xff08;函数&#xff09;。 面向对象编程的好处比较多&#xff0c;我们先来说一下“继承…

QWidget的ui界面绘制成图片

文章目录 源文件源码解释效果修复图片清晰度 源文件 #include "widget.h" #include "ui_widget.h"#include <QPixmap> #include <QDir>Widget::Widget(QWidget *parent): QWidget(parent), ui(new Ui::Widget) {ui->setupUi(this);// 构造…

Pinely Round 2 (Div. 1 + Div. 2) G. Swaps(组合计数)

题目 给定一个长度为n(n<1e6)的序列&#xff0c;第i个数ai(1<ai<n)&#xff0c; 操作&#xff1a;你可以将当前i位置的数和a[i]位置的数交换 交换可以操作任意次&#xff0c;求所有本质不同的数组的数量&#xff0c;答案对1e97取模 思路来源 力扣群 潼神 心得 感…

Android之布局转圆角

Android之布局转圆角 文章目录 Android之布局转圆角说明一、效果图二、实现步骤1.自定义RoundRelativeLayout2.使用 总结 说明 很多需求比较无语&#xff0c;需要某个布局转圆角&#xff0c;像个显眼包一样&#xff0c;所以为了满足显眼包&#xff0c;必须整呐提示&#xff1a…

Java智慧工地信息化管理平台源码,依托计算机信息、网络通讯、物联网、系统集成及云计算技术建立

Java智慧工地源码 智慧工地APP源码 系统定义&#xff1a; 智慧工地信息化管理平台是依托计算机信息、网络通讯、物联网、系统集成及云计算技术&#xff0c;通过数据采集、信息动态交互、智能分析&#xff0c;建立起来的一套集成的项目建设综合管理系统。实现项目管理信息化、网…

LeetCode 剑指offer 09.用两个栈实现队列

LeetCode 剑指offer 09.用两个栈实现队列 题目描述 用两个栈实现一个队列。队列的声明如下&#xff0c;请实现它的两个函数 appendTail 和 deleteHead &#xff0c;分别完成在队列尾部插入整数和在队列头部删除整数的功能。(若队列中没有元素&#xff0c;deleteHead 操作返回…

C语言网络编程实现组播(多播)

1、组播IP划分 224.0.0.0&#xff5e;224.0.0.255 为预留的组播地址&#xff08;永久组地址&#xff09;&#xff0c;地址224.0.0.0保留不做分配&#xff0c;其它地址供路由协议使用&#xff1b; 224.0.1.0&#xff5e;224.0.1.255 是公用组播地址&#xff0c;可以用于Inter…

Scala的特质trait与java的interface接口的区别,以及Scala特质的自身类型和依赖注入

1. Scala的特质trait与java接口的区别 Scala中的特质&#xff08;trait&#xff09;和Java中的接口&#xff08;interface&#xff09;在概念和使用上有一些区别&#xff1a; 默认实现&#xff1a;在Java中&#xff0c;接口只能定义方法的签名&#xff0c;而没有默认实现。而在…

机器连接和边缘计算

以一种高效、可扩展的方式进行连接和边缘计算的结合&#xff0c;解决了在工业物联网应用中的机器数据集成问题。 一 边缘计算 边缘计算描述了由中央平台管理的数据分散式处理&#xff0c;它对于工业物联网而言非常重要。在许多应用程序中&#xff0c;由于数据量非常大&#xf…

【USRP】集成化仪器系列1 :信号源,基于labview实现

USRP 信号源 1、设备IP地址&#xff1a;默认为192.168.10.2&#xff0c;请勿 修改&#xff0c;运行阶段无法修改。 2、天线输出端口是TX1&#xff0c;请勿修改。 3、通道&#xff1a;0 对应RF A、1 对应 RF B&#xff0c;运行 阶段无法修改。 4、中心频率&#xff1a;当需要…

界面控件DevExpress .NET应用安全 Web API v23.1亮点:支持Swagger模式

DevExpress拥有.NET开发需要的所有平台控件&#xff0c;包含600多个UI控件、报表平台、DevExpress Dashboard eXpressApp 框架、适用于 Visual Studio的CodeRush等一系列辅助工具。 DevExpress 今年第一个重要版本v23.1日前已正式发布了&#xff0c;该版本拥有众多新产品和数十…

电子学会 2023年3月 青少年软件编程Python编程等级考试三级真题解析(选择题+判断题+编程题)

青少年编程Python编程等级考试三级真题解析(选择题+判断题+编程题) 2023年3月 一、选择题(共25题,共50分) 十进制数111转换成二进制数是?( ) A. 111 B. 1111011 C. 101111 D. 1101111 答案选:D 考点分析:考察python 进制转换 十进制转二进制,采用除二倒取余数,直到商…

使用 SQL 的方式查询消息队列数据以及踩坑指南

Pulsar-sql.png 背景 为了让业务团队可以更好的跟踪自己消息的生产和消费状态&#xff0c;需要一个类似于表格视图的消息列表&#xff0c;用户可以直观的看到发送的消息&#xff1b;同时点击详情后也能查到消息的整个轨迹。 消息列表 点击详情后查看轨迹 原理介绍 由于 Pulsar …