
SpringSecurity实现
技术简介
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: 处理过滤器链中跑出的任何
AccessDeniedException
和AuthenticationException
- FilterSecurityInterceptor: 负责权限校验的过滤器
可以通过Debug
查看当前系统中SpringSecurity过滤器链
中有哪些过滤器及他们的顺序
1.2.2 认证流程详解
各接口详解:
- Authentication接口: 它的实现类,表示当前访问系统的用户,分装了用户相关的信息
- AuthenticationManager接口: 定义了认证
Authenticatioin
的方法 - UserDetailsService接口: 加载用户特定数据的核心接口。里面定义了一个根据用户名查询用户信息的方法(包含用户的各个状态)
- UserDetails接口: 提供核心用户信息。通过
UserDetailsService
根据用户名获取处理的用户信息要封装成UserDetails
对象返回。然后将这些信息封装到Authentication
对象中
1.3 实际解决方案
1.3.1 思路分析
登陆:
- 自定义登陆接口
- 调用
ProvideManager
的方法进行认证,如果认证通过生成Jwt
- 吧用户信息存入
redis
中
- 调用
- 自定义
UserDetialsService
- 在这个实现类中去查询数据库
校验:
- 自定义
Jwt
认证过滤器- 获取
token
- 解析
token
获取其中的userid
- 从
redis
中获取用户信息 - 存入
SecurityContextHolder
- 获取
1.3.2 准备工作
创建redis
和Jwt
的工具类,其中redis
的工具类要完成JSON
的序列化和反序列化,Jwt
的工具类中在创建token
环节需使用 setSbject()
方法填入当前用户ID,为方便后期使用。
1.3.3 实现
1.3.3.1 数据库校验用户
从之前的分析可以知道,可以自定义一个 UserDetailsService
,让 SpringSecurity
使用自己的 UserDetailsService
。自己的 UserDetailsService
可以从数据库中查询用户名和密码。
1.3.3.1.1 准备工作
添加 Mybatis
或 JPA
相关依赖并测试可用性。
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
对这个接口放行,让用户访问这个接口的时候不用登陆也能访问。
在接口中通过 AuthenticationManager
的 authenticate()
方法来进行用户认证,所以需要在 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
对象的方法去进行异常处理。
所以如果需要自定义异常处理,只需要自定义 AuthenticationEntryPoint
和 AccessDeniedHandler
然后配置给 SpringSecurity
即可。
- 自定义实现类,必须指定返回的状态码为200,否则前端可能无法处理,当然如果项目是根据返回头的状态来判断的则另说。
- 配置给
SpringSecurity
- 在配置类中注入对应容器
- 在
HttpSecurity
的配置方法中调用exceptionHandling()
方法配置
如果有需要自定义认证成功/失败处理器、登出成功/失败处理器或是其他处理可以自行实现相应 Handler
,最后统一在 SpringSecurity
的 HttpSecurity
配置类中配置即可。
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 配置类的链式调用
在 SpringSecurity
的 HttpSecurity
配置类中可以使用传统的逐行调用的方法也可以使用更为简洁的链式调用方法。使用方法很简单,在每一项配置结束后调用 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() {}
参数说明(此注解所模拟的用户可以为数据库中不存在的用户):
value
或username
:用户名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]]