Spring Cloud_zuul+shiro+jwt整合

作者: 小疯子 分类: Spring Cloud 发布时间: 2020-02-20 11:19

搞了一个周起码实现了基本功能,但是深入研究还是有些乱,先把目前开发的情况整理一下方便后期继续。

参考链接:

Springcloud的一种权限实现方案,zuul和shiro:https://blog.csdn.net/u014203449/article/details/88087516
跟我学shiro,多项目:https://www.w3cschool.cn/shiro/pe2s1ifu.html
我的git项目:https://github.com/shuishuiafeng/shop

一. 目标

1.外部请求统一从网关zuul进入,并且服务内部互相调用接口要校验权限

2.cloud和shiro结合,达到单点登录,和集中一个服务完成权限管理,其他业务服务不需要关注权限如何实现

3.其他服务依然可以控制权限细粒度到接口,如在接口上使用@RequirePermisson等注解,方便开发(这个我没用上,因为目前我是根据数据库表中定义的权限来分辨接口是否可访问的)

二、思路

SpirngCloud zuul网关有两个作用,一个是分配路由,一个是过滤。zuul的过滤器作用有限,只能简单的做一些某个url是否能够访问之类的,无法像shiro一样细粒度到某个用户是否有某种权限;(zuul过滤器书上的例子是在请求开始前判断请求头是否存在token,不存在的一律拦截,这种简单的过滤功能)

shiro单体应用实现登录和权限过滤,但是如果放到spring cloud微服务之后,总不能每个微服务都要写一套shiro框架?这样显然太麻烦了。这么几个思路:

1.在zuul服务里用shiro,做成动态url权限控制,就是把访问哪个url需要用什么权限,写入数据库,在过滤器读取与用户有的权限作对比;但是服务互相调用校验就行不通了,因为服务间调用不通过zuul

2.写一个微服务专用于shiro认证和授权,包含用户、权限的curd,暴露出查询一个用户拥有什么权限的接口;在其他服务中,都写一个拦截器拿访问者token去授权服务拿此用户的权限,再跟请求的url对比(通过token可以得到当前的登录用户信息,从而也是能得到用户拥有的权限url,再跟请求的url对比可以判断是否有访问请求url的权限);或者可以自定义注解用aop,注解标注的是访问此url需要什么权限,远程调用授权服务接口查询当前用户所有权限,与请求的url对比。

但是这个要自己实现拦截器。
3.第二种思路的简单版本。(就用它)

server服务:专用于shiro认证和授权,包含用户、权限的curd,暴露出查询一个用户拥有什么权限的接口;

client项目:打成jar包供其他服务依赖,用shiro,client不同于server服务的是:

1.在realm(获取身份验证信息(doGetAuthenticationInfo)及授权信息(doGetAuthorizationInfo))中只有授权方法,没有认证方法,因为不需要 client项目 认证、需要 server服务 认证。将登陆地址配置为 server服务 的地址。这样未登录的用户都会跳转到server服务登录,想办法保存下原路径,登录成功后再返回原服务(暂未实现).
并且 client项目 的realm授权方法是调用server服务接口查询权限,再返回给client项目的安全管理器。同时做成session共享(???没找到原文例子中哪里共享,没事反正我也没有用session)

其他业务服务:只需要依赖于client。(就可以实现校验权限的功能了)

这种思路来自于《跟我学shiro》的多项目集中权限,其实想想这种思路是可以的,shiro本质也是靠拦截器进行权限校验,虽然相当于每个服务都开启了一套shiro,但也就是容器中多了一些shiro拦截器和实例,而且可以用shiro的各种功能,开发方便。可以完成我们的三个目标。

三、具体实现

Spring Cloud项目搭建,包含有api-gateway作为zuul微服务网关,shiro-base提供关于shiro的相关几个微服务,shiro-base名下有shiro-base-api,就是上面讲的shiro server服务,shiro-client是上面的 shiro client项目相关的微服务,用于打包成jar文件供其他微服务使用;还有我的admin-api提供后台服务的微服务;

3.1 建立eureka,建立zuul服务——api-gateway

所有微服务shiro-base、admin-api、api-gateway都注册到eureka中,这样能够通过zuul访问admin-api和shiro-base微服务
贴api-gateway微服务的zuul配置:(详细看项目代码)

spring:
  application:
    name: api-gateway
server:
  port: 5555
