侧边栏壁纸
  • 累计撰写 11 篇文章
  • 累计创建 15 个标签
  • 累计收到 1 条评论

目 录CONTENT

文章目录

SpringSecurity实现

Pone
2022-10-26 / 0 评论 / 1 点赞 / 38 阅读 / 4,969 字 / 正在检测是否收录...
温馨提示:
本文最后更新于 2022-10-26,若内容或图片失效,请留言反馈。部分素材来自网络,若不小心影响到您的利益,请联系我们删除。

技术简介

‌‌‌  Spring Security 是 Spring 家族中的一个安全管理框架。相比与另外一个安全框架Shiro,它提供了更丰富的功能,社区资源也比Shiro丰富。

‌‌‌  一般来说中大型的项目都是使用SpringSecurity 来做安全框架。小项目有Shiro的比较多,因为相比与SpringSecurity,Shiro的上手更加的简单。

‌‌‌  一般Web应用的需要进行认证授权

‌‌‌  认证:验证当前访问系统的是不是本系统的用户,并且要确认具体是哪个用户

‌‌‌  授权:经过认证后判断当前用户是否有权限进行某个操作

‌‌‌  而认证和授权也是SpringSecurity作为安全框架的核心功能。

主要依赖

‌‌‌  在SpringBoot项目中直接引入Spring Security依赖就可实现绝大多数功能

  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

1. 认证

1.1 登陆校验流程

‌‌‌  

1.2 认证原理

1.2.1 SpringSecurity完整流程

‌‌‌  SpringSecurity的原理就是过滤器链,内部包含了提供各种功能的过滤器。这里我们可以看看入门案例中的过滤器。
‌‌‌  

‌‌‌  图中只展现了核心过滤器,其他的非核心过滤器并没有在图中展示。

  • UsernamePasswordAuthenticationFilter: 负责处理我们在登陆页面填写了用户名密码后的登陆请求。入门案例的认证工作主要有它负责。
  • ExceptionTranslationFilter: 处理过滤器链中跑出的任何AccessDeniedExceptionAuthenticationException
  • FilterSecurityInterceptor: 负责权限校验的过滤器

‌‌‌  可以通过Debug查看当前系统中SpringSecurity过滤器链中有哪些过滤器及他们的顺序
‌‌‌  

1.2.2 认证流程详解

‌‌‌  

‌‌‌  各接口详解:

  • Authentication接口: 它的实现类,表示当前访问系统的用户,分装了用户相关的信息
  • AuthenticationManager接口: 定义了认证Authenticatioin的方法
  • UserDetailsService接口: 加载用户特定数据的核心接口。里面定义了一个根据用户名查询用户信息的方法(包含用户的各个状态)
  • UserDetails接口: 提供核心用户信息。通过UserDetailsService根据用户名获取处理的用户信息要封装成UserDetails对象返回。然后将这些信息封装到Authentication对象中

1.3 实际解决方案

1.3.1 思路分析

‌‌‌登陆:

  1. 自定义登陆接口
    1. 调用ProvideManager的方法进行认证,如果认证通过生成Jwt
    2. 吧用户信息存入redis
  2. 自定义UserDetialsService
    - 在这个实现类中去查询数据库

校验:

  • 自定义Jwt认证过滤器
    • 获取token
    • 解析token获取其中的userid
    • redis中获取用户信息
    • 存入SecurityContextHolder

1.3.2 准备工作

‌‌‌  创建redisJwt的工具类,其中redis的工具类要完成JSON的序列化和反序列化,Jwt的工具类中在创建token环节需使用 setSbject() 方法填入当前用户ID,为方便后期使用。

1.3.3 实现

1.3.3.1 数据库校验用户

‌‌‌  从之前的分析可以知道,可以自定义一个 UserDetailsService ,让 SpringSecurity 使用自己的 UserDetailsService 。自己的 UserDetailsService 可以从数据库中查询用户名和密码。

1.3.3.1.1 准备工作

