Spring Cloud Gateway + Spring Security OAuth2 + JWT 实现统一认证授权和网关鉴权

本文涉及的产品
Redis 开源版,标准版 2GB
推荐场景:
搭建游戏排行榜
简介: Spring Cloud Gateway + Spring Security OAuth2 + JWT 实现统一认证授权和网关鉴权

一. 前言

hi,大家好~ 好久没更文了,期间主要致力于项目的功能升级和问题修复中,经过一年时间这里只贴出关键部分代码的打磨,【有来】终于迎来v2.0版本,相较于v1.x版本主要完善了OAuth2认证授权、鉴权的逻辑,结合小伙伴提出来的建议,。


写这篇文章的除了对一年来项目的阶段性总结,也是希望帮助大家快速理解当下流行的OAuth2认证授权模式,以及其在当下主流的微服务+前后端分离开发模式(Spring Cloud + Vue)的实践应用。


在此之前自己有写过有关 Spring Security OAuth2 + Gateway 统一认证授权+鉴权 和 基于网关统一鉴权的RBAC权限设计的两篇文章:


Spring Cloud实战 | 第六篇:Spring Cloud + Spring Security OAuth2 + JWT实现微服务统一认证鉴权


Spring Cloud实战 | 第十一篇:Spring Cloud Gateway统一鉴权下针对RESTful接口的RBAC权限设计方案,附Vue按钮权限控制


本篇可以说是在项目升级后对上面两篇文章的总结。


二. 项目介绍

1. 项目简介

youlai-mall 是基于Spring Boot 2.5.0、Spring Cloud 2020 、Spring Cloud Alibaba 2021、vue、element-ui、uni-app快速构建的一套全栈开源商城平台,包括后端微服务、前端管理、微信小程序和APP应用。


2. 项目源码

项目名称 码云(Gitee) Github

微服务后台 youlai-mall youlai-mall

系统管理前端 youlai-mall-admin youlai-mall-admin

微信小程序 youlai-mall-weapp youlai-mall-weapp

APP端【暂不更新】 youlai-mall-app youlai-mall-app

码云(Gitee) GitHub

微信图片_20230706084931.png微信图片_20230706085005.png

3. 项目预览

线上预览地址

地址: www.youlai.tech 用户名/密码: admin/123456


系统管理端

微信图片_20230706085142.png微信图片_20230706085145.png微信图片_20230706085208.png微信图片_20230706085210.png

微信小程序

 

 

4. 项目文档

Spring Cloud 实战

Spring Cloud实战 | 第一篇:Windows搭建Nacos服务

Spring Cloud实战 | 第二篇:Spring Cloud整合Nacos实现注册中心

Spring Cloud实战 | 第三篇:Spring Cloud整合Nacos实现配置中心

Spring Cloud实战 | 第四篇:Spring Cloud整合Gateway实现API网关

Spring Cloud实战 | 第五篇:Spring Cloud整合OpenFeign实现微服务之间的调用

Spring Cloud实战 | 第六篇:Spring Cloud Gateway+Spring Security OAuth2+JWT实现微服务统一认证授权

Spring Cloud实战 | 最七篇:Spring Cloud Gateway+Spring Security OAuth2认证授权模式注销JWT失效方案

Spring Cloud实战 | 最八篇:Spring Cloud + Spring Security OAuth2+ Vue前后端分离模式下无感知刷新实现JWT续期

Spring Cloud实战 | 最九篇:Spring Cloud + Spring Security OAuth2认证服务器统一认证自定义异常处理

Spring Cloud实战 | 第十篇 : Spring Cloud + Nacos整合Seata 1.4.1实现分布式事务

Spring Cloud实战 | 第十一篇:Spring Cloud Gateway统一鉴权下针对RESTful接口的RBAC权限设计方案,附Vue按钮权限控制

Spring Cloud & Alibaba 实战 | 第十二篇: Sentinel+Nacos实现网关和普通流控、熔断降级

vue + element-ui实战

vue-element-admin实战 | 第一篇: 移除mock接入微服务接口,搭建Spring Cloud+Vue前后端分离管理平台

vue-element-admin实战 | 第二篇: 最小改动接入后台接口实现根据权限动态加载菜单

uni-app 实战

uni-app实战 | 第一篇:从0到1快速开发一个商城微信小程序,无缝接入Spring Security OAuth2认证授权登录

5. 版本升级

此次升级2.0.0版本主要内容和说明整理如下:


【认证服务器】youlai-auth 添加自定义客户端信息获取类;


说明: 通过ClientDetailsServiceImpl#loadClientByClientId方法feign远程获取客户端信息,后续版本计划添加多级缓存提升性能;


【认证服务器】youlai-auth 添加JWT生成器JwtGenerator;


说明: 包含秘钥库加签、设置有效期和增强,适用一些除OAuth2自带常用的4种认证模式之外的一些特殊场景,目前暂不支持JWT续期,后续版本计划添加;


【资源服务器】youlai-gateway 添加本地公钥加载方式;


说明: 这里有个问题是比较多人问的,就是如何根据秘钥库生成公钥,下文详细说明;


