最近需要写一个单体架构的认证授权,于是翻出了很久前写的学成在线的Spring-Security+OAUTH2实现的单点登录。
提一下Spring-Security的实现原理:
就是在过滤器链中提供了一个接口delegatingFilterProxy用于扩展第三方,实现为FilterChainProxy,一组过滤器链
然后学成教育里面是个什么呢
接下来开始流程了:
首先是导入依赖:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
然后好像是还有几张表:
现在从流程图上开始看:
流程是这样的:
- 用户提交用户名、密码被SecurityFilterChain中的UsernamePasswordAuthenticationFilter过滤器获取到,封装为请求Authentication,通常情况下是UsernamePasswordAuthenticationToken这个实现类。
- 然后过滤器将Authentication提交至认证管理器(AuthenticationManager)进行认证明
- 认证成功后,AuthenticationManager身份管理器返回一个被填充满了信息的(包括上面提到的权限信息,身份信息,细节信息,但密码通常会被移除)Authentication实例
- SecurityContextHolder安全上下文容器将第3步填充了信息的Authentication,通过SecurityContextHolder.getContext().setAuthentication(…)方法,设置到其中。
- 可以看出AuthenticationManager接口(认证管理器)是认证相关的核心接口,也是发起认证的出发点,它的实现类为ProviderManager。而Spring Security支持多种认证方式,因此ProviderManager维护着一个List<AuthenticationProvider>列表,存放多种认证方式,最终实际的认证工作是由AuthenticationProvider完成的。咱们知道web表单的对应的AuthenticationProvider实现类为DaoAuthenticationProvider,它的内部又维护着一个UserDetailsService负责UserDetails的获取。最终AuthenticationProvider将UserDetails填充至Authentication。
接下来是OAuth2认证流程:
1、授权码服务
官网图片是这个:
大概就是我登陆客户端,发送认证请求,我自己统一认证了就去认证服务器拿令牌,然后返回令牌后拿着令牌去找资源服务器拿取资源。
开始配置:
1、配置类
@Configuration
@EnableAuthorizationServer
public class AuthorizationServer extends AuthorizationServerConfigurerAdapter {
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
}
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
}
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
}
}
1)ClientDetailsServiceConfigurer:用来配置客户端详情服务(ClientDetailsService),
随便一个客户端都可以随便接入到它的认证服务吗?答案是否定的,服务提供商会给批准接入的客户端一个身份,用于接入时的凭据,有客户端标识和客户端秘钥,在这里配置批准接入的客户端的详细信息。
2)AuthorizationServerEndpointsConfigurer:用来配置令牌(token)的访问端点和令牌服务(token services)。
3)AuthorizationServerSecurityConfigurer:用来配置令牌端点的安全约束.
2、令牌策略配置类
@Configuration
public class TokenConfig {
private String SIGNING_KEY = "mq123";
@Autowired
TokenStore tokenStore;
// @Bean
// public TokenStore tokenStore() {
// //使用内存存储令牌(普通令牌)
// return new InMemoryTokenStore();
// }
@Autowired
private JwtAccessTokenConverter accessTokenConverter;
@Bean
public TokenStore tokenStore() {
return new JwtTokenStore(accessTokenConverter());
}
@Bean
public JwtAccessTokenConverter accessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setSigningKey(SIGNING_KEY);
return converter;
}
//令牌管理服务
/**
* InMemoryTokenStore:这个版本的实现是被默认采用的,它可以完美的工作在单服务器上(即访问并发量
* 压力不大的情况下,并且它在失败的时候不会进行备份),大多数的项目都可以使用这个版本的实现来进行
* 尝试,你可以在开发的时候使用它来进行管理,因为不会被保存到磁盘中,所以更易于调试.
*
* JdbcTokenStore:这是一个基于JDBC的实现版本,令牌会被保存进关系型数据库。使用这个版本的实现时,
* 你可以在不同的服务器之间共享令牌信息,使用这个版本的时候请注意把"spring-jdbc"这个依赖加入到你的
* classpath当中。
*
* JwtTokenStore:这个版本的全称是 JSON Web Token(JWT),它可以把令牌相关的数据进行编码(因此对
* 于后端服务来说,它不需要进行存储,这将是一个重大优势),但是它有一个缺点,那就是撤销一个已经授
* 权令牌将会非常困难,所以它通常用来处理一个生命周期较短的令牌以及撤销刷新令牌(refresh_token)。
* 另外一个缺点就是这个令牌占用的空间会比较大,如果你加入了比较多用户凭证信息。JwtTokenStore 不会保
* 存任何数据,但是它在转换令牌值以及授权信息方面与 DefaultTokenServices 所扮演的角色是一样的。
* @return
*/
@Bean(name="authorizationServerTokenServicesCustom")
public AuthorizationServerTokenServices tokenService() {
DefaultTokenServices service=new DefaultTokenServices();
service.setSupportRefreshToken(true);//支持刷新令牌
service.setTokenStore(tokenStore);//令牌存储策略
// service.setClientDetailsService();客户端详情服务,这个就是另一个类配置的
TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
tokenEnhancerChain.setTokenEnhancers(Arrays.asList(accessTokenConverter));
service.setTokenEnhancer(tokenEnhancerChain);//增强
service.setAccessTokenValiditySeconds(7200); // 令牌默认有效期2小时
service.setRefreshTokenValiditySeconds(259200); // 刷新令牌默认有效期3天
return service;
}
}
然后就请求一下吧
http://localhost:8080/auth/oauth/authorize?client_id=LDH&response_type=code&scope=all&redirect_uri=http://localhost:8080/login
client_id:客户端准入标识。
response_type:授权码模式固定为code。
scope:客户端权限。
redirect_uri:跳转uri,当授权码申请成功后会跳转到此地址,并在后边带上code参数(授权码)。
然后授权---重定向----拿到授权码-----拿着授权码申请令牌
http://localhost:8080/auth/oauth/token?client_id=LDH&client_secret=LDH&grant_type=authorization_code&code=CTvCrB&redirect_uri=http://localhost:8080/login
@Override
public void configure(ClientDetailsServiceConfigurer clients)
throws Exception {
//clientId:(必须的)用来标识客户的Id。
//secret:(需要值得信任的客户端)客户端安全码,如果有的话。
//scope:用来限制客户端的访问范围,如果为空(默认)的话,那么客户端拥有全部的访问范围。
//authorizedGrantTypes:此客户端可以使用的授权类型,默认为空。
//authorities:此客户端可以使用的权限(基于Spring Security authorities)。
//配置在内存里,后面配置在数据库中
clients.inMemory()// 使用in-memory存储
.withClient("LDH")// client_id
// .secret("LDH")//客户端密钥
.secret(new BCryptPasswordEncoder().encode("LDH"))//客户端密钥
.resourceIds("xuecheng-plus")//资源列表,允许访问的资源
.authorizedGrantTypes("authorization_code", "password", "client_credentials", "implicit", "refresh_token")// 该client允许的授权类型authorization_code,password,refresh_token,implicit,client_credentials
.scopes("all")// 允许的授权范围
.autoApprove(false)//false跳转到授权码页面
//客户端接收授权码的重定向地址
.redirectUris("http://www.51xuecheng.cn")
;
}
2、密码模式
http://localhost:8080/oauth/token?client_id=LDH&client_secret=LDH&grant_type=password&username=ldh&password=888
然后就登陆。
什么是JWT
使用JWT可以实现无状态认证,什么是无状态认证?
传统的基于session的方式是有状态认证,用户登录成功将用户的身份信息存储在服务端,这样加大了服务端的存储压力,并且这种方式不适合在分布式系统中应用。
如下图,当用户访问应用服务,每个应用服务都会去服务器查看session信息,如果session中没有该用户则说明用户没有登录,此时就会重新认证,而解决这个问题的方法是Session复制、Session黏贴。
JWT令牌的优点:
1、jwt基于json,非常方便解析。
2、可以在令牌中自定义丰富的内容,易扩展。
3、通过非对称加密算法及数字签名技术,JWT防止篡改,安全性高。
4、资源服务使用JWT可不依赖认证服务即可完成授权。
缺点:
1、JWT令牌较长,占存储空间比较大。
但是我觉得不适合高并发,因为太长,校验麻烦,网络消耗也比较大
JWT令牌由三部分组成,每部分中间使用点(.)分隔,比如:xxxxx.yyyyy.zzzzz
- Header
头部包括令牌的类型(即JWT)及使用的哈希算法(如HMAC SHA256或RSA)
一个例子如下:
下边是Header部分的内容
JSON |
将上边的内容使用Base64Url编码,得到一个字符串就是JWT令牌的第一部分。
- Payload
第二部分是负载,内容也是一个json对象,它是存放有效信息的地方,它可以存放jwt提供的信息字段,比如:iss(签发者),exp(过期时间戳), sub(面向的用户)等,也可自定义字段。
此部分不建议存放敏感信息,因为此部分可以解码还原原始内容。
最后将第二部分负载使用Base64Url编码,得到一个字符串就是JWT令牌的第二部分。
一个例子:
JSON |
- Signature
第三部分是签名,此部分用于防止jwt内容被篡改。
这个部分使用base64url将前两部分进行编码,编码后使用点(.)连接组成字符串,最后使用header中声明的签名算法进行签名。
一个例子:
JSON |
base64UrlEncode(header):jwt令牌的第一部分。
base64UrlEncode(payload):jwt令牌的第二部分。
secret:签名所使用的密钥。
为什么JWT可以防止篡改?
第三部分使用签名算法对第一部分和第二部分的内容进行签名,常用的签名算法是 HS256,常见的还有md5,sha 等,签名算法需要使用密钥进行签名,密钥不对外公开,并且签名是不可逆的,如果第三方更改了内容那么服务器验证签名就会失败,要想保证验证签名正确必须保证内容、密钥与签名前一致。
从上图可以看出认证服务和资源服务使用相同的密钥,这叫对称加密,对称加密效率高,如果一旦密钥泄露可以伪造jwt令牌。
JWT还可以使用非对称加密,认证服务自己保留私钥,将公钥下发给受信任的客户端、资源服务,公钥和私钥是配对的,成对的公钥和私钥才可以正常加密和解密,非对称加密效率低但相比对称加密非对称加密更安全一些。
从授权服务拿到token后去访问资源服务
同时也要配置一个TokenConfig和上面一样,然后还有一个资源服务,资源服务id要和上面client那里配的对应
@Configuration
@EnableResourceServer//标记资源服务
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
//资源服务标识
public static final String RESOURCE_ID = "xuecheng-plus";
@Autowired
TokenStore tokenStore;
/**
* tokenServices:ResourceServerTokenServices 类的实例,用来实现令牌服务。
* tokenStore:TokenStore类的实例,指定令牌如何访问,与tokenServices配置可选
* resourceId:这个资源服务的ID,这个属性是可选的,但是推荐设置并在授权服务中进行验证。
* 其他的拓展属性例如 tokenExtractor 令牌提取器用来提取请求中的令牌。
* HttpSecurity配置这个与Spring Security类似:
* 请求匹配器,用来设置需要进行保护的资源路径,默认的情况下是保护资源服务的全部路径。
* 通过http.authorizeRequests()来设置受保护资源的访问规则
* 其他的自定义权限保护规则通过 HttpSecurity 来进行配置。
*
* @param resources
* @EnableResourceServer 注解自动增加了一个类型为 OAuth2AuthenticationProcessingFilter 的过滤器链
*/
@Override
public void configure(ResourceServerSecurityConfigurer resources) {
resources.resourceId(RESOURCE_ID)//资源 id
.tokenStore(tokenStore)
// .tokenServices() 资源验证服务,可以本地可以异地,性能差
.stateless(true);//用于指示在这些资源上只允许基于令牌的身份验证的标志。
}
@Override
public void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.authorizeRequests()
// .antMatchers("/**").access("#oauth2.hasScope('all')")
// .antMatchers("/r/**","/course/**").authenticated()//所有/r/**的请求必须认证通过
.anyRequest().permitAll()
;
}
// @Bean
// public ResourceServerTokenServices tokenServices(){
// RemoteTokenServices services = new RemoteTokenServices();
// services.setCheckTokenEndpointUrl();//验证端点
// services.setClientId();//客户端名称
// services.setClientSecret();//客户端密钥
// return services;
// }
}
jwt令牌中记录了用户身份信息,当客户端携带jwt访问资源服务,资源服务验签通过后将前两部分的内容还原即可取出用户的身份信息,并将用户身份信息放在了SecurityContextHolder上下文(ThreadLocal,不知道记没记错,ThreadLocal是ThreadMap里的kv),SecurityContext与当前线程进行绑定,方便获取用户身份。
因为是分布式,所以通过网关认证即可
就跳过了。
@Component
@Slf4j
public class GatewayAuthFilter implements GlobalFilter, Ordered {
//白名单
private static List<String> whitelist = null;
static {
//加载白名单
try (
InputStream resourceAsStream = GatewayAuthFilter.class.getResourceAsStream("/security-whitelist.properties");
) {
Properties properties = new Properties();
properties.load(resourceAsStream);
Set<String> strings = properties.stringPropertyNames();
whitelist= new ArrayList<>(strings);
} catch (Exception e) {
log.error("加载/security-whitelist.properties出错:{}",e.getMessage());
e.printStackTrace();
}
}
@Autowired
private TokenStore tokenStore;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String requestUrl = exchange.getRequest().getPath().value();
AntPathMatcher pathMatcher = new AntPathMatcher();
//白名单放行
for (String url : whitelist) {
if (pathMatcher.match(url, requestUrl)) {
return chain.filter(exchange);
}
}
//检查token是否存在
String token = getToken(exchange);
if (StringUtils.isBlank(token)) {
return buildReturnMono("没有认证",exchange);
}
//判断是否是有效的token
OAuth2AccessToken oAuth2AccessToken;
try {
oAuth2AccessToken = tokenStore.readAccessToken(token);
boolean expired = oAuth2AccessToken.isExpired();
if (expired) {
return buildReturnMono("认证令牌已过期",exchange);
}
return chain.filter(exchange);
} catch (InvalidTokenException e) {
log.info("认证令牌无效: {}", token);
return buildReturnMono("认证令牌无效",exchange);
}
}
/**
* 获取token
*/
private String getToken(ServerWebExchange exchange) {
String tokenStr = exchange.getRequest().getHeaders().getFirst("Authorization");
if (StringUtils.isBlank(tokenStr)) {
return null;
}
String token = tokenStr.split(" ")[1];
if (StringUtils.isBlank(token)) {
return null;
}
return token;
}
private Mono<Void> buildReturnMono(String error, ServerWebExchange exchange) {
ServerHttpResponse response = exchange.getResponse();
String jsonString = JSON.toJSONString(new RestErrorResponse(error));
byte[] bits = jsonString.getBytes(StandardCharsets.UTF_8);
DataBuffer buffer = response.bufferFactory().wrap(bits);
response.setStatusCode(HttpStatus.UNAUTHORIZED);
response.getHeaders().add("Content-Type", "application/json;charset=UTF-8");
return response.writeWith(Mono.just(buffer));
}
@Override
public int getOrder() {
return 0;
}
}
认证授权部分:
回到上面那个图
要自己认证的话,就改写UserDetails的loadByUserName()就可以,返回的UserDetails里面也继承写一下。要更多信息就扩展UserDetails;
用户表中存储了用户的账号、手机号、email,昵称、qq等信息,UserDetails接口只返回了username、密码等信息,如下:
public interface UserDetails extends Serializable {
Collection<? extends GrantedAuthority> getAuthorities();
String getPassword();
String getUsername();
boolean isAccountNonExpired();
boolean isAccountNonLocked();
boolean isCredentialsNonExpired();
boolean isEnabled();
}
第一是可以扩展UserDetails,使之包括更多的自定义属性,第二也可以扩展username的内容 ,比如存入json数据内容作为username的内容。相比较而言,方案二比较简单还不用破坏UserDetails的结构,我们采用方案二。把其他信息转换成Json字符串存进去username里面。
获取用户身份:
public static XcUser getUser() {
try {
Object principalObj = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
if (principalObj instanceof String) {
//取出用户身份信息
String principal = principalObj.toString();
//将json转成对象
XcUser user = JSON.parseObject(principal, XcUser.class);
return user;
}
} catch (Exception e) {
log.error("获取当前登录用户身份出错:{}", e.getMessage());
e.printStackTrace();
}
return null;
}
认证方式多样:在里面就实现了微信登陆和密码登录,一定要屏蔽密码对比这一行为!!!!
看下面的就知道了:
重写UserDetailsService,根据传进去的字符串模式不同而用不同的处理方式
@Component
public class UserDetailServiceImpl implements UserDetailsService {
@Autowired
private XcUserMapper xcUserMapper;
@Autowired
ApplicationContext applicationContext;
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
AuthParamsDto authParamsDto;
authParamsDto = JSON.parseObject(s, AuthParamsDto.class);
String authType=authParamsDto.getAuthType();
AuthService authService = applicationContext.getBean(authType, AuthService.class);
XcUserExt execute = authService.execute(authParamsDto);
return getUserPrincipal(execute);
}
/**
* @description 查询用户信息
* @param user 用户id,主键
* @return com.xuecheng.ucenter.model.po.XcUser 用户信息
* @author Mr.M
* @date 2022/9/29 12:19
*/
public UserDetails getUserPrincipal(XcUserExt user){
//用户权限,如果不加报Cannot pass a null GrantedAuthority collection
List<String> auth = xcUserMapper.getAuth(user.getId());
String[] array = auth.toArray(new String[auth.size()]);
String password = user.getPassword();
//为了安全在令牌中不放密码
user.setPassword(null);
//将user对象转json
String userString = JSON.toJSONString(user);
//创建UserDetails对象
UserDetails userDetails = User.withUsername(userString).password(password).authorities(array).build();
return userDetails;
}
}
@Service("wx")
@Slf4j
public class WXAuthServiceImpl implements AuthService, WXAuthService {
@Autowired
private XcUserMapper xcUserMapper;
@Override
public XcUserExt execute(AuthParamsDto authParamsDto) {
//账号
String userName = authParamsDto.getUsername();
//查询数据库看账号是否存在
LambdaQueryWrapper<XcUser> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(XcUser::getUsername, userName);
XcUser xcUser = xcUserMapper.selectOne(queryWrapper);
if (xcUser == null) {
throw new RuntimeException("账户不存在");
}
XcUserExt xcUserExt = new XcUserExt();
BeanUtils.copyProperties(xcUser, xcUserExt);
xcUser.setPassword(null);
return xcUserExt;
}
@Autowired
private RestTemplate restTemplate;
@Value("${weixin.appid}")
String appid;
@Value("${weixin.secret}")
String secret;
@Override
public XcUser wxAuth(String code) {
Map<String, String> map = getAccess_token(code);
String openid = map.get("openid");
String access_token = map.get("access_token");
//拿access_token查询用户信息
Map<String, String> userinfo = getUserinfo(access_token, openid);
//保存到数据库
XcUser xcUser = addWxUser(userinfo);
return xcUser;
}
//用户授权后获取token
private Map<String, String> getAccess_token(String code) {
String wxUrl_template = "https://api.weixin.qq.com/sns/oauth2/access_token?appid=%s&secret=%s&code=%s&grant_type=authorization_code";
//请求微信地址
String wxUrl = String.format(wxUrl_template, appid, secret, code);
log.info("调用微信接口申请access_token, url:{}", wxUrl);
ResponseEntity<String> exchange = restTemplate.exchange(wxUrl, HttpMethod.POST, null, String.class);
String result = exchange.getBody();
log.info("调用微信接口申请access_token: 返回值:{}", result);
Map<String, String> resultMap = JSON.parseObject(result, Map.class);
return resultMap;
}
// https://api.weixin.qq.com/sns/oauth2/access_token?appid=APPID&secret=SECRET&code=CODE&grant_type=authorization_code
// 携带token查询用户信息
private Map<String, String> getUserinfo(String access_token, String openid) {
String wxUrl_template = "https://api.weixin.qq.com/sns/userinfo?access_token=%s&openid=%s";
//请求微信地址
String wxUrl = String.format(wxUrl_template, access_token, openid);
log.info("调用微信接口申请access_token, url:{}", wxUrl);
ResponseEntity<String> exchange = restTemplate.exchange(wxUrl, HttpMethod.POST, null, String.class);
//防止乱码进行转码
String result = new String(exchange.getBody().getBytes(StandardCharsets.ISO_8859_1), StandardCharsets.UTF_8);
log.info("调用微信接口申请access_token: 返回值:{}", result);
Map<String, String> resultMap = JSON.parseObject(result, Map.class);
return resultMap;
}
//保存到数据库
@Autowired
XcUserRoleMapper xcUserRoleMapper;
@Transactional
public XcUser addWxUser(Map userInfo_map){
String unionid = userInfo_map.get("unionid").toString();
//根据unionid查询数据库
XcUser xcUser = xcUserMapper.selectOne(new LambdaQueryWrapper<XcUser>().eq(XcUser::getWxUnionid, unionid));
if(xcUser!=null){
return xcUser;
}
String userId = UUID.randomUUID().toString();
xcUser = new XcUser();
xcUser.setId(userId);
xcUser.setWxUnionid(unionid);
//记录从微信得到的昵称
xcUser.setNickname(userInfo_map.get("nickname").toString());
xcUser.setUserpic(userInfo_map.get("headimgurl").toString());
xcUser.setName(userInfo_map.get("nickname").toString());
xcUser.setUsername(unionid);
xcUser.setPassword(unionid);
xcUser.setUtype("101001");//学生类型
xcUser.setStatus("1");//用户状态
xcUser.setCreateDate(LocalDateTime.now());
xcUserMapper.insert(xcUser);
XcUserRole xcUserRole = new XcUserRole();
xcUserRole.setId(UUID.randomUUID().toString());
xcUserRole.setUserId(userId);
xcUserRole.setRoleId("17");//学生角色
xcUserRoleMapper.insert(xcUserRole);
return xcUser;
}
}
前端就这样请求:
{{auth_host}}/auth/oauth/token?client_id=XcWebApp&client_secret=XcWebApp&grant_type=password&username={"username":"stu1","authType":"password","password":"111111"}
微信那里有个内网穿透的问题:
我们的开发环境在局域网,微信回调指向一个公网域名。
如何让微信回调请求至我们的开发计算机上呢?
可以使用内网穿透技术,什么是内网穿透?
内网穿透简单来说就是将内网外网通过隧道打通,让内网的数据让外网可以获取。比如常用的办公室软件等,一般在办公室或家里,通过拨号上网,这样办公软件只有在本地的局域网之内才能访问,那么问题来了,如果是手机上,或者公司外地的办公人员,如何访问到办公软件呢?这就需要内网穿透工具了。开启隧道之后,网穿透工具会分配一个专属域名/端口,办公软件就已经在公网上了,在外地的办公人员可以在任何地方愉快的访问办公软件了~~
1、在内网穿透服务器上开通隧道,配置外网域名,配置穿透内网的端口即本地电脑上的端口。
这里我们配置认证服务端口,最终实现通过外网域名访问本地认证服务。
2、在本地电脑上安装内网穿透的工具,工具上配置内网穿透服务器隧道token。
市面上做内网穿透的商家很多,需要时可以查阅资料了解下。
授权
RBAC
如何实现授权?业界通常基于RBAC实现授权。
RBAC分为两种方式:
基于角色的访问控制(Role-Based Access Control)
基于资源的访问控制(Resource-Based Access Control)
在需要授权的接口处使用@PreAuthorize("hasAuthority('权限标识符')")进行控制
五张表
把权限信息放进UserDetails就可以了,上面已经写了
ROLE的话好像是在权限前面加ROLE_
SpringSecurity给用户授权,一个用户能同时拥有多种身份Role,及权限鉴权注解方法hasRole及hasAuthority的使用区别-CSDN博客
然后:单体的思路就是自己去校验用户名密码,然后用JWTUtils去颁发令牌,属于是替换了原来的流程,帮忙校验了。然后每次进来用个过滤器,把权限附上去就完了