文章目录

  • 一、技术概述
  • 二、技术详述
  • 1. 基本鉴权逻辑
  • 2. 鉴权的全流程
  • 3. JWT获取用户角色信息
  • 三、遇到的问题和解决过程
  • 四、总结
  • 五、参考文献

一、技术概述

  绝大多数的软件应用都离不开权限安全,但是权限安全相关的代码在每个项目中又十分类似,因此我就编写了一个基于SpringSecurity的支持动态权限验证的权限安全模块的脚手架,并应用在我们的项目之中。

二、技术详述

1. 基本鉴权逻辑

  首先要说明,该权限模块支持用户、角色、资源三级的动态权限管理,并且基于JWT可支持OAuth2.0的认证模型。因此我们第一步需要建出这三张主表以及关联表。

Spring Shiro 权限管理 spring security权限管理 jwt_spring

Spring Shiro 权限管理 spring security权限管理 jwt_后端_02


Spring Shiro 权限管理 spring security权限管理 jwt_java_03

  鉴权原理是查找用户所含有的所有角色中是否拥有对应资源的访问权限,最终都要靠resource表中的url进行鉴权,这里url是通配符匹配url,因为真正在判断url时我们使用Spring提供的AntPathRequestMatcher通配符匹配器对当前访问的资源路径进行判断和鉴权。

Spring Shiro 权限管理 spring security权限管理 jwt_java_04

@Bean("dynamicSecurityService")
    public DynamicSecurityService dynamicSecurityService(UmsResourceService umsResourceService){
        return ()->{
            Map<RequestMatcher, List<ConfigAttribute>> map = new ConcurrentHashMap<>();
			//获取所有的资源和角色的对应关系
            List<ResourceRoleBO> list = umsResourceService.getAllResourceRole();
            for (ResourceRoleBO resource : list) {
                //通配符匹配器
                map.put(new AntPathRequestMatcher(resource.getUrl()),
                        //所有角色信息
                        resource.getRoleList().stream()
                            .map(role->new org.springframework.security.access.SecurityConfig(role.getName()))
                            .collect(Collectors.toList())
                        );
            }
            return map;
        };
    }

获取资源和角色sql语句

<select id="getAllResourceRole" resultMap="AllResourceRole">
        SELECT
            r.`id`,
            r.`name`,
            r.`url`,
            rl.`name` rl_name
        FROM ums_resource r
                 LEFT JOIN ums_role_resource_relation rrr ON rrr.`resource_id`=r.`id`
                 LEFT JOIN ums_role rl ON rl.`id`=rrr.`role_id`
        WHERE rl.`name` IS NOT NULL
        ORDER BY r.`id`
</select>
@Data
public class ResourceRoleBO extends UmsResource {

    private List<UmsRole> roleList;
}

@Data
@EqualsAndHashCode(callSuper = false)
@TableName("ums_resource")
@ApiModel(value="UmsResource对象", description="")
public class UmsResource implements Serializable {

    private static final long serialVersionUID=1L;

    @TableId(value = "id", type = IdType.AUTO)
    private Long id;

    @ApiModelProperty(value = "资源名")
    private String name;

    @ApiModelProperty(value = "资源描述")
    private String description;

    @ApiModelProperty(value = "资源对应url")
    private String url;

    @ApiModelProperty(value = "资源创建时间")
    private Date createTime;

    @ApiModelProperty(value = "资源更新时间")
    private Date updateTime;


}

这里的DynamicSecurity是自己写的接口

public interface DynamicSecurityService {

    /**
     * 加载资源ANT通配符和资源对应MAP
     * key:匹配器 NT通配符的匹配器(在DynamicSecurityMetadataSource中起作用)
     * value 资源所对应的角色
     * @return
     */
    Map<RequestMatcher, List<ConfigAttribute>> loadDataSource();

}

 上面代码的逻辑是umsResourceService获取所有的角色和资源组成一个对应关系类,然后将每个资源的通配符匹配url作为key,value值为该资源可以被访问的所有角色放入一个线程安全的Map中进行暂存,之后所有的鉴权操作就只要看用户访问资源通配符匹配到的url时,有没有对应的角色,如果有就放行,没有则提示没有权限访问资源。
 如何体现动态权限访问?当前端提供了角色和资源的增删改查界面时,当调用增删改接口时,后端只需把map中的缓存删除,然后再从数据库加载一次就是最新的角色资源关系,不需要重新部署应用。

