1. 简介
Spring Security 是 Spring 家族中的一个安全管理框架。相比与另外一个安全框架Shiro,它提供了更丰富的功能,社区资源也比Shiro丰富。
- 一般来说中大型的项目都是使用SpringSecurity 来做安全框架。小项目有Shiro的比较多,因为相比与SpringSecurity,Shiro的上手更加的简单。
- 一般Web应用的需要进行认证和授权。
认证:
- 验证当前访问系统的是不是本系统的用户,并且要确认具体是哪个用户
授权:
- 经过认证后判断当前用户是否有权限进行某个操作
而**认证和授权**也是SpringSecurity作为安全框架的核心功能。
接下来以用户名密码登录这个案例来讲解!
- 利用 JWT 机制,文章:JWT 重点讲解-CSDN博客
- Redis 缓存,文章:Redis 工具类 与 Redis 布隆过滤器-CSDN博客
Spring Security 推荐学习视频:SpringSecurity框架教程-Spring Security+JWT实现项目级前端分离认证授权-挑战黑马&尚硅谷_哔哩哔哩_bilibili
2. 依赖
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> <version>3.0.4</version> </dependency>
引入依赖后我们在尝试去访问之前的接口就会自动跳转到一个 SpringSecurity的默认登陆页面
- 默认用户名是user,密码会输出在控制台。
- 必须登陆之后才能对接口进行访问。
退出登录:
等一下你就知道原理了~
3. 登录认证
3.1 登录校验流程
3.2 Spring Security 默认登录的原理
3.2.1 Spring Security 完整流程
SpringSecurity 的原理其实就是一个过滤器链,内部包含了提供各种功能的过滤器。
- 经过所有过滤器才能访问 API,过滤器也能处理响应啥的~
图中只展示了 核心过滤器,其它的非核心过滤器并没有在图中展示。
- UsernamePasswordAuthenticationFilter
- 负责处理我们在登陆页面填写了用户名密码后的登陆请求。入门案例的认证工作主要有它负责
- ExceptionTranslationFilter
- 处理过滤器链中抛出的任何AccessDeniedException和AuthenticationException ,有异常抛出就肯定过不了这个过滤器了
- FilterSecurityInterceptor
- 负责权限校验的过滤器。
3.2.2 登录逻辑探究
UsernamePasswordAuthenticationFilter 就是默认登录方式,默认是 Cookie-Session 机制
概念速查:
Authentication接口: 它的实现类,表示当前访问系统的用户,封装了用户相关信息。
AuthenticationManager接口:定义了认证Authentication的方法
UserDetailsService接口:加载用户特定数据的核心接口。里面定义了一个根据用户名查询用户信息的方法。
UserDetails接口:提供核心用户信息。通过UserDetailsService根据用户名获取处理的用户信息要封装
成UserDetails对象返回。然后将这些信息封装到Authentication对象中。
- 访问
/test/hello
第一次没有会话,UsernamePasswordAuthenticationFilter 尝试获取,获取不到,强制让用户登录(重定向到[GET] /login
页面,本次请求不算数) - 访问
[POST]/login
携带用户名密码,验证成功后,将 “用户相关信息(UserDetails)”构造出一个核心对象(Authentication),放入这次请求的 Security 框架上下文对象中,前往之后的拦截器
- 其他过滤器都没问题了(登录没啥权限要求),请求成功到达
[POST]/login
,进行对应逻辑,返回响应~
- 访问
/test/hello
携带 Cookie,UsernamePasswordAuthenticationFilter 获取到会话记录 UserDetails,”构造出一个核心对象(Authentication),放入这次请求的 Security 框架上下文对象中,前往之后的拦截器
会话记录:
- 其他过滤器都没问题了(登录没啥权限要求),请求成功到达
/test/hello
,进行对应逻辑,返回响应~
3.3 自定义改动
- 自定义用户名密码校验方式
- 自定义拦截器,可调节在哪个拦截器之前或之后,或者删除哪些拦截器
3.3.1 自定义用户密码校验
- 自定义的 UserDetails 获取方式
F1
- 自定义密码的加密算法
F2
设[POST]/login
携带的用户名为 U
,密码为 P
,UserDetails 为 D
D = F1(U);
- 判断
F2(P)
与D.getPassword()
,是否相同
3.3.2 自定义 UserDetails 获取方式 F1
我知道我没有给出一些数据库与MP相关的代码,不过思路看得懂即可!
@Data @AllArgsConstructor @NoArgsConstructor public class LoginUser implements UserDetails { private SysUser user; private List<String> permissions; @JSONField(serialize = false)//代表,由不通过fastjson序列化,用系统redis其他的方式存入redis private List<GrantedAuthority> authorities;//com.alibaba.fastjson.JSONException: autoType is not support. @Override public Collection<? extends GrantedAuthority> getAuthorities() { if(Objects.isNull(this.authorities)) { // 将 permissions 的权限信息封装成 SimpleGrantedAuthority 象,并且也是集合 this.authorities = this.permissions.stream() .parallel() .map(SimpleGrantedAuthority::new) .collect(Collectors.toList()); } return this.authorities; } @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; } public LoginUser(SysUser user, List<String> permissions) { this.user = user; this.permissions = permissions; } }
@Service @Slf4j public class UserDetailsServiceImpl implements UserDetailsService { @Resource private SysMenuService menuService; // 查询用户信息 @Override public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException { // 查询用户信息 SysUser user = Db.lambdaQuery(SysUser.class) .eq(SysUser::getUserName, s) .one(); if(Objects.isNull(user)) { log.warn("用户名或者密码错误"); throw new RuntimeException("用户名或者密码错误"); } // todo 查询对应的权限信息 List<String> permissions = menuService.selectMenuById(user.getId()); // 把数据封装成UserDetails返回 return new LoginUser(user, permissions); } }
认证的时候,就会用我们实现的加载数据的方法
3.3.3 自定义加密算法 F2
//BCryptPasswordEncoder的Bean对象加入到容器里 @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }
在认证的时候,就是用我们的这个 PasswordEncoder 去加密(默认不加密,不用加密算法)
用同种加密算法去注册两个账号:
@Resource private PasswordEncoder encoder; @Test public void createUser() { List<SysUser> list = new ArrayList<SysUser>() {{ add(new SysUser(){{ setId(1L); setNickName("马1号"); setUserName("mara1"); setPassword(encoder.encode("123456")); }}); add(new SysUser(){{ setId(2L); setNickName("马2号"); setUserName("mara2"); setPassword(encoder.encode("123456")); }}); }}; Db.saveBatch(list); }
3.4 自定义登录认证
对于以上内容,依赖默认的登录页面,获取是用户名密码的机制也比较单一,我们往往需要一些其他的登录方式~
接下来,我们来搞一下自定义登录(还是以用户名密码,但是这次是我们自己写的业务逻辑)
3.4.1 自定义登录接口
@Data public class FormUserDTO { private String userName; private String password; }
@RestController //@RequestMapping("/user") public class LoginController { @Resource private LoginService loginService; @PostMapping("/user/login") public ResponseResult login(@RequestBody FormUserDTO user) { //登录 return loginService.login(user); } }
public interface LoginService { ResponseResult login(FormUserDTO user); }
@Override @Bean public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); }
实现:
@Resource private AuthenticationManager authenticationManager; @Resource private RedisCache redisCache; @Override public ResponseResult<Map<String, String>> login(FormUserDTO user) { // 获取AuthenticationManager authenticate认证 UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user.getUserName(), user.getPassword()); Authentication authenticate = authenticationManager.authenticate(authenticationToken); //如果认证不通过,提示 if(Objects.isNull(authenticate)) { log.warn("登录失败"); throw new RuntimeException("登录失败"); } //如果认证通过了,用userid生成一个jwt LoginUser loginUser = (LoginUser) authenticate.getPrincipal();//认证的时候已经调用了getAuthorities了 String userid = loginUser.getUser().getId().toString(); String jwt = JwtUtil.createJWT(userid); //把完整的用户信息存入redis,userid作为key redisCache.setCacheObject("login:" + userid, loginUser); // redisCache.getCacheObject("login:" + userid);//是完完整整的存进去的 // 符合jwt return new ResponseResult(200, "登录成功", new HashMap<String, String>(){{ this.put("token", jwt); }}); }
这就相当于替 UsernamePasswordAuthenticationFilter 调用了认证方法获得 authenticate:
自然就是一轮认证:
认证失败也是在这个过程中抛出异常的
3.4.2 自定义过滤器
@Component @Slf4j public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {// 保证请求只会经过这个过滤器一次 @Resource private RedisCache redisCache; @Override protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException { // 获取token String token = httpServletRequest.getHeader("token"); if(!StringUtils.hasText(token)) { filterChain.doFilter(httpServletRequest, httpServletResponse);//放行 return; } // 解析token String userid = null; try { Claims claims = JwtUtil.parseJWT(token); userid = claims.getSubject(); } catch (Exception e) { log.error("token非法"); filterChain.doFilter(httpServletRequest, httpServletResponse);//放行 return; } //从redis中获取用户信息 String redisKey = "login:" + userid; LoginUser loginUser = redisCache.getCacheObject(redisKey); if(Objects.isNull(loginUser)) { filterChain.doFilter(httpServletRequest, httpServletResponse);//放行 return; } // 存入SecurityContextHolder UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());//代表已认证 SecurityContextHolder.getContext().setAuthentication(authenticationToken); filterChain.doFilter(httpServletRequest, httpServletResponse);//放行 } }
认证成功要记录!
3.4.3 Security 配置
yaml 配置文件去配置不灵活,不鲜明,在这里不演示
@Configuration @EnableGlobalMethodSecurity(prePostEnabled = true)//开启权限控制,不开启这个,注解的权限控制不能生效 public class SecurityConfig extends WebSecurityConfigurerAdapter { @Resource private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter; @Resource private AuthenticationEntryPoint authenticationEntryPoint; @Resource private AccessDeniedHandler accessDeniedHandler; //BCryptPasswordEncoder的Bean对象加入到容器里 @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Override @Bean public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } @Override protected void configure(HttpSecurity http) throws Exception { // super.configure(http); 默认是cookie session //内部指定表单,用户输入表单就去验证,也就是UsernamePasswordAuthenticationFilter的认证方式 // 既然注释掉了,则代表这个过滤器(可能不止这一个)失效了~(不被调用 -> 过滤器不工作),并且还注释掉“控制”相关的配置 http //关闭csrf .csrf().disable() //不通过Session获取SecurityContext .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() .antMatchers("/user/login") /*匿名访问*/.anonymous() // 添加过滤器 http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);//添加过滤器在某个过滤器之前 } }
不必调用父类的configure方法,UsernamePasswordAuthenticationFilter就相当于没用了
UsernamePasswordAuthenticationFilter 无法进行重定向
不请求`[POST]/login` ,UsernamePasswordAuthenticationFilter就不会调用认证的方法,SecurityContextHolder就不会有对应的那个Authentication对象的记录
最终需要认证的接口就访问不了!
- “哪些可以匿名访问不用认证,哪些需要进行认证”,只不过是一些“认证/权限等等的控制”罢了,没有实际的认证机制
- 每个请求都会经过过滤器链,只不过一些没通过检查也无所谓!
这样我们就需要自己写个拦截器,去规定怎么样才算认证成功
这样,必须提前在/user/login (接口内用了认证方法,这一步代表体检做认证),将userid存到redis,生成token返回
请求必须携带token,由 jwtAuthenticationTokenFilter 进行认证是否有token,redis是否有记录
有则代表提前做了认证,SecurityContextHolder 设置那个Authentication对象,放行即可,就不需要进行UsernamePasswordAuthenticationFilter的那个默认login页面去认证了
把默认的关了的话,UsernamePasswordAuthenticationFilter 就相当于没用了,UsernamePasswordAuthenticationFilter 并不会让认证结果有任何变化
没有 jwtAuthenticationTokenFilter 的话,那么 SecurityContextHolder 就没有记录,那么请求必然不通过认证/验权
jwtAuthenticationTokenFilter 没有让 SecurityContextHolder 有记录,那么请求也必然不通过认证/验权
3.4.4 退出登录
@GetMapping("/user/logout") public ResponseResult logout() { return loginService.logout(); }
public interface LoginService { ResponseResult login(FormUserDTO user); ResponseResult logout(); }
实现:
@Override public ResponseResult logout() { //获取用户id UsernamePasswordAuthenticationToken authentication = (UsernamePasswordAuthenticationToken) SecurityContextHolder.getContext().getAuthentication(); LoginUser loginUser = (LoginUser) authentication.getPrincipal(); String userid = loginUser.getUser().getId().toString(); // 删除redis中的值 String redisKey = "login:" + userid; redisCache.deleteObject(redisKey); return new ResponseResult(200, "注销成功"); }
补充,用户名为 null,在 loadUserByUsername 方法的 userName 为"NONE_PROVIDED"
- 如果真的有用户名是这个,密码还没错,还真的能登录成功!
这是借用了 UsernamePasswordAuthenticationFilter 的认证方法,我们当然也可以自己写一个认证方法,构造UserDetails 啊
- 也可以研究其他的 SpringSecurity 提供的认证方法~
Spring Security 重点解析(下):https://developer.aliyun.com/article/1509659