简介

Spring Security 是 Spring 家族中的一个安全管理框架。相比较于另外一个安全框架 Shiro,它提供了更丰富的功能,社区资源也比 Shiro 丰富。一般来说中大型的项目都是使用 Spring Security 来做安全框架。小项目用 Shiro 比较多,因为相比与 Spring Security,Shiro 的上手更加简单,一般 Web 应用需要进行认证授权

  • 认证: 验证当前访问系统的是不是本系统用户,并且要确认具体是哪个用户
  • 授权: 经过认证后判断当前用户有权限进行某个操作

而认证和授权也是 Spring Security 作为安全框架的核心功能

认证

Spring Security 的原理其实就是一个过滤器链,内部包含了提供了各种功能的过滤器

  • UsernamePasswordAuthenticationFilter:负责处理我们在登陆页天蝎了用户名密码的登陆请求
  • ExceptionTranslationFilter:处理过滤器链中抛出的任何 AccessDeniedException 和 AuthenticationException
  • FilterSecurityInterceptor:负责权限校验的过滤器

流程

  1. 提交用户名密码
  2. 封装 Authentication 对象,这时候最多只有用户名和密码,权限还没有
  3. 调用 authenticate 方法进行认证
  4. 调用 DaoAuthenticationProvider 的 authentication 方法进行认证
  5. 调用 loadUserByUsername 方法查询用户
    • 根据用户名区查询对应的用户及这个用户对应的权限信息,InMemoryUserDetailsManager 是在内存中查找
    • 把对应的用户信息包括权限信息封装成 UserDetails 对象
  6. 返回 UserDetails 对象
  7. 通过 PasswordEncoder 对比 UserDetails 中的密码和 Authentication 的密码是否正确
  8. 如果正确就把 UserDetails 中的权限信息设置到 Authentication 对象中
  9. 返回 Authentication 对象
  10. 如果上一步返回了 Authentication 对象就使用 SecurityContextHolder.getContext().setAuthentication 方法存储该对象。其他过滤器中会通过 SecurityContextHolder 来获取当前用户信息

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

密码加密存储

实际项目中我们不会把密码明文存储在数据库中,默认使用的 PasswordEncoder 要求数据库中的密码格式为: {id}password,它会根据 id 去判断密码的加密方式,但是我们一般不会采取这种方式,所以就需要替换 PasswordEncoder。我们一般使用 Spring Security 为我们提供的 BCryptPasswordEncoder。我们只需要使用把 BCryptPasswordEncoder 对象注入到 Spring 容器中,Spring Security 就会使用该 PasswordEncoder 来进行密码校验。我们可以定一个 Spring Security 的配置类,Spring Security 要求这个配置类继承 WebSecurityConfigurerAdapter

1
2
3
4
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}

登录接口

接下来我们需要自己定义登录接口,然后让 Spring Security 对这个接口放行,让用户访问这个接口的时候不用登录也能访问。在接口中我们通过 AuthenticationManager 的 authenticate 方法来进行用户认证,所以需要在 SecurityConfig 中配置把 AuthenticationManager 注入容器。认证成功的话生成一个 jwt,放入响应中返回,并且为了让用户下回请求时能通过 jwt 识别出具体的是哪个用户,我们需要把用户信息存入 redis,可以把用户 id 作为 key

1
2
3
4
5
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
1
2
3
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(user.getUserName(), user.getPassword());
Authentication authenticate = authenticationManager.authenticate(authToken);
1
2
3
4
5
6
7
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and().authorizeRequests()
.antMatchers("/user/login").anonymous()
.anyRequest().authenticated();
}

授权

权限系统的作用

例如我们一个学校图书馆的管理系统,如果是普通学生登录就能看到借书还书相关功能,不可能让他看到并且去使用添加书籍信息,删除信书籍息等功能,但是如果是一个图书管理员的账号登录了,应该就能看到并使用这些权限,总结起来就是不同用户可以使用不同的功能,这就是权限系统要去实现的效果。我们不能只依赖前端去判断用户的权限来实现显示哪些菜单哪些按钮,因为如果只是这样,有人知道对应的接口地址就可以不通过前端,直接去发送请求来实现某些操作。所以我们还需要在后台进行用户权限判断,判断当前用户是否有响应权限,必须基于相应的权限才能进行相应的操作

授权基本流程

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

授权实现

限制访问资源所需权限

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

1
@EnableGlobalMethodSecurity(prePostEnabled = true)
1
2
3
4
5
@PreAuthorize("hasAuthority('test')")
@RequestMapping("/hello")
public String hello() {
return "hello";
}

封装权限信息

我们前面在写 UserDetailsServiceImpl 的时候说过,在查询用户后还要获取对应的权限信息,封装到 UserDetails 中返回

1
2
3
private List<String> permissions;
@JSONField(serialize = false)
private List<SimpleGrantedAuthority> authorities;
1
2
3
4
5
6
7
8
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
if (authorities != null) {
return authorities;
}
// 把permissions中String类型的权限信息封装成SimpleGrantedAuthority对象
return permissions.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList());
}