#路由转发
zuul:
   routes:
    shiro-base:
      path: /shiro-base/**
      serviceId: shiro-base
    api-admin-url:
      path: /admin-api/** # 注意访问路径还需要写上serviceId所对应的context-path 即admin-api
      serviceId: admin-api
    api-service-user-url:
      path: /server-user/**
      serviceId: server-user
      sensitiveHeaders:
        - Access-Control-Allow-Origin # 取消敏感头信息过滤掉,默认是过滤掉Cookie、set-Cookie、Authorization三个属性再传递到下游的外部服务器
        - Access-Control-Allow-Methods # 还有对指定路由进行自定义敏感头 https://blog.csdn.net/u014203449/article/details/88087516
  add-host-header: true #使得网关在进行路由转发前为请求设置Host头信息
eureka:
  client:
    service-url:
      defaultZone: ${eureka.client.service-url.default-zone}
ribbon:
  ReadTimeout: 120000
  ConnectTimeout: 30000

3.2 shiro-base服务搭建shiro

。关于登陆、用户、角色、权限的接口都写在base服务中。要求能在base中单独认证成功。
。使用redis做权限缓存(这个好像没留意实现没实现),其他服务认证每次都访问base服务压力比较大,可以优先从缓存拿数据。如果你的shiro安全管理器和sessiondao同时实现了cache接口,直接将redis管理器注入安全管理器即可;或者可以用网上重写的redissessiondao,具体可看我这篇https://blog.csdn.net/u014203449/article/details/80888637
。用feign推荐的项目格式。maven父模块包含两个子模块。api模块写暴露出的接口,impl模块写具体的实现。

在base的接口中写一个通过用户id查询权限码的接口,用feign供其他服务调用。

/**
 * @Author: Xiaofeng
 * @Date: 2020/1/9 20:27
 * @Description: 权限服务: 根据用户名获取用户权限
 */
@FeignClient(name = ShiroBaseConstants.SERVICE_APP_ID)
public interface BaseAuthorityRestService {

    /**
     * 根据用户登录名称获取用户权限列表,以permCode为string返回
     * @param userName
     * @return
     */
    //这个没有权限
    @GetMapping("/getAuthorityByUser")
    public Result<LoginSysUserRedisVo> getAuthorityByUser(@RequestParam("userName") String userName);
}

---shiro-base-api: 提供了feignClient进行访问的各种api接口操作
---shiro-base-common: 提供了通用的一些business、service、convert、filter等
---shiro-base-impl: 提供针对shiro-base-api的实现,以及还有JwtRealm、ShiroConfig等
---shiro-base-jwt: 因为这里登陆是shiro+jwt结合实现的,所以这里提供关于jwt的一些通用操作:生成token,jwt操作工具类

3.2.1 大体流程讲解

(1). 登录流程

通过zuul调用访问shiro-base-api中的shiro-base\shiro-base-api\src\main\java\com\shiro\base\api\v1\BaseLoginRestService.java中的login方法,然后会去shiro-base-impl下的shiro-base\shiro-base-impl\src\main\java\com\shiro\base\controller\LoginController.java子类中找到对应的login方法执行,如下图所示:

然后去找到business中执行的此方法:shiro-base\shiro-base-common\src\main\java\com\shiro\base\business\impl\UserBusinessImpl.java
->userLoginn方法,此方法干了点啥?
step1. 根据用户名从数据库中获取查看此用户是否存在
step2. 判断密码是否正确(通过sha256加密后再增加盐值在来一遍sha256加密)
step3. 获取用户目前的角色和用户权限
step4. 根据用户名和盐值以及过期时间,生成JwtToken,此JwtToken是HostAuthenticationToken的子类,可用于shiro的认证(后面再看再补充)
step5. 执行subject.login操作进行登录;
step6. 登录信息缓存到redis: jwtToken为key,登录用户信息为value
step7. 登录用户信息(包含token)返回给方法调用者
然后再回到LoginController.java文件方法中,将token放到response的header中,返回给前端。

关于登录的详细隐藏操作:

在UserBusinessImpl执行认证登陆subject.login(jwtToken);后会看不到的使用securityManager去调用JwtRealm中的doGetAuthenticationInfo方法进行登录认证,将jwtToken作为参数传给这个方法,返回个带有jwtToken和salt的SimpleAuthenticationInfo,这里就是获取用户的信息了
下一步就是shiro+jwt证书匹配,就是通过jwtToken和SimpleAuthenticationInfo使用JwtCredentialsMatcher(CredentialsMatcher)中的doCredentialsMatch进行密码的比对,将token和SimpleAuthenticationInfo中的盐salt通过jwt相关类工具进行比对,看看这个token是不是通过这个盐生成的token相匹配(JwtUtils.java->verifyToken()方法实现的),如果匹配了就登录成功,返回相关的信息给前端(什么时候调用doCredentialsMatch?在调用 realm的getAuthenticationInfo 方法获取到 AuthenticationInfo 信息后,会使用 credentialsMatcher 来验证凭据是否匹配,如果不匹配将抛出 IncorrectCredentialsException 异常。https://www.w3cschool.cn/shiro/acnz1ifa.html

关于iview前端,登录操作后获取了token,剩下其他操作都会在header中带上token来进行后台的访问,从而进行权限的验证,后续再说。

(2). 授权及权限验证

这里没有用注解的方式进行授权,而是将每一个url访问所需的权限code都保存到数据库表sys_permission中,也就是访问这个url需要有这个code权限;那么用户怎么和权限绑定?sys_role_permission表保存的角色权限关联,用户拥有某个角色,某个角色拥有某些权限,那么就能够知道用户拥有哪些权限了,所以针对登录用户就知道当前用户有哪些权限,然后用户访问某一个url时候先将访问此路径所需要的权限得到,然后和已拥有权限进行比对,所需权限在用户已有权限中包含了就说明这个url可以访问,否则accessdenied。

  • step1. JwtRealm中的doGetAuthorizationInfo获取授权信息,也就是获取当前登录用户的角色、权限列表。principalCollection.getPrimaryPrincipal()得到的就是.login(xxx) -> doGetAuthenticationInfo(xxx)中的xxx,关于Realm的介绍:https://www.w3cschool.cn/shiro/hzlw1ifd.html
  • step2. 然后到ShiroConfig中定义过滤器、拦截器:
    -- a. 过滤器更改"user"、“perms”的过滤操作类,还增加了jwt过滤器,自定义了JwtFilter(主要是通过它来进行访问url时通过传入的token再生成AuthenticationToken执行一次登录login(AuthenticationToken)操作)
    -- b. 拦截器-按照url进行拦截:eg:/areaInfo = perms[area:query,area:add]
    通过sys_permission中存放的数据,知道某个url访问需要有什么权限permissionCode。
    除了有perms过滤拦截之外,还要有前面定义的jwtFilter,也就是说所有url请求都是要带着token先进行登录操作

    上面jwtFilter进行了登录成功操作,然后PermissionAuthorizationFilter进行权限过滤操作:
    PermissionAuthorizationFilter是访问某个路径所需要的权限搜索出来,eg:/areaInfo = perms[area:query,area:add],isAccessAllowed中的mappedValue可能就是对应访问路径的area:query,area:add数组,然后和JwtRealm中得到的当前登录用户权限(JwtRealm中的doGetAuthorizationInfo方法获取的,往下扒源代码也能发现这个逻辑)进行比对,看此用户是否拥有访问这个路径的权限再进行下一步的访问

以上就是关于授权流程的大体讲解:当用户调用url访问后台的时候,首先先根据头部带的token通过jwtFilter进行登录,调用JwtRealm的身份验证进行登录操作;然后会继续执行过滤器链PermissionAuthorizationFilter,其中的subject.isPermitted(permission)方法,首先会调用JwtRealm->doGetAuthorizationInfo得到当前登录用户拥有的角色和权限们,然后和访问某个url所需要拥有的权限permission进行允许比对判断

JwtRealm中的doGetAuthorizationInfo方法:如果身份验证成功,在进行授权时就通过 doGetAuthorizationInfo 方法获取角色 / 权限信息用于授权验证
shiro什么时候会进入doGetAuthorizationInfo(PrincipalCollection principals): https://blog.csdn.net/u014082617/article/details/50949386
-- 1.授权方法,在本项目中就是:isPermitted方法执行时候会执行此方法。(上面PermissionAuthorizationFilter的isAccessAllowed方法中)
-- 2.实际上是 先执行 AuthorizingRealm,自定义realm的父类中的 getAuthorizationInfo方法,
逻辑是先判断缓存中是否有用户的授权信息(用户拥有的操作码),如果有 就直返回不调用自定义 realm的授权方法了,
如果没缓存,再调用自定义realm,去数据库查询。
用库查询一次过后,如果 在安全管理器中注入了 缓存,授权信息就会自动保存在缓存中,下一次调用需要操作码的接口时,
就肯定不会再调用自定义realm授权方法了。 网上有分析AuthorizingRealm,shiro使用缓存的过程
-- 3.AuthorizingRealm 有多个实现类realm,推测可能是把 自定义realm注入了安全管理器,所以才调用自定义的

以上就是在shiro-base自身项目中实现了登录和授权功能;

3.3 建立shiro-client项目

shiro-client项目依赖于base-api(要用base的接口)、open-feign(通过feign调用) 。此项目以后要打包,给admin-api、server-user之类的业务服务使用。

在shiro-client项目中也搭建shiro。与base服务的区别是,uaa的shiro配置中将登陆接口定位到base服务登陆接口。并且要从zuul入口访问_http://localhost:5555/base-api/loginn, 5555是zuul的端口,统一通过zuul访问登陆页面,因为base有可能是多实例的,而且如果直接访问base浏览器会暴露出base服务的地址,我们只应该暴露出zuul的地址。

页面里的js css也要从zuul/服务名访问,否则会访问失败。

shiro-client项目代码说明:

  • a. ShiroClientConfig中的public ShiroFilterFactoryBean shiroFilter方法和shiro-base中的一致,只不过多了个setLoginUrl操作;
  • b. ShiroClientRealm作为shiro-client的Realm,不同于shiro-base中的JwtRealm啦。
    校验权限逻辑是通过调用shiro-base中的getAuthorityByUser调用
    Result authorityByUser = baseAuthorityRestService.getAuthorityByUser(username);
    因为此处的realm不是通过spring注入到 ShiroFilterFactoryBean 的,所以无法在realm中用@Autowired直接调用feign。但当spring启动成功后,注解FeignClient的base接口实例已经注入到了spring中,我这里从spring中手动获取BaseAuthorityRestService 实例再使用。具体看代码上的注释:也就是realm在config中使用并不是通过spring注入的,所以不能使用autowired直接注入已经在spring中的接口实例,只能手动从spring中拿:

    //这里没有直接注入实例,ShiroClientRealm被用在配置类中new出的,直接注入报servercontext not set 的错。只能使用时从spring容器中拿
    baseAuthorityRestService = SpringUtils.getBean(BaseAuthorityRestService.class);
    // 从base shiro中获取当前登陆用户目前所拥有的权限
    Result<LoginSysUserRedisVo> authorityByUser = baseAuthorityRestService.getAuthorityByUser(username);
    待研究研究研究研究----------------------------

    server-user加shiro-client依赖,但这时启动server-user服务,shiro-client中shiro拦截器并没有注入到spring中,很明显springboot只能扫描到启动类路径下的配置注解,jar包中的注解无法直接扫描到。这里我们通过加spring.factories的方式

    理一下header中带着token的url请求,代码的执行顺序:
    url过来,JwtFilter-》isAccessAllowed进行判断,会调用createToken生成类型为AuthenticationToken的token(就是从header中获取的),然后执行login(subject_token);方法,此时执行会调用 JwtRealm中的doGetAuthenticationInfo进行登录认证获取身份验证相关信息,然后还会去CredentialsMatcher以AuthenticationToken的token,和get到的AuthenticationInfo中的salt,以salt生成token和前面的token进行比较verify,如果错误就登录失败啦,成功继续往下走过滤器链:perms的过滤器链,进行权限认证了,权限认证就看上面说过的代码执行流程:PermissionAuthorizationFilter-》isPermitted(permission_访问path所需要拥有的权限)调用ShiroClientRealm的doGetAuthorizationInfo得到当前登录用户所拥有的权限,所拥有的权限和permission通过isPermitted中的某些方法进行验证授权。

    关于jwt token的刷新问题,可以参考这篇文章https://www.sundayfine.com/jwt-refresh-token/
    一个好的模式是在它过期之前刷新令牌。
    将令牌过期时间设置为一周,并在每次用户打开Web应用程序并每隔一小时刷新令牌。如果用户超过一周没有打开过应用程序,那他们就需要再次登录,这是可接受的Web应用程序UX(用户体验)。
    要刷新令牌,API需要一个新的端点,它接收一个有效的,没有过期的JWT,并返回与新的到期字段相同的签名的JWT。然后Web应用程序会将令牌存储在某处。

3+