2. 鉴权的全流程

 首先要写SpringSecurity的配置类,这里继承自WebSecurityConfigurerAdapter,只要重写configure就可以了。

public abstract class SecurityConfig extends WebSecurityConfigurerAdapter {


    /**
     * 由于Portal前台服务没有动态权限功能,所以要配置,当改属性有被成功注入就注册资源权限
     */
    @Autowired(required = false)
    private SecurityResourceRoleSource securityResourceRoleSource;

    @Autowired(required = false)
    private DynamicSecurityMetadataSource dynamicSecurityService;

    /**
     * 权限配置  白名单...jwt认证
     *
     * @param http
     * @throws Exception
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //白名单进行放行
        final ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry registry = http.authorizeRequests();

        //循环白名单进行放行
        for (String url : ignoredUrlsConfig().getUrls()) {
            registry.antMatchers(url).permitAll();
        }

        //允许可以请求OPTIONS CORS  当后端设置了CORS,浏览器在发送每个请求前会自动发送一个OPTIONS请求,所以这里要放行所有的OPTIONS请求
        registry.antMatchers(HttpMethod.OPTIONS).permitAll();

        if (securityResourceRoleSource != null) {
            final Map<String, List<String>> resourceRole = securityResourceRoleSource.getResourceRole();

            //循环注册
            for (Map.Entry<String, List<String>> resourceRoleEntry : resourceRole.entrySet()) {
                //将Objetc[]转化为String[]
                final List<String> roles = resourceRoleEntry.getValue();
                final String[] rolesStr = roles.toArray(new String[roles.size()]);
                registry.antMatchers(resourceRoleEntry.getKey()).hasAnyAuthority(rolesStr);
            }

        }

        // 其他任何请求都需要身份认证
        registry
                //其他的任何请求都需要权限验证
                .anyRequest().authenticated()
                .and()
                //支持跨域
                .cors()
                .and()
                //关闭csrf跨站请求伪造:因为现在使用jwt来实现认证,不需要csrf防护
                .csrf().disable()
                //禁止session,永远不创建和使用session
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                //自定义权限拒绝处理类
                .exceptionHandling()
                // 没有权限访问时的处理类
                .accessDeniedHandler(restfulaccessDeniedHandler())
                // 没有登录时的处理类
                .authenticationEntryPoint(restfulAuthenticationEntryPoint())
                // 加入jwt认证过滤器
                .and()
                .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);

        //有动态权限配置时添加动态权限校验过滤器
        if (dynamicSecurityService != null) {
            registry.and().addFilterBefore(dynamicSecurityFilter(), FilterSecurityInterceptor.class);
        }
    }

}

///省略一些Bean的声明

 该配置类中的dynamicSecurityService就是之前提到的动态权限管理的缓存变量。

public class DynamicSecurityMetadataSource implements FilterInvocationSecurityMetadataSource {

    //所有的资源角色信息的Map
    private static Map<RequestMatcher, List<ConfigAttribute>> configAttributeMap = null;

    @Autowired
    private DynamicSecurityService dynamicSecurityService;

    // @PostConstruct spring在创建bean的时候调用这个注解初始化方法
    // 读取到所有的资源角色信息
    @PostConstruct
    public void loadDataSource() {
        configAttributeMap = dynamicSecurityService.loadDataSource();
    }

    // 清除   在资源分配的时候就清除掉
    public void clearDataSource() {
        configAttributeMap.clear();
        configAttributeMap = null;
    }


    @Override
    public Collection<ConfigAttribute> getAttributes(Object o) throws IllegalArgumentException {
        //在清除之后就会再次获取最新的资源角色信息
        if (configAttributeMap == null) {
            this.loadDataSource();
        }
        List<ConfigAttribute> configAttributes = new ArrayList<>();
        //获取当前访问的路径
        HttpServletRequest request = ((FilterInvocation) o).getRequest();
        //循环所有角色信息,取出原本存放的路径通配符并遍历
        for (RequestMatcher pattern : configAttributeMap.keySet()) {
            //匹配成功
            if (pattern.matches(request)) {
                //拿到角色信息
                configAttributes.addAll(configAttributeMap.get(pattern));
            }
        }
        // 未设置操作请求权限,返回空集合
        return configAttributes;
    }

    @Override
    public Collection<ConfigAttribute> getAllConfigAttributes() {
        return null;
    }

    @Override
    public boolean supports(Class<?> aClass) {
        return true;
    }
}

 当调用了DynamicSecurityMetadataSource的clearDataSource()方法时,就会清空缓存,在调用getAttributes()方法时会检测缓存是否存在,不在就说明要再次从数据库中拉取最新的角色资源关系。
 在SecurityConfig的configure方法中就是鉴权的整个流程。首先查看访问路径是否是白名单,白名单路径可以在配置文件中进行增加或修改,然后要设置放行所有的OPTIONS请求。

3. JWT获取用户角色信息

 在之前的鉴权逻辑中,还没有说明用户的角色信息如何获取,这里我们是通过登录时给前端发送一个独一无二的token,前端在之后每个请求都要携带上这个token用于鉴权,该token中存有用户名,在过滤器中查询用户名对应的用户,如果查询到则就知道了他的角色信息,这里为了加快查询的速度,将token和用户信息存放到redis中,从缓存中获取就更快一点。

public class JwtAuthenticationFilter extends OncePerRequestFilter {


    @Autowired
    private UserDetailsService userDetailsService;
    @Autowired
    private JwtTokenUtil jwtTokenUtil;


    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

        //拿到jwt令牌
        String jwt = request.getHeader(jwtTokenUtil.getTokenHeader());

        //判断是否存在 判断开头是否加了head
        if (!StrUtil.isBlank(jwt) && jwt.startsWith(jwtTokenUtil.getTokenHead())) {
            /**
             * 注意在Filter抛出的异常是不会被SpringMVC的统一异常处理类捕获到的
             * 因为SpringMVC是Servlet层的,而Filter在Servlet层之前,所以这里抛出的异常到不了Servlet,会显示500
             */
            //throw new ApiException(ResultCode.UNAUTHORIZED);