‌‌‌  添加 MybatisJPA 相关依赖并测试可用性。

1.3.3.1.2 核心代码实现

‌‌‌  创建一个类实现 UserDetailsService 接口,重写其中的方法。添加用户名从数据库中查询用户信息,并将用户信息封装成 UserDetails 返回。 ^UserDetailsService

‌‌‌  自定义一个 UserDetails 实现该接口,把用户信息封装在其中。 ^UserDetails

‌‌‌  在测试时可设置当前用户权限为空,测试接口可以使用所有权限。

1.3.3.2 密码加密存储

‌‌‌  实际项目中不会在数据库存储明文密码。

‌‌‌  默认使用的 PasswordEncoder 要求数据库中的密码格式为:{id}password。它会根据id去判断密码的加密方式,如需明文保存id需写成noop。但一般不会采用这种方式,所以就需要替换 PasswordEncoder

‌‌‌  一般使用 SpringSecurity 为我们提供的 BcryptPasswordEncoder,只需在 Spring 容器中注入此对象,SpringSecurity 就会使用该 PasswordEncoder 来进行密码校验。

‌‌‌  可以定义一个 SpringSecurity 的配置类, SpringSecurity 要求这个配置类要继承 WebSecurityConfigurerAdapter

  @Configuration
  public class SecurityConfig extends WebSecurityConfigurerAdapter {


    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

  }

1.3.3.3 登陆接口

‌‌‌  接下来需要自定义接口,然后让 SpringSecurity 对这个接口放行,让用户访问这个接口的时候不用登陆也能访问。

‌‌‌  在接口中通过 AuthenticationManagerauthenticate() 方法来进行用户认证,所以需要在 SecurityConfig 中配置把 AuthenticationManager 注入容器,在 authenticate() 方法中需要的参数类型为 Authentication 此处可以 new 一个 Authentication 的子类 UsernamePasswordAuthenticationToken 来解决这个问题。

‌‌‌  认证成功后生成一个 Jwt ,放入相应中返回。并且为了让用户下回请求是能通过 Jwt 识别出具体的是哪个用户,需要把用户信息存入 redis , 可以把用户id作为key。

1.3.3.4 认证过滤器

‌‌‌  需要自定义一个过滤器实现 OncePerRequestFilter ,这个过滤器会去获取请求头中的 token ,对 token 进行解析取出其中的userid。

‌‌‌  使用userid去 redis 中获取对应的自定义 UserDetails 对象。
‌‌‌  使用 UsernamePasswordAuthenticationToken 封装 Authentication 对象存入 SecurityContextHolder

‌‌‌  ❗️ 在认证结束后必须执行 filterChain.doFilter(request, response); 放行当前过滤器, 在 token 验证失败和 redis 读取失败后必须抛出错误。

‌‌‌  在自定义的 SecurityConfig 中使用 addFilterBefore() 方法将自定义的过滤器加在 JwtAuthenticationTokenFilter 之前,并在其中关闭 csrf 设定不通过 Session 获取 SecurityContext 。为登陆接口开放允许匿名访问权限,并设定其他接口都需要鉴权认证。

1.3.3.5 退出登陆

‌‌‌  只需要设定一个登陆接口,然后获取 SecurityContextHolder 中的认证信息,删除 redis 中对应的数据即可。

2. 授权

2.0 授权系统的作用

‌‌‌  例如一个学校图书馆的管理系统,如果是普通学生登录就能看到借书还书相关的功能,不可能让他看到并且去使用添加书籍信息,删除书籍信息等功能。但是如果是一个图书馆管理员的账号登录了,应该就能看到并使用添加书籍信息,删除书籍信息等功能。

‌‌‌  总结起来就是不同的用户可以使用不同的功能。这就是权限系统要去实现的效果。
‌‌‌  我们不能只依赖前端去判断用户的权限来选择显示哪些菜单哪些按钮。因为如果只是这样,如果有人知道了对应功能的接口地址就可以不通过前端,直接去发送请求来实现相关功能操作。