【RBAC权限设计】请求接口权限和按钮权限归并在一条数据;


**说明:**根据反馈大多数场景下前端如果设置了按钮权限(显示/隐藏),后端也需同时设置其接口权限拦截,可以算的上相辅相成的存在;


【表结构】 OAuth2官方表oauth_client_details重命名了sys_oauth_client;


**说明:**这个不要问,问就是强迫症,把OAuth2客户端作为可管理的数据放在了系统管理部分,不重命名这张表就显得很个性;


【依赖包升级】Spring Boot、Spring Cloud 、Spring Cloud Alibaba 、 Spring Security OAuth2等升级至最新版本, 具体最新版本源码中查看;


**说明:**其中要注意的是Spring Security OAuth2新版本认证接口不支持将客户端信息(client_id/client_secret)放在请求路径中,已经有多位小伙伴在使用Postman测试将其放在请求路径中报了401的错误;


【API】根据系统管理端和小程序/APP端设置不同的前缀标识进行区分,系统管理端接口请求前缀标识使用/api,小程序端/APP端请求前缀标识使用/app-api;


**说明:**这样设计目的在于一个微服务同时要给管理端和小程序端/APP同时提供不同的接口服务,其实这样没问题,但是系统管理端除了登录还需要鉴权,小程序/APP端仅需要登录,所以添加不同的标识区别。其实如果有资源和条件可以把系统管理服务接口和小程序/APP服务接口拆开来,这有点映照如果不是生活所迫,谁愿意一身才华这句。


6. ToDoList

项目2.x版本计划事项


多租户


IM即时通讯(Netty/zookeeper/redis)


商品搜索(ElasticSearch)


移动端Android、IOS端适配(uni-app)


Vue2.x升级Vue3.x


分布式链路追踪(SkyWalking)


多级缓存(商品/权限)


OAuth2授权码模式


分布式事务(Seata TCC模式)


日志搜集(EFK)



三. OAuth2认证授权

1. OAuth2的定义

OAuth2概念

以下摘自阮一峰老师的文章 OAuth 2.0 的一个简单解释


OAuth2.0是目前最流行的授权机制,用来授权第三方应用,获取用户数据。


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


OAuth2角色

资源拥有者(Resource Owner):用户。

第三方应用程序(Client):也称为“客户端”,客户端需要资源服务器的资源(用户信息)。

认证服务器(Authorization Server):提供登录认证的接口。

资源服务器(Resource Server):客户端携带token获取资源的目标服务器,需能校验token;一般和认证服务器同一台服务器,也可以是不同的服务器。

注意:OAuth2的资源是用户信息(ID,昵称、性别、头像等),而非微服务资源(商品服务、订单服务等)。


OAuth2流程

概念和角色定义这些比较模糊,接下来用【有来项目】演示下OAuth2整个流程,方便快速理解OAuth2,先看下整个项目架构流程图

微信图片_20230706154620.png



流程举例:


用户请求订单服务(OAuth2客户端)想获取自己的订单数据 ,但获取订单数据需要用户的资源(比如用户ID),所以需要先到认证中心(OAuth2认证服务器)去认证,认证通过后会返回JWT,接下来用户携带JWT请求订单服务,其中会经过网关(OAuth2资源服务器),网关验证JWT是否有效,验证有效则将携带着用户资源的JWT传递给订单服务,订单服务拿到用户ID之后即可获取到用户的订单数据。


一般资源服务器和认证服务器是同一台服务器,但在这里将资源服务器从认证服务器分离到了网关,个人觉得主要是因为网关的特性,因为所有的服务访问都必须经过网关,可以统一校验JWT的有效性,通过后将携带用户资源的JWT给对应的服务,同样也是契合微服务的单一职责原则,降低耦合度。


2. OAuth2认证服务器

OAuth2认证服务器的职责很好理解,提供认证接口,认证通过后返回生成token,对应【有来项目】的youlai-auth认证中心。


认证接口及调试

很多刚接触Spring Security OAuth2的小伙伴不知道其认证接口在哪里。所以这里稍微提一下认证endpoint是/oauth/token,【有来】中重写此认证endpoint,位于OAuthController#postAccessToken方法。


Postman认证接口调试

微信图片_20230706154719.png微信图片_20230706154722.png





Knife4j认证接口调试(墙裂推荐)


网关youlai-gateway启动后,其服务端口是9999,然后访问 http://localhost:9999/doc.html


点击左侧目录的第二个节点Authorize填写OAuth2的参数完成认证


微信图片_20230706154740.png


认证通过后,再点击该微服务的其他接口,会将认证接口生成的token自动填充到请求头中,非常方便和人性化

微信图片_20230706154744.png



核心代码

这里只贴出认证中心youlai-auth关键部分代码,完整代码请从码云Gitee或Github获取。


pom依赖


   org.springframework.security.oauth.boot

   spring-security-oauth2-autoconfigure



   org.springframework.security

   spring-security-oauth2-jose


1

2

3

4

5

6

7

8

安全拦截配置