            //对jwt进行解码,如果解码失败用户名返回的是null
            jwt = jwt.substring(jwtTokenUtil.getTokenHead().length());
            final String username = jwtTokenUtil.getUserNameFromToken(jwt);

            if (!StrUtil.isBlank(username)) {

                // 从服务器中查询该用户名
                final UserDetails userDetails = userDetailsService.loadUserByUsername(username);
                if (userDetails != null) {
                    //生成SpringSecurity的通过认证标识
                    /**
                     * UsernamePasswordAuthenticationToken的构造函数第一个值和第二个值原本填用户名和密码
                     * 但这里直接传入UserDetails,这样之后从SecurityContextHolder拿出来Principle的时候就可以直接强转为MemberDetails
                     */
                    UsernamePasswordAuthenticationToken authenticationToken =
                            new UsernamePasswordAuthenticationToken(
                                    userDetails,
                                    null,
                                    userDetails.getAuthorities());
                    /**
                     *
                     * 在Security的工作流程开始前,把jwt从header中提取出来,然后验证其正确性,如果正确则在这个地方
                     * 把Security需要的Token进行封装,然后放到SecurityContextHolder里
                     * 设完authenticationToken这个标识,后面Security在处理时发现有这个标识就会代表认证通过
                     * 而且这个标识会一直存放在SecurityContextHolder里
                     */
                    SecurityContextHolder.getContext().setAuthentication(authenticationToken);
                }
            }
        }
        filterChain.doFilter(request, response);
    }
}

 值得注意的是JWT验证不能写在拦截器Interceptor里,因为filter会比interceptor先拦截到请求
jwt验证是所有请求验证的第一步,所以必须是一个filter,而且要在SpringSecurity内置的所有filter里的第一个。所以在上面的configure中将其放在SpringSecurity内置的第一个filter,也就是UsernamePasswordAuthenticationFilter之前。

registry.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);

三、遇到的问题和解决过程

  • 问题:在把SecurityConfig抽象成两个应用都可以使用的抽象类时遇到了很大的阻力,出现了很多bug,因为security模块是没有启动类的,所以里面的Bean很容易写错,不该加载的反而加载了。
  • 解决方案:使用idea提供的spring容器插件可以清楚的看到Spring中注入的Bean和不同模块下生成的Bean,通过多次尝试和断点debug,才把SecurityConfig成功的抽象到了security模块中,供前台和后台的后端一起使用。

四、总结

 关于权限安全模块,实际上也有很简单的实现方案,但是为了更大程度的复用,不得不对其进行结构优化,虽然增大了代码量,但是他的可复用性非常强,不论是静态权限,或者是无资源表,再或者是结合SpringSecurity的鉴权注解,都可以正常工作,只是在使用前,尽量弄清楚整个模块的鉴权原理,可以更清楚的使用这个脚手架。

五、参考文献