‌‌‌  所以我们还需要在后台进行用户权限的判断,判断当前用户是否有相应的权限,必须具有所需权限才能进行相应的操作。

2.1 授权基本流程

‌‌‌  在 SpringSecurity 中,会使用默认的 FilterSecurityInterceptor 来进行权限校验。在 FilterSecurityInterceptor 中会从 SecurityContextHolder 获取其中的 Authentication ,然后获取其中的权限信息。当前用户是否拥有访问当前资源所需的权限。所以在项目中只需要把当前登陆用户的权限信息也存入 Authentication ,然后设置当前资源所需要的权限即可。

2.2 授权实现

2.2.1 限制访问资源所需权限

‌‌‌  SpringSecurity 为我们提供了基于注解的权限控制方案,这也是项目中主要采用的方式,可以使用注解去指定访问对应的资源所需的权限,使用前需开启相关配置。

  @EnableGlobalMethodSecurity(prePostEnabled = true)

‌‌‌  之后可以使用对应的注释:@PreAuthorize ,在注释中使用相应的 SPEL 语句即可声明当前接口的权限。

  @RestController
  public class HelloController {

    @RequestMapping("/hello")
    @PreAuthorize("hasAuthority('test')")
    public String hello(){
        return "hello";
    }
  }

2.2.2 封装权限信息