@Configuration

@EnableWebSecurity

@Slf4j

public class WebSecurityConfig extends WebSecurityConfigurerAdapter {


   /**

    * Security接口拦截配置

    */

   @Override

   protected void configure(HttpSecurity http) throws Exception {

       http.authorizeRequests().antMatchers("/oauth/**").permitAll()

           // @link https://gitee.com/xiaoym/knife4j/issues/I1Q5X6 (接口文档knife4j需要放行的规则)

           .antMatchers("/webjars/**", "/doc.html", "/swagger-resources/**", "/v2/api-docs").permitAll()

           .anyRequest().authenticated()

           .and()

           .csrf().disable();

   }

 

   @Bean

   public AuthenticationManager authenticationManagerBean() throws Exception {

       return super.authenticationManagerBean();

   }

}


1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

认证授权配置

@Configuration

@EnableAuthorizationServer

@AllArgsConstructor

public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {


   private AuthenticationManager authenticationManager;

   private UserDetailsServiceImpl userDetailsService;

   private ClientDetailsServiceImpl clientDetailsService;


   /**

    * OAuth2客户端【数据库加载】

    */

   @Override

   @SneakyThrows

   public void configure(ClientDetailsServiceConfigurer clients) {

       clients.withClientDetails(clientDetailsService);

   }


   /**

    * 配置授权(authorization)以及令牌(token)的访问端点和令牌服务(token services)

    */

   @Override

   public void configure(AuthorizationServerEndpointsConfigurer endpoints) {

       TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();

       List tokenEnhancers = new ArrayList<>();

       tokenEnhancers.add(tokenEnhancer());

       tokenEnhancers.add(jwtAccessTokenConverter());

       tokenEnhancerChain.setTokenEnhancers(tokenEnhancers);

       endpoints

               .authenticationManager(authenticationManager)

               .accessTokenConverter(jwtAccessTokenConverter())

               .tokenEnhancer(tokenEnhancerChain)

               .userDetailsService(userDetailsService)

               // refresh token有两种使用方式:重复使用(true)、非重复使用(false),默认为true

               //      1 重复使用:access token过期刷新时, refresh token过期时间未改变,仍以初次生成的时间为准

               //      2 非重复使用:access token过期刷新时, refresh token过期时间延续,在refresh token有效期内刷新便永不失效达到无需再次登录的目的

               .reuseRefreshTokens(true);

   }


   /**

    * 使用非对称加密算法对token签名

    */

   @Bean

   public JwtAccessTokenConverter jwtAccessTokenConverter() {

       JwtAccessTokenConverter converter = new JwtAccessTokenConverter();

       converter.setKeyPair(keyPair());

       return converter;

   }


   /**

    * 从classpath下的密钥库中获取密钥对(公钥+私钥)

    */

   @Bean

   public KeyPair keyPair() {

       KeyStoreKeyFactory factory = new KeyStoreKeyFactory(new ClassPathResource("jwt.jks"), "123456".toCharArray());

       KeyPair keyPair = factory.getKeyPair("jwt", "123456".toCharArray());

       return keyPair;

   }


   /**

    * JWT内容增强

    */

   @Bean

   public TokenEnhancer tokenEnhancer() {

       return (accessToken, authentication) -> {

           Map additionalInfo = CollectionUtil.newHashMap();

           OAuthUserDetails OAuthUserDetails = (OAuthUserDetails) authentication.getUserAuthentication().getPrincipal();

           additionalInfo.put("userId", OAuthUserDetails.getId());

           additionalInfo.put("username", OAuthUserDetails.getUsername());

           ((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(additionalInfo);

           return accessToken;

       };

   }


   @Bean

   public DaoAuthenticationProvider authenticationProvider() {

       DaoAuthenticationProvider provider = new DaoAuthenticationProvider();

       provider.setHideUserNotFoundExceptions(false); // 用户不存在异常抛出

       provider.setUserDetailsService(userDetailsService);

       provider.setPasswordEncoder(passwordEncoder());

       return provider;

   }


   /**

    * 密码编码器

    * 委托方式,根据密码的前缀选择对应的encoder,例如:{bcypt}前缀->标识BCYPT算法加密;{noop}->标识不使用任何加密即明文的方式

    * 密码判读 DaoAuthenticationProvider#additionalAuthenticationChecks

    */

   @Bean

   public PasswordEncoder passwordEncoder() {

       return PasswordEncoderFactories.createDelegatingPasswordEncoder();

   }

}


认证授权配置类主要实现功能:


指定构建用户认证信息UserDetailsService为UserDetailsServiceImpl,从数据库获取用户信息和前端传值进行密码判读

指定构建客户端认证信息ClientDetailsService为ClientDetailsServiceImpl,从数据库获取客户端信息和前端传值进行密码判读

JWT加签,从密钥库获取密钥对完成对JWT的签名,密钥库如何生成下文细说

JWT增强

UserDetailService自定义实现加载用户认证信息


@Service

@AllArgsConstructor

public class UserDetailsServiceImpl implements UserDetailsService {


   private UserFeignClient userFeignClient;


   @Override

   public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

       String clientId = JwtUtils.getAuthClientId();

       OAuthClientEnum client = OAuthClientEnum.getByClientId(clientId);


       Result result;

       OAuthUserDetails oauthUserDetails = null;

       switch (client) {

           default:

               result = userFeignClient.getUserByUsername(username);

               if (ResultCode.SUCCESS.getCode().equals(result.getCode())) {

                   SysUser sysUser = (SysUser)result.getData();

                   oauthUserDetails = new OAuthUserDetails(sysUser);

               }

               break;

       }

       if (oauthUserDetails == null || oauthUserDetails.getId() == null) {

           throw new UsernameNotFoundException(ResultCode.USER_NOT_EXIST.getMsg());

       } else if (!oauthUserDetails.isEnabled()) {

           throw new DisabledException("该账户已被禁用!");

       } else if (!oauthUserDetails.isAccountNonLocked()) {

           throw new LockedException("该账号已被锁定!");

       } else if (!oauthUserDetails.isAccountNonExpired()) {

           throw new AccountExpiredException("该账号已过期!");

       }

       return oauthUserDetails;

   }

}


ClientDetailsService自定义实现客户端认证信息


@Service

@AllArgsConstructor

public class ClientDetailsServiceImpl implements ClientDetailsService {


   private OAuthClientFeignClient oAuthClientFeignClient;


   @Override

   @SneakyThrows

   public ClientDetails loadClientByClientId(String clientId) {

       try {

           Result result = oAuthClientFeignClient.getOAuthClientById(clientId);

           if (Result.success().getCode().equals(result.getCode())) {

               SysOauthClient client = result.getData();

               BaseClientDetails clientDetails = new BaseClientDetails(

                       client.getClientId(),

                       client.getResourceIds(),

                       client.getScope(),

                       client.getAuthorizedGrantTypes(),

                       client.getAuthorities(),

                       client.getWebServerRedirectUri());

               clientDetails.setClientSecret(PasswordEncoderTypeEnum.NOOP.getPrefix() + client.getClientSecret());

               return clientDetails;

           } else {

               throw new NoSuchClientException("No client with requested id: " + clientId);

           }

       } catch (EmptyResultDataAccessException var4) {

           throw new NoSuchClientException("No client with requested id: " + clientId);

       }

   }

}

生成密钥库

生成密钥库脚本命令


keytool -genkey -alias jwt -keyalg RSA -keypass 123456 -keystore jwt.jks -storepass 123456

1

参数说明


-alias 别名

-keyalg 密钥算法

-keypass 密钥口令

-keystore 生成密钥库的存储路径和名称

-storepass 密钥库口令


微信图片_20230706154801.png


3. OAuth2资源服务器

OAuth2资源服务器是提供给客户端资源的服务器,有验证token的能力,token有效则放开资源,对应【有来项目】的youlai-gateway网关。


核心代码

这里只贴出网关youlai-gateway关键部分代码,完整代码请从码云Gitee或Github获取。


pom依赖



   org.springframework.security

   spring-security-config



   org.springframework.security

   spring-security-oauth2-resource-server



   org.springframework.security

   spring-security-oauth2-jose



统一鉴权管理器

微服务项目最终对外暴露的只有网关服务一个端口,其他微服务端口不对外暴露,所有的请求都会经过网关路由转发到内网微服务上,所以网关是进行接口访问权限校验最好的实践地。


原因有以下:


降低开发成本,不必为每个微服务单独引入Security模块,专注业务模块的开发;

缩短访问链路,无权访问的请求直接在网关被拦截;

统一入口,统一拦截。

不过网关鉴权有个需注意的地方,因为项目API设计遵守RESTful接口设计规范,基于RESTful然后我举个例子说,给你一个/youlai-admin/users/1请求路径,你没法判断是获取ID为1的用户信息还是修改ID为1的用户信息,怎么办?


所以将请求方法和请求路径结合生成restfulPath = GET:/youlai-admin/users/1,这样系统就可以进行区分,在设置权限拦截规则的时候需要考虑到,具体的在下文的RBAC权限设计详细说,这里暂只贴出网关鉴权的逻辑代码。


@Component

@AllArgsConstructor

@Slf4j

public class ResourceServerManager implements ReactiveAuthorizationManager {


   private RedisTemplate redisTemplate;


   @Override

   public Mono check(Mono mono, AuthorizationContext authorizationContext) {

       ServerHttpRequest request = authorizationContext.getExchange().getRequest();

       // 预检请求放行

       if (request.getMethod() == HttpMethod.OPTIONS) {

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

       }

       PathMatcher pathMatcher = new AntPathMatcher(); // 【声明定义】Ant路径匹配模式,“请求路径”和缓存中权限规则的“URL权限标识”匹配

       String path = request.getURI().getPath();


       String token = request.getHeaders().getFirst(AuthConstants.AUTHORIZATION_KEY);


       // 移动端请求无需鉴权,只需认证(即JWT的验签和是否过期判断)

       if (pathMatcher.match(GlobalConstants.APP_API_PATTERN, path)) {

           // 如果token以"bearer "为前缀,到这一步说明是经过NimbusReactiveJwtDecoder#decode和JwtTimestampValidator#validate等解析和验证通过的,即已认证

           if (StrUtil.isNotBlank(token) && token.startsWith(AuthConstants.AUTHORIZATION_PREFIX)) {

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

           } else {

               return Mono.just(new AuthorizationDecision(false));

           }

       }


       // Restful接口权限设计 @link https://www.cnblogs.com/haoxianrui/p/14396990.html

       String restfulPath = request.getMethodValue() + ":" + path;

       log.info("请求方法:RESTFul请求路径:{}", restfulPath);


       // 缓存取【URL权限标识->角色集合】权限规则

       Map permRolesRules = redisTemplate.opsForHash().entries(GlobalConstants.URL_PERM_ROLES_KEY);


       // 根据 “请求路径” 和 权限规则中的“URL权限标识”进行Ant匹配,得出拥有权限的角色集合

       Set hasPermissionRoles = CollectionUtil.newHashSet(); // 【声明定义】有权限的角色集合

       boolean needToCheck = false; // 【声明定义】是否需要被拦截检查的请求,如果缓存中权限规则中没有任何URL权限标识和此次请求的URL匹配,默认不需要被鉴权


       for (Map.Entry permRoles : permRolesRules.entrySet()) {

           String perm = permRoles.getKey(); // 缓存权限规则的键:URL权限标识

           if (pathMatcher.match(perm, restfulPath)) {

               List roles = Convert.toList(String.class, permRoles.getValue()); // 缓存权限规则的值:有请求路径访问权限的角色集合

               hasPermissionRoles.addAll(Convert.toList(String.class, roles));

               if (needToCheck == false) {

                   needToCheck = true;

               }

           }

       }

       // 没有设置权限规则放行;注:如果默认想拦截所有的请求请移除needToCheck变量逻辑即可,根据需求定制

       if (needToCheck == false) {

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

       }


       // 判断用户JWT中携带的角色是否有能通过权限拦截的角色

       Mono authorizationDecisionMono = mono

               .filter(Authentication::isAuthenticated)

               .flatMapIterable(Authentication::getAuthorities)

               .map(GrantedAuthority::getAuthority)

               .any(authority -> {

                   log.info("用户权限(角色) : {}", authority); // ROLE_ROOT

                   String role = authority.substring(AuthConstants.AUTHORITY_PREFIX.length()); // 角色编码 ROOT

                   if (GlobalConstants.ROOT_ROLE_CODE.equals(role)) { // 如果是超级管理员则放行

                       return true;

                   }

                   return CollectionUtil.isNotEmpty(hasPermissionRoles) && hasPermissionRoles.contains(role); // 用户角色中只要有一个满足则通过权限校验

               })

               .map(AuthorizationDecision::new)

               .defaultIfEmpty(new AuthorizationDecision(false));

       return authorizationDecisionMono;

   }

}



资源服务器配置

@ConfigurationProperties(prefix = "security")

@AllArgsConstructor

@Configuration

@EnableWebFluxSecurity

public class ResourceServerConfig {


   private ResourceServerManager resourceServerManager;


   @Setter

   private List ignoreUrls;


   @Bean

   public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {

       http.oauth2ResourceServer().jwt().jwtAuthenticationConverter(jwtAuthenticationConverter())

               .publicKey(rsaPublicKey()) // 本地获取公钥

               //.jwkSetUri() // 远程获取公钥

       ;

       http.oauth2ResourceServer().authenticationEntryPoint(authenticationEntryPoint());

       http.authorizeExchange()

               .pathMatchers(Convert.toStrArray(ignoreUrls)).permitAll()

               .anyExchange().access(resourceServerManager)

               .and()

               .exceptionHandling()

               .accessDeniedHandler(accessDeniedHandler()) // 处理未授权

               .authenticationEntryPoint(authenticationEntryPoint()) //处理未认证

               .and().csrf().disable();


       return http.build();

   }


   /**

    * 未授权自定义响应

    */

   @Bean

   ServerAccessDeniedHandler accessDeniedHandler() {

       return (exchange, denied) -> {

           Mono mono = Mono.defer(() -> Mono.just(exchange.getResponse()))

                   .flatMap(response -> ResponseUtils.writeErrorInfo(response, ResultCode.ACCESS_UNAUTHORIZED));

           return mono;

       };

   }


   /**

    * token无效或者已过期自定义响应

    */

   @Bean

   ServerAuthenticationEntryPoint authenticationEntryPoint() {

       return (exchange, e) -> {

           Mono mono = Mono.defer(() -> Mono.just(exchange.getResponse()))

                   .flatMap(response -> ResponseUtils.writeErrorInfo(response, ResultCode.TOKEN_INVALID_OR_EXPIRED));

           return mono;

       };

   }


   /**

    * @return

    * @link https://blog.csdn.net/qq_24230139/article/details/105091273

    * ServerHttpSecurity没有将jwt中authorities的负载部分当做Authentication

    * 需要把jwt的Claim中的authorities加入

    * 方案:重新定义权限管理器,默认转换器JwtGrantedAuthoritiesConverter

    */

   @Bean

   public Converter> jwtAuthenticationConverter() {

       JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();

       jwtGrantedAuthoritiesConverter.setAuthorityPrefix(AuthConstants.AUTHORITY_PREFIX);

       jwtGrantedAuthoritiesConverter.setAuthoritiesClaimName(AuthConstants.JWT_AUTHORITIES_KEY);


       JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();

       jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter);

       return new ReactiveJwtAuthenticationConverterAdapter(jwtAuthenticationConverter);

   }


   /**

    * 本地加载JWT验签公钥

    * @return

    */

   @SneakyThrows

   @Bean

   public RSAPublicKey rsaPublicKey() {

       Resource resource = new ClassPathResource("public.key");

       InputStream is = resource.getInputStream();

       String publicKeyData = IoUtil.read(is).toString();

       X509EncodedKeySpec keySpec = new X509EncodedKeySpec((Base64.decode(publicKeyData)));


       KeyFactory keyFactory = KeyFactory.getInstance("RSA");

       RSAPublicKey rsaPublicKey = (RSAPublicKey)keyFactory.generatePublic(keySpec);

       return rsaPublicKey;

   }

}


