1.简介
Spring Security 是 Spring家族中的一个安全管理框架。相比与另外一个安全框架Shiro,它提供了更丰富的功能,社区资源也比Shiro丰富。一般来说中大型的项目都是使用SpringSecurity来做安全框架。小项目有Shiro的比较多,因为相比与SpringSecurity,Shiro的上手更加的简单。一般Web应用的需要进行认证和授权。
认证:验证当前访问系统的是不是本系统的用户,并且要确认具体是哪个用户。
授权:经过认证后判断当前用户是否有权限进行某个操作。
而认证和授权也是SpringSecurity作为安全框架的核心功能。
2.原理初探
SpringSecurity的原理其实就是一个过滤器链(实质上一共有15个过滤器),内部包含了提供各种功能的过滤器。这里我们可以看看入门案例中的过滤器。
图中只展示了核心过滤器,其它的非核心过滤器并没有在图中展示,选择其中比较核心的三个过滤器进行介绍。
UsernamePasswordAuthenticationFilter:用于拦截登录请求,当进行表单登录时,该Filter将用户名和密码封装成一个 UsernamePasswordAuthenticationToken,并将这个token交给AuthenticationManager
进行认证,通过authenticationManager.authenticate(token)进行认证,返回一个Authentication对象。
ExceptionTranslationFilter:处理过滤器链中抛出的任何AccessDeniedException(授权异常)和AuthenticationException(认证异常),对于认证异常一般交给AuthenticationEntryPoint进行处理,一般是重定向到登录页面,对于授权异常则交给AccessDeniedHandler处理,一般是重定向到一个错误页面。
FilterSecurityInterceptor:过滤器链最后的关卡,用于权限比对,从 SecurityContextHolder中获取 Authentication,比对用户拥有的权限和所访问资源需要的权限。
我们可以通过Debug查看当前系统中SpringSecurity过滤器链中有哪些过滤器及它们的顺序。
3.逻辑及代码实现
3.1导入依赖
在SpringBoot项目中使用SpringSecurity我们只需要引入依赖即可实现入门案例。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
引入依赖后我们在尝试去访问之前的接口就会自动跳转到一个SpringSecurity的默认登陆页面,默认用户名是user,密码会输出在控制台。必须登陆之后才能对接口进行访问。
3.2登陆校验流程
(1) 认证:springSecurity首先处理的是用户登录过程,在用户登录的时候携带账号和密码,在登录接口对用户进行认证,通过账号和密码封装为一个
UsernamePasswordAuthenticationToken,
再调用AuthenticationManager
的authticate
方法将上面的token
作为参数传入,
这一步认证期间会调用到UserDetailService类的loadUserByUsername方法,需要在这个方法中查询用户密码是否存在及匹配,存在且匹配的话则从数据库中获取到该用户的权限数组,将这个权限数组封装到UserDetail中返回。AuthenticationManager
认证成功将,后则返回一个jwtToken给前端,登录后用户访问任何一个接口都需要携带这个jwtToken,以便于后端根据token获取到当前用户的权限。
(2)授权:授权过程主要在登录的认证阶段根据数据库中用户具有的权限在loadUserByUsername方法中完成了,后续我们需要自定义一个拦截器,拦截用户登录后的访问请求,拦截器中主要是获取请求携带的token,通过token获取当前用户具有的权限,再将用户具有的权限通过SecurityContextHolder.getContext().setAuthentication()设置注入。
3.3代码实现
3.3.1springSecurity配置类
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
//关闭csrf
.csrf().disable()
//不通过Session获取SecurityContext
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
// 对于登录接口 允许匿名访问
.antMatchers("/user/login").anonymous()
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated();
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
主要配置了密码加密方式,以及一些安全配置具体如图中注解。
3.3.2自定义登录接口
@RestController
public class LoginController {
@Autowired
private LoginServcie loginServcie;
@PostMapping("/user/login")
public ResponseResult login(@RequestBody User user){
return loginServcie.login(user);
}
}
service进行逻辑处理
@Service
public class LoginServiceImpl implements LoginServcie {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private RedisCache redisCache;
@Override
public ResponseResult login(User user) {
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user.getUserName(),user.getPassword());
Authentication authenticate = authenticationManager.authenticate(authenticationToken);
if(Objects.isNull(authenticate)){
throw new RuntimeException("用户名或密码错误");
}
//使用userid生成token
LoginUser loginUser = (LoginUser) authenticate.getPrincipal();
String userId = loginUser.getUser().getId().toString();
String jwt = JwtUtil.createJWT(userId);
//authenticate存入redis
redisCache.setCacheObject("login:"+userId,loginUser);
//把token响应给前端
HashMap<String,String> map = new HashMap<>();
map.put("token",jwt);
return new ResponseResult(200,"登陆成功",map);
}
}
3.3.3重写UserDetailService方法
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//根据用户名查询用户信息
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(User::getUserName,username);
User user = userMapper.selectOne(wrapper);
//如果查询不到数据就通过抛出异常来给出提示
if(Objects.isNull(user)){
throw new RuntimeException("用户名或密码错误");
}
//TODO 根据用户查询权限信息 添加到LoginUser中
List<String> permissionKeyList = menuMapper.selectPermsByUserId(user.getId());
//封装成UserDetails对象返回
return new LoginUser(user,permissionKeyList);
}
}
因为UserDetailsService方法的返回值是UserDetails类型,所以需要定义一个类,实现该接口,把用户信息封装在其中。
@Data
@NoArgsConstructor
@AllArgsConstructor
public class LoginUser implements UserDetails {
private User user;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return null;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUserName();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
3.3.4自定义请求过滤器
我们需要自定义一个过滤器,这个过滤器会去获取请求头中的token,对token进行解析取出其中的userid。使用userid去redis中获取对应的LoginUser对象。然后封装Authentication对象存入SecurityContextHolder。
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
@Autowired
private RedisCache redisCache;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
//获取token
String token = request.getHeader("token");
if (!StringUtils.hasText(token)) {
//放行
filterChain.doFilter(request, response);
return;
}
//解析token
String userid;
try {
Claims claims = JwtUtil.parseJWT(token);
userid = claims.getSubject();
} catch (Exception e) {
e.printStackTrace();
throw new RuntimeException("token非法");
}
//从redis中获取用户信息
String redisKey = "login:" + userid;
LoginUser loginUser = redisCache.getCacheObject(redisKey);
if(Objects.isNull(loginUser)){
throw new RuntimeException("用户未登录");
}
//存入SecurityContextHolder
//TODO 获取权限信息封装到Authentication中
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(loginUser,null,null);
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
//放行
filterChain.doFilter(request, response);
}
}
3.3.5自定义退出登录接口
我们只需要定义一个登陆接口,然后获取SecurityContextHolder中的认证信息,删除redis中对应的数据即可。
@Service
public class LoginServiceImpl implements LoginServcie {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private RedisCache redisCache;
@Override
public ResponseResult logout() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
LoginUser loginUser = (LoginUser) authentication.getPrincipal();
Long userid = loginUser.getUser().getId();
redisCache.deleteObject("login:"+userid);
return new ResponseResult(200,"退出成功");
}
}
3.3.6开启权限匹配
SpringSecurity为我们提供了基于注解的权限控制方案,这也是我们项目中主要采用的方式。我们可以使用注解去指定访问对应的资源所需的权限,但是要使用它我们需要先开启相关配置同时在springSecurity配置类上加下面的注解。
@EnableGlobalMethodSecurity(prePostEnabled = true)
然后就可以使用注解了,如下图在hello方法上加一个要求权限的字符
@RestController
public class HelloController {
@RequestMapping("/hello")
@PreAuthorize("hasAuthority('test')")
public String hello(){
return "hello";
}
}
这就代表,当前登录用户必须拥有test权限才可以访问hello接口,而用户的具体权限已经在loadUserByUsername方法中设置了。
结尾
非常感谢大家观看我的博客,respect!