‌‌‌  在前面的[[03 Spring Security#^UserDetailsService|自定义UserDetailsService]]有说过,在查询出用户后还要获取对应的权限信息,封装到 UserDetails 中返回。

‌‌‌  修改之前[[03 Spring Security#^UserDetails|自定义的UserDetails]],添加数组使其能够封装权限信息,并将其在类中自动转化为 SimpleGrantedAuthority 类型以便让 SpringSecurity 自动调用 getAuthorities() 方法读取。

‌‌‌  修改完后就可以修改自定义 UserDetailsService 了,在其把权限信息也封装进 UserDetails 中,为方便测试此时可以把权限信息写死,正式项目中采用查询数据库的方式获取。

2.2.3 从数据库查询权限信息

2.2.3.1 RBAC权限模型

‌‌‌  RBAC权限模型(Role-Based Access Control)即:基于角色的权限控制。这是目前最常被开发者使用也是相对易用、通用权限模型。
‌‌‌  

2.2.3.2 代码实现

‌‌‌  只需要根据用户id去查询其所对应的权限信息即可。

‌‌‌  定义好具体的数据库查询方法后,在[[03 Spring Security#^UserDetailsService|自定义UserDetailsService]]中调用该方法查询到具体权限信息后封装进[[03 Spring Security#^UserDetails|自定义UserDetails]]即可。

4. 自定义失败处理

‌‌‌  一般前后端项目中在认证失败或是授权失败的情况下返回和普通接口相同结构的 json ,这样可以让前端能对响应进行统一的处理。要实现这个功能需要知道 SpringSecurity 的异常处理机制。

‌‌‌  在 SpringSecurity 中,如果在认证或者授权的过程中出现了异常会被 ExceptionTranslationFilter 捕捉到。在 ExceptionTranslationFilter 中会去判断时认证失败还是授权失败出现的异常。

‌‌‌  如果是认证过程中出现的异常会被封装成 AuthenticationException 然后调用 AuthenticationEntryPoint 对象的方法去进行异常处理。

‌‌‌  如果是授权过程中出现的异常会被封装成 AccessDeniedException 然后调用 AccessDeniedHandler 对象的方法去进行异常处理。

‌‌‌  所以如果需要自定义异常处理,只需要自定义 AuthenticationEntryPointAccessDeniedHandler 然后配置给 SpringSecurity 即可。

  1. 自定义实现类,必须指定返回的状态码为200,否则前端可能无法处理,当然如果项目是根据返回头的状态来判断的则另说
  2. 配置给 SpringSecurity
    1. 在配置类中注入对应容器
    2. HttpSecurity 的配置方法中调用 exceptionHandling()方法配置

‌‌‌  如果有需要自定义认证成功/失败处理器、登出成功/失败处理器或是其他处理可以自行实现相应 Handler ,最后统一在 SpringSecurityHttpSecurity 配置类中配置即可。

5. 跨域

‌‌‌  浏览器出于安全的考虑,使用 XMLHttpRequest 对象发起 HTTP 请求是必须遵守同源策略,否则就是跨域的 HTTP 请求,默认情况下是被禁止的。同源策略要求源相同才能正常进行通信,即协议域名端口号都完全一致。

‌‌‌  前后端分离项目,前端项目和后端项目一般都不是同源的,所以肯定会存在跨域请求的问题,一般均由后端处理。所以就要处理一下,让前端能够进行跨域请求。

5.1 对 SpringBoot 配置,进行跨域请求

‌‌‌  @Configuration
‌‌‌  public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
      // 设置允许跨域的路径
        registry.addMapping("/**")
                // 设置允许跨域请求的域名
                .allowedOriginPatterns("*")
                // 是否允许cookie
                .allowCredentials(true)
                // 设置允许的请求方式
                .allowedMethods("GET", "POST", "DELETE", "PUT")
                // 设置允许的header属性
                .allowedHeaders("*")
                // 跨域允许时间
                .maxAge(3600);
    }
‌‌‌  }

5.2 开启 SpringSecurity 的跨域访问

‌‌‌  有些时候做完上一步可能就已经能够正常跨域访问了,但为了确保在生产环境中能够稳定运行最好加上此配置

‌‌‌  由于所有的资源都会受到 SpringSecurity 的保护,所以想要跨域访问还要让 SpringSecurity 运行跨域访问。

‌‌‌  与之前相同在 SpringSecurtity 的配置类中添加 cors() 方法即可。

6. 遗留小问题

6.1 配置类的链式调用

‌‌‌  在 SpringSecurityHttpSecurity 配置类中可以使用传统的逐行调用的方法也可以使用更为简洁的链式调用方法。使用方法很简单,在每一项配置结束后调用 and() 方法即可,此方法返回的数据类型就是 HttpSecurity ,之后的配置就可以继续用链式调用的方式调用了。

6.2 自定义权限校验方法

‌‌‌  可以定义自己的权限校验方法,以达成更为复杂的权限结构判断,并在 @PreAuthorize 注解中使用自己的方法。

6.2.1 定义自己的鉴权方法

‌‌‌  新建一个类并使用 @Component 注解将其注入至 Spring 容器中,创建一个方法且返回值为 boolean ,具体写法可参照 SecurityExpressionRoot 中的 hasAuthority() 方法。

‌‌‌  个人推荐通过 SecurityContextHolder 来获取当前用户的权限状态,可以参照以下示例。( LoginUser 为自定义的 UserDetails 实现类,Permissions 为内部自定义的 String 类型数组)

‌‌‌  @Component()
‌‌‌  public class MyExpressionRoot {

    public boolean hasAuthority(String authority){
        //获取当前用户的权限
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        LoginUser loginUser = (LoginUser) authentication.getPrincipal();
        List<String> permissions = loginUser.getPermissions();
        //判断用户权限集合中是否存在authority
        return permissions.contains(authority);
    }
‌‌‌  }

6.2.2 调用自己的鉴权方法

‌‌‌  在 SPEL表达式 中使用 @<beanName> 相当于获取容器中bean的名字为 <beanName> 的对象,然后再调用这个对象的 hasAuthority() 方法。

‌‌‌  @RequestMapping("/hello")
‌‌‌  @PreAuthorize("@myExpressionRoot.hasAuthority('system:dept:list')")
‌‌‌  public String hello(){
    return "hello";
‌‌‌  }

6.3 CSRF

‌‌‌  CSRF是指跨站请求伪造(Cross-site request forgery),是web常见的攻击之一。

‌‌‌  SpringSecurity去防止CSRF攻击的方式就是通过csrf_token。后端会生成一个csrf_token,前端发起请求的时候需要携带这个csrf_token,后端会有过滤器进行校验,如果没有携带或者是伪造的就不允许访问。

‌‌‌  可以发现CSRF攻击依靠的是cookie中所携带的认证信息。但是在前后端分离的项目中我们的认证信息其实是token,而token并不是存储中cookie中,并且需要前端代码去把token设置到请求头中才可以,所以CSRF攻击也就不用担心了。

6.4 Redis 反序列化失败

‌‌‌  在使用 SpringSecurity 时需实现 UserDetails 接口,但在 RedisTemplate 中使用了 Jackson2JsonRedisSerializer 作为了序列化方式则有可能导致反序列化时报错 Could not read JSON: Unrecognized field “enabled",原因是反序列化是根据 set 方法来实现的,而在 UserDetails 中只有 get 方法。
‌‌‌  解决方法:在 RedisTemplate 的配置中添加 objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); 即可,实际案例如下:

@Bean  
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory){  
   RedisTemplate<String, Object> template = new RedisTemplate<>();  
   template.setConnectionFactory(redisConnectionFactory);  
  
   // Json序列化配置  
   final Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);  
   final ObjectMapper objectMapper = new ObjectMapper();  
   objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);  
   objectMapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance,  
         ObjectMapper.DefaultTyping.NON_FINAL,  
         JsonTypeInfo.As.WRAPPER_ARRAY);    objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);  
   jackson2JsonRedisSerializer.setObjectMapper(objectMapper);  
   // String的序列化  
   final StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();  
  
   // 具体的序列化方式  
   // key用string  
   template.setKeySerializer(stringRedisSerializer);  
   template.setHashKeySerializer(stringRedisSerializer);  
  
   // value用json  
   template.setValueSerializer(jackson2JsonRedisSerializer);  
   template.setHashValueSerializer(jackson2JsonRedisSerializer);  
  
   template.afterPropertiesSet();  
   return template;  
}