自定义失败处理

我们还希望在认证失败或者是授权失败的情况下也能和我们的接口一样返回相同结构的 JSON,这样就可以让前端能对响应进行统一处理。要实现这个功能我们要知道 Spring Security 的异常处理机制。在 Spring Security 中,如果我们在认证或者授权的过程中出现了异常会被 ExceptionTranslationFilter 捕获,在 ExceptionTranslationFilter 中去判断是认证失败还是授权失败出现的异常。如果是认证过程中出现的异常会被封装成 AuthenticationException 然后调用 AuthenticationEntryPoint 对象的方法去进行异常处理。如果是授权过程中出现的异常会被封装成 AccessDeniedException 然后调用 AccessDeniedHandler 对象的方法去进行异常处理。所以如果需要自定义异常处理,只需要自定义AuthenticationEntryPoint和AccessDeniedHandler然后配置给 Spring Security 即可

  • 自定义实现类
1
2
3
4
5
6
7
8
@Component
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) {
WebUtils.renderString(response,
JSON.toJSONString(new ResponseResult(HttpStatus.UNAUTHORIZED.value(), "用户认证失败请查询登录")));
}
}
1
2
3
4
5
6
7
8
@Component
public class AccessDeniedHandlerImpl implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e) {
WebUtils.renderString(response,
JSON.toJSONString(new ResponseResult(HttpStatus.FORBIDDEN.value(), "您的权限不足")));
}
}
  • 配置给 Spring Security
1
2
3
4
5
http.exceptionHandling()
.authenticationEntryPoint(authenticationEntryPoint)
.and()
.exceptionHandling()
.accessDeniedHandler(accessDeniedHandler);

跨域

浏览器出于安全考虑,使用 XMLHttpRequest 对象发起 HTTP 请求时必须遵守同源策略,否则就是跨域的 HTTP 请求,默认情况下是被禁止的。同源策略策略要求源相同才能正常通信,即协议,域名,端口号都完全一致。前后端分离的项目,前端项目和后端项目一般都不是同源的,所以肯定存在跨域问题,所以我们需要处理一下,让前端能进行跨域请求

  • 先对 SpringBoot 配置,允许跨域请求
1
2
3
4
5
6
7
8
9
10
11
12
13
corsRegistry
// 设置跨域允许的路径
.addMapping("/**")
// 设置跨域允许的请求的域名
.allowedOriginPatterns("*")
// 设置是否允许携带Cookie
.allowCredentials(true)
// 设置允许的请求方式
.allowedMethods("GET", "POST", "DELETE", "PUT")
// 设置允许的Header属性
.allowedHeaders("*")
// 跨域允许的时间
.maxAge(3600);
  • 对 Spring Security 进行配置
1
http.cors();

其他权限校验方法

我们前面都是使用 hasPreAuthorize 注解,然后在其中使用的是 hasAuthority 方法进行校验。Spring Security 还为我们提供了其他方法,例如 hasAnyAuthority、hasRole、hasAnyRole、hasAuthority 方法实际是执行到 了SecurityExpressionRoot 的 hasAuthority,大家只要断点即可知道它内部的原理,它内部其实是调用 authentication 的 getAuthorities 方法获取用户的权限列表,然后判断我们存入的方法参数数据在权限列表中。hasAnyAuthority 方法可以传入多个权限,只要用户有其中任意一个权限都可以访问对应资源。hasRole 要求有对应角色才可以访问,但是其内部会把我们传入的参数拼上 ROLE_ 后再去比较,所以这种情况下用户对应的权限也要有 ROLE_ 这个前缀才可以。hasAnyRole 有任意角色就可以访问,其余和 hasRole 一样

自定义权限校验方法

我们也可以自定义权限校验方法,在 @PreAuthorize 注解中使用我们方法

1
2
3
4
5
6
7
8
9
@Component("wxz")
public class WxzExpressionRoot {
public final boolean hasAuthority(String authority) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
LoginUser loginUser = (LoginUser)authentication.getPrincipal();
List<String> permissions = loginUser.getPermissions();
return permissions.contains(authority);
}
}

CSRF

CSRF 是指跨站请求伪造(Cross-site request forget),是web最常见的攻击之一,Spring Security 去防止 CSRF 攻击的方式就是通过 csrf_token,前端发起请求的时候需要携带这个 csrf_token,后端会有过滤器进行校验,如果没有携带或者伪造的就不允许访问。我们可以发现 CSRF 攻击依靠的是 cookie 所携带的认证信息,但是在前后端分离的项目中我们的认证信息其实是 token,而 token 并不存储在 cookie 中,并且需要前端代码去把 token 设置到请求头中才可以,所以 CSRF 攻击也就不用担心了

登录成功处理器

实际上在 UsernamePasswordAuthenticationFilter 进行登录认证的时候,如果成功了是会调用 AuthenticationSuccessHandler 的方法进行认证成功后的处理,AuthenticationSuccessHandler 就是登录成功处理器