资源服务器配置类主要实现功能:


配置访问白名单列表 ignoreUrls,白名单请求无需认证和鉴权;

配置本地方式获取公钥或者远程获取公钥,公钥验证JWT的签名,其中本地公钥方式【有来项目】2.0.0版本新增;

配置未授权、token无效或者已过期的自定义异常。

http.oauth2ResourceServer().jwt().jwtAuthenticationConverter(jwtAuthenticationConverter())

               .publicKey(rsaPublicKey()) // 本地获取公钥

               //.jwkSetUri() // 远程获取公钥

       ;


OAuth2资源服务器(网关)在对JWT验签的时候需要使用公钥,通过上面代码可以看到加载公钥有两种方式,分为本地和远程两种方式,下面就两种方式如何实现进行说明,同时也补充下版本2.0.0新增的本地加载公钥方式中公钥是怎么根据密钥库生成的。


远程加载公钥

认证中心youlai-auth添加获取公钥接口


   @ApiOperation(value = "获取公钥", notes = "login")

   @GetMapping("/public-key")

   public Map getPublicKey() {

       RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();

       RSAKey key = new RSAKey.Builder(publicKey).build();

       return new JWKSet(key).toJSONObject();


网关youlai-gateway配置公钥的远程请求地址


spring:

security:

  oauth2:

    resourceserver:

      jwt:

        jwk-set-uri: 'http://localhost:9999/youlai-auth/oauth/public-key'


本地加载公钥

   /**

    * 本地加载JWT验签公钥

    * @return

    */

   @SneakyThrows

   @Bean

   public RSAPublicKey rsaPublicKey() {

       Resource resource = new ClassPathResource("public.key");

       InputStream is = resource.getInputStream();

       String publicKeyData = IoUtil.read(is).toString();

       X509EncodedKeySpec keySpec = new X509EncodedKeySpec((Base64.decode(publicKeyData)));


       KeyFactory keyFactory = KeyFactory.getInstance("RSA");

       RSAPublicKey rsaPublicKey = (RSAPublicKey)keyFactory.generatePublic(keySpec);

       return rsaPublicKey;

   }


本地加载方式第一步是加载类路径下的公钥pulic.key,那么这个公钥是怎么生成的?



微信图片_20230706154820.png

生成公钥

其实有关公钥的生成,Github项目中一个issue有详细的描述 https://github.com/hxrui/youlai-mall/issues/27


微信图片_20230706154822.png


在这里补充下其详细生成过程


首先访问 http://slproweb.com/products/Win32OpenSSL.html 下载OpenSSL ,根据系统选择对应版本

微信图片_20230706154849.png



添加OpenSSL安装后的bin路径如D:\Program Files\OpenSSL-Win64\bin 至系统环境变量path中


cmd切换到密钥库jwt.jks所在路径中,执行keytool -list -rfc --keystore jwt.jks | openssl x509 -inform pem -pubkey


输入密钥库口令就可以看到生成的公钥,将内容复制到pulic.key文件即可

微信图片_20230706154927.png



重新生成密钥库后,项目需mvn clean,同步更新公钥内容,否则token验签过不了报token无效


四. 网关统一鉴权

在上一章节提到网关是所有微服务请求的入口,在这里进行统一鉴权是不二之选;不过针对RESTful接口统一鉴权的情况,配置拦截路径的规则需携带请求方法加以区别。


接下来就【有来项目】中如何实现Spring Cloud Gateway + RESTful接口统一拦截鉴权而进行的权限设计进行说明。


1. RBAC权限模型

RBAC(Role-Based Access Control)基于角色访问控制,目前使用最为广泛的权限模型。


此模型有三个角色用户、角色和权限,在传统的权限模型用户直接关联加了角色层,解耦了用户和权限,使得权限系统有了更清晰的职责划分和更高的灵活度。

微信图片_20230706154938.jpg



这种RBAC权限设计和市面上大差不差,区别的是sys_permission权限表的设计:


权限表中的menu_id字段标识该权限属于某个菜单模块,仅方便模块管理,无强关联;

权限标识分为接口权限标识url_perm和按钮权限标识btn_perm,网关只能根据请求路径去鉴权,和按钮的权限标识区别很大。

先看下sys_permission权限表的数据,比较下接口权限标识(url_perm)和按钮权限(btn_perm)标识的区别


微信图片_20230706154950.png


2. 权限管理

添加菜单

进入菜单管理页面,进入表单页面,可以看到这是针对vue-router路由做的菜单设计,系统实现了动态权限路由加载以及路由两种编程式跳转




添加权限

首先选择菜单,右侧关联加载出权限数据,注意这里的关联只是方便权限模块化管理,无实际关联设计

微信图片_20230706155000.png



设置URL权限拦截规则,因为是RESTful的接口设计,所以规则中需携带请求Method,在网关鉴权使用Ant匹配器,下图中的*匹配任意参数


微信图片_20230706155009.png

微信图片_20230706155021.png

角色授权

进入角色管理页面,点击选择角色→选择菜单→加载权限,勾选设置


微信图片_20230706155031.png


3. 权限验证

上面设置系统管理员有用户管理、角色管理、菜单管理3个菜单和查看用户和编辑用户2个接口和按钮权限,刷新页面后如下,可以看到页面只有3个菜单,并且新增和删除按钮未在页面显示

微信图片_20230706155033.png



添加部门菜单,但未授权查询部门列表权限,刷新页面看到部门管理菜单出现了


微信图片_20230706155052.png


点击部门管理菜单请求部门分页列表接口时,提示访问未授权,即接口拦截规则生效


微信图片_20230706155055.png


4. 权限实现原理

接口权限

权限规则数据

在系统管理完成对接口权限的设置,先看下数据库的权限数据


微信图片_20230706155110.png


权限规则数据加载至缓存


因为权限数据使用频率高但变化频率不高,目前将其加载至Redis缓存,后续添加本地缓存的多级缓存策略进行优化


/**

* Spring容器启动完成时加载权限规则至Redis缓存

*/

@Component

@AllArgsConstructor

public class InitPermissionRoles implements CommandLineRunner {


   private ISysPermissionService iSysPermissionService;


   @Override

   public void run(String... args) {

       iSysPermissionService.refreshPermRolesRules();

   }

}

具体加载详见源码,加载完成后在Redis呈现出来的数据如下


微信图片_20230706155122.png


网关鉴权代码调试


接下来就是关键部分了,之前无论RBAC权限设计、还是管理平台的操作和权限规则缓存加载等都是为了网关统一鉴权做的准备工作


当请求到网关时,如果有配置权限拦截规则但未配置白名单的请求需要走鉴权的逻辑,下面通过代码调试来看下鉴权过程:


进入ResourceServerManager#check方法,网关鉴权开始,这里拿系统管理员(ADMIN)访问部门列表接口举例


根据请求方法和请求路径拼接自定义的 restfulPath = GET:/youlai-admin/api/v1/depts


微信图片_20230706155134.png


从Redis缓存读取权限规则,可以看到权限规则列表中有匹配部门列表接口的规则


微信图片_20230706155137.png

从权限规则中获取有部门列表接口权限的角色,可以看到有权限的角色集合并没有ADMIN

微信图片_20230706155156.png


最后一步,拿当前用户JWT携带的角色和拥有权限的角色进行匹配,只要有一个匹配,就说明用户拥有访问权限则放行,但上面的结果可想而知,系统管理员并没有部门列表接口的访问权限,则鉴权不通过被拦截

微信图片_20230706155158.png


按钮权限

按钮权限实现原理


按钮权限控制的核心是Vue自定义指令,Vue除了内置指令有v-model 、v-if和v-show等,同样也支持注册自定义指令作用在元素上。


项目中使用Vue.directive注册自定义指令v-has-permission来判断当前登录用户是否拥有按钮权限。看下图就明白如何应用的:


微信图片_20230706155229.png


Vue自定义指令


如何自定义Vue指令并注册成全局指令呢?其实vue-element-admin已自定义过很多的指令,仅需跟着照葫芦/画瓢就行。

微信图片_20230706155232.png

在 src/directive/permission路径添加hasPermission.js文件,编写按钮权限控制代码逻辑

微信图片_20230706155122.png


注册v-has-permission全局指令,在main.js注册成全局指


微信图片_20230706155301.png


按钮元素使用自定义指令

微信图片_20230706155258.png



最后提一下,用户是在登录成功的时候获取用户信息时拿到的按钮权限标识集合


微信图片_20230706155303.png


五. 常见问题

收集一些项目的issue和被常见的问题。


启动网关GatewayApplication报错,Error:Kotlin: Module was compiled with an incompatible version of Kotlin.


IDEA禁用Kotlin插件


Mybatis参数和请求参数注解报错


IDEA版本升级


token无效或已过期


进入https://jwt.io/ 解析JWT查看是否过期

是否更换过密钥库jwt.jks,如果更换网关本地需同步更新公钥内容public.key,执行mvn clean再启动项目

源码调试分析,JWT解析源码坐标:NimbusReactiveJwtDecoder#decode;JWT过期校验源码坐标:JwtTimestampValidator#validate

OAuth2认证授权报错,401 Unauthorized


客户端信息错误,新版本Spring Security OAuth2不支持客户端信息(client_id/client_secret)放入请求路径,Base64加密后放在请求头


认证中心Security已配置放行,还是进入不到/oauth/token接口


这个问题和上面的问题都可以在过滤器BasicAuthenticationFilter#doFilterInternal方法添加断点调试分析


Cannot load keys from store: class path resource [xxx.jks]


检查获取KeyPair密钥对的时候输入的密钥库密码是否正确

更换密钥库jwt.jks的同时网关需同步更新公钥内容public.key,执行mvn clean再启动项目

密码或用户名错误

微信图片_20230706155335.png

源码调试分析,密码判断源码坐标:DaoAuthenticationProvider#additionalAuthenticationChecks

微信图片_20230706155337.png



前端工程npm install报错


本地是否安装git

请确认有个好的网络环境,需从GitHub下载依赖

项目中使的用自动代码生成工具


MybatisX,Mybatis-Plus官方推荐的IDEA插件,优势在于零配置实现MyBatis-Plus的代码生成,也支持Lombok,如果项目使用Mybatis-Plus,比较推荐

微信图片_20230706155341.png



Maven依赖包缺失


配置阿里云远程仓库,settings.xml找到 标签替换为以下内容


 

  alimaven

  aliyun maven

  http://maven.aliyun.com/nexus/content/groups/public/

  central        

 


删除本地仓库重新下载依赖至本地仓库


OAuth2的认证授权接口请求头Basic是怎么得到


访问在线base64编码





六. 写在最后

本篇内容主要涉及OAuth2认证授权模式的原理以及应用,严格遵守微服务单一职责的设计原则,将资源服务器从认证服务器拆分出来,让认证服务器(认证中心)统一负责认证授权,资源服务器(网关)统一处理鉴权,做到功能上的高度解耦。基于RBAC权限模型设计一套适配微服务+前后端分离开发模式的权限框架,在网关统一鉴权的设计基础上实现了对RESTful规范接口的细粒度鉴权;借助vue.directive自定义指令实现页面的按钮权限控制。总之,【有来】不仅仅是表面上的全栈商城项目,也是一套集成当下主流开发模式、主流技术栈的完整的微服务脚手架项目,没有过度的自定义封装逻辑,容易上手学习和方便二次扩展。最后希望各位道友多多关注开源项目的进展,一起加油,如果项目中遇到问题或者有什么建议,欢迎联系我们。





相关文章
|
监控 负载均衡 Java
深入理解Spring Cloud中的服务网关
深入理解Spring Cloud中的服务网关
|
负载均衡 Java 网络架构
在SpringCloud2023中快速集成SpringCloudGateway网关
本文主要简单介绍SpringCloud2023实战中SpringCoudGateway的搭建。后续的文章将会介绍在微服务中使用熔断Sentinel、鉴权OAuth2、SSO等技术。
493 2
在SpringCloud2023中快速集成SpringCloudGateway网关
|
安全 Java 数据安全/隐私保护
|
XML Java 数据格式
如何使用 Spring Cloud 实现网关
如何使用 Spring Cloud 实现网关
348 3
|
JSON 前端开发 Java
SpringCloud怎么搭建GateWay网关&统一登录模块
本文来分享一下,最近我在自己的项目中实现的认证服务,目前比较简单,就是可以提供一个公共的服务,专门来处理登录请求,然后我还在API网关处实现了登录拦截的效果,因为在一个博客系统中,有一些地址是可以不登录的,比方说首页;也有一些是必须登录的,比如发布文章、评论等。所以,在网关处可以支持自定义一些不需要登录的地址,一些需要登录的地址,也可以在网关处进行校验,如果未登录,可以返回JSON格式的出参,前端可以进行相关处理,比如跳转到登录页面等。
721 4
|
监控 负载均衡 Java
深入理解Spring Cloud中的服务网关
深入理解Spring Cloud中的服务网关
|
Java API 开发者
Spring Cloud Gateway中的GlobalFilter:构建强大的API网关过滤器
Spring Cloud Gateway中的GlobalFilter:构建强大的API网关过滤器
1159 0
|
Java Nacos 网络架构
Spring Cloud gateway 网关四 动态路由
Spring Cloud gateway 网关四 动态路由
|
微服务
springCloud之路由网关gateway
springCloud之路由网关gateway
330 0
|
负载均衡 Java API
Spring Cloud Gateway 详解:构建高效的API网关解决方案
Spring Cloud Gateway 详解:构建高效的API网关解决方案
655 0

热门文章

最新文章