6.5 单元测试

‌‌‌  在定义service接口时如果采用了接口方法级的安全限制则会导致无法进行单元测试,为解决这一问题需单独引入 SpringSeurity 的测试依赖:

<dependency>  
   <groupId>org.springframework.boot</groupId>  
   <artifactId>spring-boot-starter-test</artifactId>  
   <scope>test</scope>  
</dependency>

‌‌‌  由于使用了 RBAC 框架进行权限管理,部分开发者会在service接口上进行权限限制,在这时如果想要测试service接口可能就会无法通过权限限制。此时可以采用 @WithMockUser@WithUserDetails 注解进行测试。

6.5.1 @WithMockUser

‌‌‌  如果在service接口中使用了 SpringSecurity 的原生方法进行了限制,如:hasAuthority() 就可使用此注解进行测试:

@Test
@WithMockUser(authorities = "test")
void test() {}

参数说明(此注解所模拟的用户可以为数据库中不存在的用户):

  • valueusername :用户名
  • roles (数据类型为 String[] ) :权限名(默认会加入 ROLE_ ,与 hasRole()hasAnyRole() 对应)
  • authorities (数据类型为 String[] ):许可名(与 hasAuthority()hasAnyAithority 对应)

6.5.2 @WithUserDetails

‌‌‌  有些时候需要使用自定义的一些鉴权方法,此时就需要使用 @WithUserDetails 配合自定义的 [[03 Spring Security#^UserDetailsService|UserDetailsService]] 进行测试:

@Test  
@WithUserDetails(value = "admin", userDetailsServiceBeanName = "userDetailsServiceImpl")  
void test() {}

参数说明(此注解所模拟的用户必须在数据库中存在):

  • value :将从数据库查询的用户名
  • userDetailsServiceBeanName :前文中已实现的 [[03 Spring Security#^UserDetailsService|UserDetailsService]]

7. 资料来源

1

评论区