Spring Security 是 Spring 家族中的一个安全管理框架。目前比较主流的是另外一个安全框架Shiro,它提供了更丰富的功能,社区资源也比Shiro丰富,但是shiro并不简便,这里轻量级安全框架更推荐国产安全框架satokensatoken官网
一般大型的项目都是使用SpringSecurity 来做安全框架。这些安全框架主要的内容包含以下功能模块
一般Web应用的需要进行认证和授权。
认证:验证当前访问系统的是不是本系统的用户,并且要确认具体是哪个用户
比如:购买东西前需要登录 ,预约前需要登录认证个人信息
授权:经过认证后判断当前用户是否有权限进行某个操作
比如;一般某些管理后台系统对应用户的角色(管理员,审核,测试)等不同角色提供不同服务,某些接口功能只对某些特定角色开启
本章主要讲认证模块 看完本章是实现前后端分离场景–>看这个Spring
security用户授权
而认证和授权也是SpringSecurity作为安全框架的核心功能。(官方中文文档截图)springsecurity官方中文文档
当然,这些功能也可以自己通过拦截器和jwt等实现认证,通过访问接口前多表联查实现授权等自定义实现,比如自己实现前后端分离安全认证
官方代码示例:GitHub - spring-projects/spring-security-samples
目前主流的还是mvc架构 以及采用servlet的web项目
该目录地址就是一个推荐的springsecurity官方案列
项目名:security-demo
JDK:17
SpringBoot:3.2.0(依赖了Spring Security 6.2.0)
Dependencies:Spring Web、Spring Security、Thymeleaf
package com.atguigu.securitydemo.controller; @Controller public class IndexController { @GetMapping("/") public String index() { return "index"; } }
在路径resources/templates中创建index.html,这个th:href=“@{/logout}” 是thymeleaf的模板语法和vue和相似
Hello Security! Hello Security
Log Out
启动项目测试Controller
浏览器中访问:http://localhost:8080/
**浏览器自动跳转到登录页面:**http://localhost:8080/login
这个时候如果没有认证访问controller的路由就会跳转默认生成的登录页面
输入用户名:user
输入密码:在控制台的启动日志中查找初始的默认密码
点击"Sign in"进行登录,浏览器就跳转到了index页面
就是类似vue的动态语法 这里了解即可
通过使用@{/logout},Thymeleaf将自动处理生成正确的URL,以适应当前的上下文路径。这样,无论应用程序部署在哪个上下文路径下,生成的URL都能正确地指向注销功能。
例如:如果我们在配置文件中添加如下内容
server.servlet.context-path=/demo
那么@{/logout}可以自动处理url为正确的相对路径(此时为demo 这里是演示路径无需添加)
但是如果是普通的/logout,路径就会不正确
页面样式bootstrap.min.css是一个CDN地址,由于在国外需要通过科学上网的方式访问
当然由于我们写项目都是客户端和服务端分离的方式,这里无需在意这个问题
我们并没有写一个登录接口,但是框架就已经
官方文档中写得清楚
客户端向应用程序发送一个请求,容器创建一个 FilterChain,其中包含 Filter 实例和 Servlet,应该根据请求URI的路径来处理 HttpServletRequest。在Spring MVC应用程序中,Servlet是 DispatcherServlet 的一个实例。一个 Servlet 最多可以处理一个 HttpServletRequest 和 HttpServletResponse。然而,可以使用多个 Filter 来完成如下工作。
防止下游的 Filter 实例或 Servlet 被调用。在这种情况下,Filter 通常会使用 HttpServletResponse 对客户端写入响应。
修改下游的 Filter 实例和 Servlet 所使用的 HttpServletRequest 或 HttpServletResponse。
过滤器的力量来自于传入它的 FilterChain。
DelegatingFilterProxy 是 Spring Security 提供的一个 Filter 实现,可以在 Servlet 容器和 Spring 容器之间建立桥梁。通过使用 DelegatingFilterProxy,这样就可以将Servlet容器中的 Filter 实例放在 Spring 容器中管理。
复杂的业务中不可能只有一个过滤器。因此FilterChainProxy是Spring Security提供的一个特殊的Filter,它允许通过SecurityFilterChain将过滤器的工作委托给多个Bean Filter实例。
SecurityFilterChain 被 FilterChainProxy 使用,负责查找当前的请求需要执行的Security Filter列表。
多个过滤器的使用可以完成各个样子的业务
可以有多个SecurityFilterChain的配置,FilterChainProxy决定使用哪个SecurityFilterChain。如果请求的URL是/api/messages/,它首先匹配SecurityFilterChain0的模式/api/**,因此只调用SecurityFilterChain 0。假设没有其他SecurityFilterChain实例匹配,那么将调用SecurityFilterChain n。
所以看下来可以发现springsecurity中包含了很多过滤器,组成过滤器链完成认证操作
SecurityFilterChain接口的实现,加载了默认的16个Filter
了解完securtiy的底层架构,在解决一个问题,那么就是登录时候的账户名和密码如何出现的,在依赖中搜索该类SecurityProperties,发现会出现一个内部类user,账户名为user,密码则是uuid,很明显这个就是启动项目时候默认生成的密码,既然这个类是属性类
,那么我们也可以进行指定,在security的配置文件文档中
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.*; import org.springframework.security.config.annotation.authentication.builders.*; import org.springframework.security.config.annotation.web.configuration.*; @Configuration @EnableWebSecurity//SPRINGBOOT项目忽略 public class WebSecurityConfig { @Bean public UserDetailsService userDetailsService() { InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager(); // 在内存中创建用户作用登录时候作为对比的数据源 manager.createUser(User.withDefaultPasswordEncoder().username("user").password("password").roles("USER").build()); return manager; } }
发现可以通过注册一个UserDetailsService的bean进行实现
其中返回的是内存管理对象实列,管理用户users
UserDetailsService 包含了根据用户名加载对象方法,该方法就是springsecurity进行用户比对的用户源,(这里是进行静态写在内存,那么后期可以自己实现这个接口来比对用户变成数据库)
其中的maner则是提供了对系统用户crud的方法
回到官网提供的demo配置案列,那么就可以将用户静态写在内存中
//见名知意 这里创建在内存的时候,还对这个用户密码进行了加密(withDefaultPasswordEncoder) manager.createUser(User.withDefaultPasswordEncoder() .username("user").password("password").roles("USER").build());
此时重启配置,就可以根据user ,password进行登录
既然是静态配置,那么就可以写在配置文件
spring.security.user.name=user spring.security.user.password=123
需要对于接口UserDetailsService有个初步映像,理解为管理认证需要的数据源管理器
实际开发的过程中,我们需要应用程序更加灵活,可以在SpringSecurity中创建自定义配置文件
官方文档:Java自定义配置
刚才实现了官方的实列配置,可以发现大致是相同,那么我们可以通过了解基于内存的管理器来更一步了解security
从刚才的实列中可知.UserDetailsService用来管理用户信息,InMemoryUserDetailsManager是UserDetailsService的一个实现,用来管理基于内存的用户信息。其中createuser就是在内存中创建系统用户,用于登录时候进行比对
实现就是在其封装在内存中map,
manner的其他方法则是对用户进行crud 不做过多阐述
那么顶级接口userdetailsService呢
loadUserByUsername 这个方法主要用于从后端系统(比如数据库,官方演示配置是内存)加载用户的详细信息。当用户尝试登录时,他们会提供自己的用户名(或其他标识)和密码。Spring Security 需要使用这个用户名来获取用户的详细信息,包括他们的密码、权限等。这就是在 UserDetailsService 接口中定义的 loadUserByUsername 方法的目的。也就是说在进行比对认证时候需要调用这个方法
loadUserByUsername 就是在尝试进行用户认证的过程中,从后端系统加载用户详细信息的关键步骤。这个步骤通常发生在 Spring Security 处理登录请求的过程中的 DaoAuthenticationProvider 中。
这里查看内存用户管理器的实现 通过用户名返回用户
在UsernamePasswordAuthenticationFilter过滤器中的attemptAuthentication方法中将用户输入的用户名密码和从内存中获取到的用户信息进行比较,进行用户认证
认证的大概逻辑
而这个用户我没并没有定义,这个用户是官方定义的用户类型
通过源码可以看到官方定义的用户 包含了用户基本信息外,还有权限列表账户是否过期等消息,所以在创建用户的时候大致可以参考一下其中user,而userdetials则是定影了用户的一些细节信息
所以接下来通过改造重写userdetialsuservice实现数据库认证
创建数据并且创建三个用户
-- 创建数据库 CREATE DATABASE `security-demo`; USE `security-demo`; -- 创建用户表 CREATE TABLE `user`( `id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY, `username` VARCHAR(50) DEFAULT NULL , `password` VARCHAR(500) DEFAULT NULL, `enabled` BOOLEAN NOT NULL ); -- 唯一索引 CREATE UNIQUE INDEX `user_username_uindex` ON `user`(`username`); -- 插入用户数据(密码是 "abc" ) INSERT INTO `user` (`username`, `password`, `enabled`) VALUES ('admin', '{bcrypt}a$GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0FxO/BTk76klW', TRUE), ('Helen', '{bcrypt}a$GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0FxO/BTk76klW', TRUE), ('Tom', '{bcrypt}a$GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0FxO/BTk76klW', TRUE);
字段也只有三个,是很基本的用户表,其中数据密码是采用springsecurity的默认加密方式
引入依赖
mysql mysql-connector-java 8.0.30 com.baomidou mybatis-plus-spring-boot3-starter 3.5.5 org.mybatis mybatis-spring com.github.xiaoymin knife4j-openapi3-jakarta-spring-boot-starter 4.1.0 org.mybatis mybatis-spring 3.0.3 org.projectlombok lombok
配置数据源
#MySQL数据源 spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver spring.datasource.url=jdbc:mysql://localhost:3306/security-demo spring.datasource.username=root spring.datasource.password=123456 #SQL日志 mybatis-plus.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl
安装myvatis-x 一键生成各个层
写一个controller验证orm框架是否操作数据库可行
@RestController @RequestMapping("/user") public class UserController { @Resource public UserService userService; @GetMapping("/list") public ListgetList(){ return userService.list(); } }
目录
创建认证管理器有内存和jdbc俩个,但是jdbc的认证是基于springtemplate 所以需要自己更改
通过上面的认识,认证过程中主要是通过loadbyusername 取出用户进行和前端输入的用户比对,所以要做的就是模仿InMemoryUserDetailsManager类
认证流程
模仿InMemoryUserDetailsManager
@Component//注入ioc 或者在配置文件中用@Bean的方式注入 @Slf4j public class DBUserDetailsManager implements UserDetailsManager, UserDetailsPasswordService { // 这样就可以按照security的规范来使用用户的管理 @Override public UserDetails updatePassword(UserDetails user, String newPassword) { return null; } //原来的内存管理器是在用户在添加到内存的map,实现这个方法这里插入数据库 @Override public void createUser(UserDetails userDetails) { // 在sql中插入信息 User user = new User(); user.setUsername(userDetails.getUsername()); user.setPassword(userDetails.getPassword()); user.setEnabled(1); userMapper.insert(user); } @Override public void updateUser(UserDetails user) { } @Override public void deleteUser(String username) { } @Override public void changePassword(String oldPassword, String newPassword) { } @Override public boolean userExists(String username) { return false; } @Resource private UserMapper userMapper; //security底层会根据这个方法来对比用户 @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { LambdaQueryWrapperwrapper = Wrappers. lambdaQuery().eq(User::getUsername, username); // 这里用户账户是唯一的 User user = userMapper.selectOne(wrapper); if (user == null){ throw new UsernameNotFoundException("系统用户不存在"); }else{ // 1表示可用 boolean isenabled = user.getEnabled() == 1; /** * ,任何非零的整数值都会被视为 true,而 0 会被视为 false。 */ //模拟系统权限列表 Collection authorities = new ArrayList<>(); return new org.springframework.security.core.userdetails.User(user.getUsername(), user.getPassword(),isenabled , true, true, true, // 权限列表 authorities); } } }
重启的时候把配置类中基于内存的用户管理器注入bean的代码注释,避免有俩个userdetailsService的实现,认证时调用loaduserBYusername冲突,重启即可,此时登录认证,输入admin,password 即可登录,则说明是从数据库中获取用户进行比对且成功
此时已经是实现基于数据库实现,但是还是不够灵活,为此我们需要了解默认配置(引入security即实现的配置)
//经过过滤器的请求 http // lambda表达式对其中请求进行遍历 .authorizeRequests(authorize -> authorize .anyRequest() .authenticated()//已认证的请求自动授权 ) // 如果没有登录认证的请求默认使用表单登录api 跳转表单进行登录 .formLogin(withDefaults())//自动生成表单 .httpBasic(withDefaults());//然后给在使用基本授权方式(游览器默认表单)
httpBasic(withDefaults())采用游览器默认认证方式,在过滤器链中注释.formLogin(withDefaults()) 那么重启
默认配置的另一个配置表单登录,如果我们觉得,这个登录页面丑呢,那么需要修改这个配置来实现自定义登录界面(这里都是前后i端一体的,分离在后面)
在templates新建login页面
登录页面
新添加一个controller负责跳转路由
@Controller public class LoginController { @GetMapping("/login") public String login() { return "login"; } }
修改配置文件
@Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // 返回的是安全过滤器链所以是依次执行的 //关闭csrf攻击防御 http.csrf(AbstractHttpConfigurer::disable).formLogin( form -> { form .loginPage("/login").permitAll() //登录页面无需授权即可访问 .usernameParameter("username") //自定义表单用户名参数,默认是username .passwordParameter("password") //自定义表单密码参数,默认是password .failureUrl("/login?error") //登录失败的返回地址 ; }); //使用表单授权方式; http // lambda表达式对其中请求进行遍历 .authorizeRequests(authorize -> authorize .anyRequest() .authenticated()//已认证的请求自动授权 ) // 如果没有登录认证的请求默认使用表单登录api 跳转表单进行登录 // .formLogin(withDefaults())//自动生成表单 不使用自动生成的表单 .httpBasic(withDefaults());//然后给在使用基本授权方式(游览器默认表单) return http.build(); }
一定记得关闭默认表达认证避免重涂,重启项目
那么实现完成了自定义登录了,接下来探讨的是密码安全部分
security在密码安全部分做了很好的加密算法
先回忆内存用户管理类中添加用户的代码,其中对用户进行了加密
为此我们根据官方实列来进行仿造数据中添加符合security定义的用户
usercontroller中写一个添加用户接口
@PostMapping("/add") public void add(@RequestBody User user){ userService.saveUserDetails(user);}
UserService接口中添加方法
void saveUserDetails(User user);
UserServiceImpl实现中添加方法
@Slf4j @Service public class UserServiceImpl extends ServiceImplimplements UserService{ @Autowired DBUserDetailsManager userDetailsManager; @Override public void adduser(User user) { log.info("最开始接收到的密码"+user.getPassword()); // security的user UserDetails details = org.springframework.security.core.userdetails.User .withDefaultPasswordEncoder().username(user.getUsername()).password(user.getPassword()) .roles("USER")//当前数据还没有角色一说 .build(); log.info("构造为userdetials的密码"+details.getPassword()); userDetailsManager.createUser(details); } }
DBUserDetailsManager中之前就添加的插入数据库方法
@Override public void createUser(UserDetails userDetails) { User user = new User(); user.setUsername(userDetails.getUsername()); user.setPassword(userDetails.getPassword()); user.setEnabled(true); userMapper.insert(user); }
使用Swagger测试
pom中添加配置用于测试
com.github.xiaoymin knife4j-openapi3-jakarta-spring-boot-starter 4.1.0
**Swagger测试地址:**http://localhost:8080/demo/doc.html
在测试接口前需要关闭csrf保护
默认情况下SpringSecurity开启了csrf攻击防御的功能,这要求请求参数中必须有一个隐藏的**_csrf**字段,如下:
在filterChain方法中添加如下代码,关闭csrf攻击防御
//关闭csrf攻击防御 http.csrf((csrf) -> { csrf.disable(); });
访问swagger接口测试
http://localhost:8080/doc.html
输出
此时就可以清楚的看到密码是进行加密了的
密码加密算法
参考文档:Password Storage :: Spring Security
明文密码:
最初,密码以明文形式存储在数据库中。但是恶意用户可能会通过SQL注入等手段获取到明文密码,或者程序员将数据库数据泄露的情况也可能发生。
Hash算法:
Spring Security的PasswordEncoder接口用于对密码进行单向转换,从而将密码安全地存储。对密码单向转换需要用到哈希算法,例如MD5、SHA-256、SHA-512等,哈希算法是单向的,只能加密,不能解密。
因此,数据库中存储的是单向转换后的密码,Spring Security在进行用户身份验证时需要将用户输入的密码进行单向转换,然后与数据库的密码进行比较。
因此,如果发生数据泄露,只有密码的单向哈希会被暴露。由于哈希是单向的,并且在给定哈希的情况下只能通过暴力破解的方式猜测密码。
彩虹表:
恶意用户创建称为彩虹表的查找表。
彩虹表就是一个庞大的、针对各种可能的字母组合预先生成的哈希值集合,有了它可以快速破解各类密码。越是复杂的密码,需要的彩虹表就越大,主流的彩虹表都是100G以上,目前主要的算法有LM, NTLM, MD5, SHA1, MYSQLSHA1, HALFLMCHALL, NTLMCHALL, ORACLE-SYSTEM, MD5-HALF。
加盐密码:
为了减轻彩虹表的效果,开发人员开始使用加盐密码。不再只使用密码作为哈希函数的输入,而是为每个用户的密码生成随机字节(称为盐)。盐和用户的密码将一起经过哈希函数运算,生成一个唯一的哈希。盐将以明文形式与用户的密码一起存储。然后,当用户尝试进行身份验证时,盐和用户输入的密码一起经过哈希函数运算,再与存储的密码进行比较。唯一的盐意味着彩虹表不再有效,因为对于每个盐和密码的组合,哈希都是不同的。
自适应单向函数:
随着硬件的不断发展,加盐哈希也不再安全。原因是,计算机可以每秒执行数十亿次哈希计算。这意味着我们可以轻松地破解每个密码。
现在,开发人员开始使用自适应单向函数来存储密码。使用自适应单向函数验证密码时,故意占用资源(故意使用大量的CPU、内存或其他资源)。自适应单向函数允许配置一个“工作因子”,随着硬件的改进而增加。我们建议将“工作因子”调整到系统中验证密码需要约一秒钟的时间。这种权衡是为了让攻击者难以破解密码。
自适应单向函数包括bcrypt、PBKDF2、scrypt和argon2。
BCryptPasswordEncoder
使用广泛支持的bcrypt算法来对密码进行哈希。为了增加对密码破解的抵抗力,bcrypt故意设计得较慢。和其他自适应单向函数一样,应该调整其参数,使其在您的系统上验证一个密码大约需要1秒的时间。BCryptPasswordEncoder的默认实现使用强度10。建议您在自己的系统上调整和测试强度参数,以便验证密码时大约需要1秒的时间。
Argon2PasswordEncoder
使用Argon2算法对密码进行哈希处理。Argon2是密码哈希比赛的获胜者。为了防止在自定义硬件上进行密码破解,Argon2是一种故意缓慢的算法,需要大量内存。与其他自适应单向函数一样,它应该在您的系统上调整为大约1秒来验证一个密码。当前的Argon2PasswordEncoder实现需要使用BouncyCastle库。
Pbkdf2PasswordEncoder
使用PBKDF2算法对密码进行哈希处理。为了防止密码破解,PBKDF2是一种故意缓慢的算法。与其他自适应单向函数一样,它应该在您的系统上调整为大约1秒来验证一个密码。当需要FIPS认证时,这种算法是一个很好的选择。
SCryptPasswordEncoder
使用scrypt算法对密码进行哈希处理。为了防止在自定义硬件上进行密码破解,scrypt是一种故意缓慢的算法,需要大量内存。与其他自适应单向函数一样,它应该在您的系统上调整为大约1秒来验证一个密码。
在测试类中编写一个测试方法
@Test void testPassword() { // 工作因子,默认值是10,最小值是4,最大值是31,值越大运算速度越慢 PasswordEncoder encoder = new BCryptPasswordEncoder(4); //明文:"password" //密文:result,即使明文密码相同,每次生成的密文也不一致 String result = encoder.encode("password"); System.out.println(result); //密码校验 Assert.isTrue(encoder.matches("password", result), "密码不一致"); }
根据密码前缀进行比对
前缀是为了判断根据哪个算法进行加密,对用户密码进行比对时候会判断前缀 不同用户不同加密方式
在项目开发中前端和后端应该是分开的特别是服务器端应该专注于数据的返回,而页面跳转等前端工作由前端完成,所以这里需要对认证功能进行前后端分离开发的i情况下定制化
先来了解登录流程
下面讨论的都是security的内置部分
官网的认证架构
所以我们需要做的就是- 前端传递的用户密码生成认证token 然后提交给认证manager,所以我们的登录接口返回的不应该是跳转路由
参考我们正常的前后端分离开发过程,我们需要自己封装一个json
自定义返回结果的话就需要重写 认证处理成功的处理器的抽象方法
同理认证失败的话就要重写认证失败处理器中的处理方法
引入fastjson
com.alibaba.fastjson2 fastjson2 2.0.37
写一个通用返回结果类(这里随便写的实际开发不可能这么简单)
@Data @AllArgsConstructor public class Result { public static final int SUCCESS_CODE=0;//成功 public static final int Nologin_CODE=401;//成功 public static final int fail_CODE=500;//失败 private int code;//错误码 private String msg;//返回信息 private Object data;//返回数据 public static Result success(Object data){ return new Result(SUCCESS_CODE,"操作成功",data); } public static Result fail(String Errormsg) { return new Result(fail_CODE,Errormsg,null); } public static Result nologin(String Errormsg) { return new Result(Nologin_CODE,Errormsg,null); } }
@Slf4j public class myAUthensuccessHandler implements AuthenticationSuccessHandler { @Resource UserMapper userMapper; @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { // 当应用程序认证成功时候触发 User user = new User(); Object principal = authentication.getPrincipal(); //获取用户身份信息 log.info("用户信息"+principal); // authentication.getCredentials();//登录凭证信息 账户密码登录 时里包含用户密码等信息 // Collection extends GrantedAuthority> collection = authentication.getAuthorities();//包含的权限信息 response.setContentType("application/json;charset=UTF-8");//响应头 //认证成功 String jsonString = JSON.toJSONString(Result.success(principal)); response.getWriter().println(jsonString);//响应体内容输出 } }
SecurityFilterChain配置
form.successHandler(new MyAuthenticationSuccessHandler()) //认证成功时的处理
输出的principal中不包含密码
返回结果
public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler { @Override public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException, IOException { //获取错误信息 String localizedMessage = exception.getLocalizedMessage(); //转换成json字符串 String json = JSON.toJSONString(Result.fail("登录失败哦")); //返回响应 response.setContentType("application/json;charset=UTF-8"); response.getWriter().println(json); } }
SecurityFilterChain配置
form.failureHandler(new MyAuthenticationFailureHandler()) //认证失败时的处理
package com.atguigu.securitydemo.config; public class MyLogoutSuccessHandler implements LogoutSuccessHandler { @Override public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { //创建结果对象 HashMap result = new HashMap(); result.put("code", 0); result.put("message", "注销成功"); //转换成json字符串 String json = JSON.toJSONString(result); //返回响应 response.setContentType("application/json;charset=UTF-8"); response.getWriter().println(json); } }
SecurityFilterChain配置
http.logout(logout -> { logout.logoutSuccessHandler(new MyLogoutSuccessHandler()); //注销成功时的处理 });
Servlet Authentication Architecture :: Spring Security
当访问一个需要认证之后才能访问的接口的时候,Spring Security会使用AuthenticationEntryPoint将用户请求跳转到登录页面,要求用户提供登录凭证。所以如果是前后端分类使用Springsecurity,一定要重写这个接口并用配置文件加入过滤器链
这里我们也希望系统返回json结果方便前端根据状态码进行跳转客户端的登录页面,因此我们定义类实现AuthenticationEntryPoint接口
package com.atguigu.securitydemo.config; public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { //获取错误信息 //String localizedMessage = authException.getLocalizedMessage(); //创建结果对象 HashMap result = new HashMap(); result.put("code", -1); result.put("message", "需要登录"); //转换成json字符串 String json = JSON.toJSONString(result); //返回响应 response.setContentType("application/json;charset=UTF-8"); response.getWriter().println(json); } }
SecurityFilterChain配置
//错误处理 http.exceptionHandling(exception -> { exception.authenticationEntryPoint(new MyAuthenticationEntryPoint());//请求未认证的接口 });
官网解析
在Spring Security框架中,SecurityContextHolder、SecurityContext、Authentication、Principal和Credential是一些与身份验证和授权相关的重要概念。它们之间的关系如下:
总结起来,SecurityContextHolder用于管理当前线程的安全上下文,存储已认证用户的详细信息,其中包含了SecurityContext对象,该对象包含了Authentication对象,后者表示用户的身份验证信息,包括Principal(用户的身份标识)和Credential(用户的凭证信息)。
自己实现前后端登录安全校验,这篇文档中我使用的就是localtrhread 线程池作为上下文对象
之前实在官方的接口中获取配置信息,那么如果在controller的环境呢
其实就是类似上下文,在最开始的哪个
IndexController:
@GetMapping("/") public Result index() { SecurityContext context = SecurityContextHolder.getContext();//存储认证对象的上下文 Authentication authentication = context.getAuthentication();//认证对象 String username = authentication.getName();//用户名 Object principal =authentication.getPrincipal();//身份 Object credentials = authentication.getCredentials();//凭证(脱敏) Collection extends GrantedAuthority> authorities = authentication.getAuthorities();//权限 System.out.println(username); System.out.println(principal); System.out.println(credentials); System.out.println(authorities); HashMapmap = new HashMap<>(); map.put("认证对象", authentication); map.put("身份信息", principal); map.put("creden", credentials); return Result.success(map); }
并且
如果把cookie删除,找不到对应的session,那么就会登录失效 说明security默认是采用会话登录,如果在分布式的环境中,session无法共享是不能完成我们需要的需求的,后续我们需要对该功能模块进行更细一步的定制化
后登录的账号会使先登录的账号cookie失效
实现接口SessionInformationExpiredStrategy
package com.atguigu.securitydemo.config; public class MySessionInformationExpiredStrategy implements SessionInformationExpiredStrategy { //当session失效 @Override public void onExpiredSessionDetected(SessionInformationExpiredEvent event) throws IOException, ServletException { //创建结果对象 HashMap result = new HashMap(); result.put("code", -1); result.put("message", "该账号已从其他设备登录"); //转换成json字符串 String json = JSON.toJSONString(result); HttpServletResponse response = event.getResponse(); //返回响应 response.setContentType("application/json;charset=UTF-8"); response.getWriter().println(json); } }
SecurityFilterChain配置
//会话管理 http.sessionManagement(session -> { session 只允许客户端匹配session 的token数量 .maximumSessions(1) .expiredSessionStrategy(new MySessionInformationExpiredStrategy()); });
此时前一个登录的客户端cookie就过期了
跨域全称是跨域资源共享(Cross-Origin Resources Sharing,CORS),它是浏览器的保护机制,只允许网页请求统一域名下的服务,同一域名指=>协议、域名、端口号都要保持一致,如果有一项不同,那么就是跨域请求。在前后端分离的项目中,需要解决跨域的问题。
在SpringSecurity中解决跨域很简单,在配置文件中添加如下配置即可
//跨域 http.cors(withDefaults());
但是上诉提供实现的接口配置都是在原来security提供的login logout 等方法上,显然我们自己的登录接口需要更自由的定制
刚才的接口实现就可以发现,官方默认的login接口逻辑无法满足我们需求,所以我们需要进一步定制化目前github有个低代码平台(maku-boot)官网对security的实现也很优秀可以下载学习
生成 Authentication Token:UsernamePasswordAuthenticationFilter 会根据请求中的用户名和密码创建一个 UsernamePasswordAuthenticationToken(未认证状态 用于在过滤链中进行认证)。
认证过程:这个未认证的 UsernamePasswordAuthenticationToken 会被传递给 AuthenticationManager 进行认证。AuthenticationManager 会调用配置的 AuthenticationProvider,通常是 DaoAuthenticationProvider,来验证用户名和密码。
加载用户详情:AuthenticationProvider 会使用配置的 UserDetailsService 来加载用户的详细信息(如权限),并进行密码的验证。
认证成功:如果认证成功,AuthenticationManager 会返回一个已认证的 Authentication 对象(包含用户的权限信息)给 UsernamePasswordAuthenticationFilter。
安全上下文:SecurityContextHolder 的 SecurityContext 会被更新为包含已认证 Authentication 对象,表示当前用户已经通过认证。
登录成功后->手动生成token返回前端,过滤器用于比对当前用户是否携带token,以及把用户信息保存localthread作为上下文对象,登出->删除该token,让其无法通过过滤器,而security自带的登录登出则是将用户信息保存到SecurityContext作为上下文
可以把token放在redis 可以通过删除redis的数据让用户的token手动失效
org.springframework.boot spring-boot-starter-data-redis io.jsonwebtoken jjwt 0.9.0 javax.xml.bind jaxb-api 2.4.0-b180830.0359 org.springframework.boot spring-boot-starter-validation
jwt 工具类
@Component @Data @Slf4j public class JwtUtil { private static final String CLAIM_KEY_USERNAME = "sub"; private static final String CLAIM_KEY_CREATED = "iat"; @Value("${jwt.data.SECRET}") private String secret; @Value("${jwt.data.expiration}") private Long expiration; private final DefaultClock clock = (DefaultClock) DefaultClock.INSTANCE; public String createToken(UserDetails userDetails) { Mapclaims = new HashMap<>(); claims.put(CLAIM_KEY_USERNAME, userDetails.getUsername()); claims.put(CLAIM_KEY_CREATED, clock.now()); return generateToken(claims); } public String getUsernameFromToken(String token) { String username; try { final Claims claims = getClaimsFromToken(token); username = claims.getSubject(); } catch (Exception e) { username = null; log.error("Error getting username from token: {}", e.getMessage()); } return username; } private Date getExpirationDateFromToken(String token) { final Claims claims = getClaimsFromToken(token); return claims.getExpiration(); } private boolean isTokenExpired(String token) { final Date expiration = getExpirationDateFromToken(token); return expiration.before(clock.now()); } private Claims getClaimsFromToken(String token) { return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody(); } private String generateToken(Map claims) { return Jwts.builder() .setClaims(claims) .setExpiration(generateExpirationDate()) .signWith(SignatureAlgorithm.HS512, secret) .compact(); } private Date generateExpirationDate() { return new Date(clock.now().getTime() + expiration * 1000); } public boolean validateToken(String token, UserDetails userDetails) { final String username = getUsernameFromToken(token); return username.equals(userDetails.getUsername()) && !isTokenExpired(token); } public boolean canTokenBeRefreshed(String token) { return !isTokenExpired(token); } public String refreshToken(String token) { final Claims claims = getClaimsFromToken(token); claims.put(CLAIM_KEY_CREATED, clock.now()); return generateToken(claims); } // 添加测试方法 public void testJwtUtil() { List authorities = new ArrayList<>(); authorities.add(new SimpleGrantedAuthority("ROLE_USER")); // 添加一个角色 User userDetails = new User( "test", "test", true, true, //用户账号是否过期 true, //用户凭证是否过期 true, //用户是否未被锁定 authorities); // 设置权限列表 // 创建token String token = createToken(userDetails); log.info("生成Token: {}", token); // 从token中获取用户名 String usernameFromToken = getUsernameFromToken(token); log.info("解析出来的用户名: {}", usernameFromToken); // 验证token boolean isValid = validateToken(token, userDetails); log.info("是否有效? {}", isValid); // 刷新token String refreshedToken = refreshToken(token); log.info("刷新 Token: {}", refreshedToken); // 验证刷新后的token boolean isValidRefreshedToken = validateToken(refreshedToken, userDetails); log.info("验证刷新后的token? {}", isValidRefreshedToken); } }
测试是否可以正常生成token
@Autowired JwtUtil jwtUtil; @Test @DisplayName("测试jwtUtil") public void testJwtUtil() { jwtUtil.testJwtUtil(); }
redis 工具类 这里用来做过滤器的时候进行请求头比对,并且用于手动让用户下线
@SuppressWarnings(value = { "unchecked", "rawtypes" }) @Component public class RedisCache { @Autowired public RedisTemplate redisTemplate; /** * 缓存基本的对象,Integer、String、实体类等 * * @param key 缓存的键值 * @param value 缓存的值 */ publicvoid setCacheObject(final String key, final T value) { redisTemplate.opsForValue().set(key, value); } /** * 缓存基本的对象,Integer、String、实体类等 * * @param key 缓存的键值 * @param value 缓存的值 * @param timeout 时间 * @param timeUnit 时间颗粒度 */ public void setCacheObject(final String key, final T value, final Integer timeout, final TimeUnit timeUnit) { redisTemplate.opsForValue().set(key, value, timeout, timeUnit); } /** * 设置有效时间 * * @param key Redis键 * @param timeout 超时时间 * @return true=设置成功;false=设置失败 */ public boolean expire(final String key, final long timeout) { return expire(key, timeout, TimeUnit.SECONDS); } /** * 设置有效时间 * * @param key Redis键 * @param timeout 超时时间 * @param unit 时间单位 * @return true=设置成功;false=设置失败 */ public boolean expire(final String key, final long timeout, final TimeUnit unit) { return redisTemplate.expire(key, timeout, unit); } /** * 获得缓存的基本对象。 * * @param key 缓存键值 * @return 缓存键值对应的数据 */ public T getCacheObject(final String key) { ValueOperations operation = redisTemplate.opsForValue(); return operation.get(key); } /** * 删除单个对象 * * @param key */ public boolean deleteObject(final String key) { return redisTemplate.delete(key); } /** * 删除集合对象 * * @param collection 多个对象 * @return */ public long deleteObject(final Collection collection) { return redisTemplate.delete(collection); } /** * 缓存List数据 * * @param key 缓存的键值 * @param dataList 待缓存的List数据 * @return 缓存的对象 */ public long setCacheList(final String key, final List dataList) { Long count = redisTemplate.opsForList().rightPushAll(key, dataList); return count == null ? 0 : count; } /** * 获得缓存的list对象 * * @param key 缓存的键值 * @return 缓存键值对应的数据 */ public List getCacheList(final String key) { return redisTemplate.opsForList().range(key, 0, -1); } /** * 缓存Set * * @param key 缓存键值 * @param dataSet 缓存的数据 * @return 缓存数据的对象 */ public BoundSetOperations setCacheSet(final String key, final Set dataSet) { BoundSetOperations setOperation = redisTemplate.boundSetOps(key); Iterator it = dataSet.iterator(); while (it.hasNext()) { setOperation.add(it.next()); } return setOperation; } /** * 获得缓存的set * * @param key * @return */ public Set getCacheSet(final String key) { return redisTemplate.opsForSet().members(key); } /** * 缓存Map * * @param key * @param dataMap */ public void setCacheMap(final String key, final Map dataMap) { if (dataMap != null) { redisTemplate.opsForHash().putAll(key, dataMap); } } /** * 获得缓存的Map * * @param key * @return */ public Map getCacheMap(final String key) { return redisTemplate.opsForHash().entries(key); } /** * 往Hash中存入数据 * * @param key Redis键 * @param hKey Hash键 * @param value 值 */ public void setCacheMapValue(final String key, final String hKey, final T value) { redisTemplate.opsForHash().put(key, hKey, value); } /** * 获取Hash中的数据 * * @param key Redis键 * @param hKey Hash键 * @return Hash中的对象 */ public T getCacheMapValue(final String key, final String hKey) { HashOperations opsForHash = redisTemplate.opsForHash(); return opsForHash.get(key, hKey); } /** * 删除Hash中的数据 * * @param key * @param hkey */ public void delCacheMapValue(final String key, final String hkey) { HashOperations hashOperations = redisTemplate.opsForHash(); hashOperations.delete(key, hkey); } /** * 获取多个Hash中的数据 * * @param key Redis键 * @param hKeys Hash键集合 * @return Hash对象集合 */ public List getMultiCacheMapValue(final String key, final Collection
配置redis和你的jwt属性
spring: data: redis: database: 1 host: 192.168.249.133 port: 6379 password: redis #timeout: 6000ms # 连接超时时长(毫秒) datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/security-demo username: root password: 111111 mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl jwt: data: # jwt加密密钥 SECRET: sahksaklsjaasa # jwt储存的请求头Authorization固定写法 tokenHeader: Authorization # jwt的过期时间(60s*60min*24h*7day) expiration: 604800 # jwt负载中拿到的头信息 tokenHead: Bearer
jwt 引入后我们思考需要做的事情 之前是security服务于web默认有登录接口和登出,但是服务于表单登录的,为此我们如果只是后端开发,返回给前端一些数据的话,这里就需要自定义的登陆接口,主要是实现流程中的第三步
首先重写loaduserByusername的类,安全校验时候就会根据这个方法取出数据比较之前是使用默认案列的方法实列化userdetails
User.withDefaultPasswordEncoder().username("user").password("password").roles("USER").build()
现在自己写一个实现该接口的类,让我们定义的用户符合security的规范
@Data public class UserDetail implements UserDetails { //直接包含我们自己数据库的对象 这样我们自己系统的用户对象可以携带任意数据 又是符合security规范的 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; } }
梳理逻辑: 获取前端发送的用户数据后,我们不自己进行比对,依靠 AuthenticationManager进行认证(考察我写的步骤三),然后生成token,返回给前端,并且保存用户数据到redis,便于进行主动删除用户的token
为此第一步就是先实际化AuthenticationManager 在配置文件中进行详细配置
配置文件
/** * 第一步是创建我们的Spring Security Java配置。该配置创建了一个被称为 springSecurityFilterChain 的 Servlet 过滤器, * 它负责应用程序中的所有安全问题(保护应用程序的URL, * 验证提交的用户名和密码,重定向到登录表单,等等)。下面的例子显示了Spring Security Java配置的最基本例子。 */ @EnableWebSecurity//申明是security配置类,要么加在启动类上 @Configuration @AllArgsConstructor public class WebSecurityConfig { private final ApplicationEventPublisher applicationEventPublisher; @Autowired private RedisCache redisCache; @Autowired private JwtUtil jwtUtil; // 从配置文件注入 ioc先扫描配置文件 @Resource UserMapper userMapper; /** * 是Spring Security用于处理基于数据库的用户认证的提供者。 * DaoAuthenticationProvider需要一个UserDetailsService对象来获取用户的详细信息进行认证, * 所以通过setUserDetailsService()方法设置了我们之前设置的manager。 * @return */ @Bean DaoAuthenticationProvider daoAuthenticationProvider() { DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider(); daoAuthenticationProvider.setPasswordEncoder( passwordEncoder()); daoAuthenticationProvider.setUserDetailsService(new DBUserDetailsManager(userMapper)); return daoAuthenticationProvider; } /** * 把默认的密码加密器换成我们自定义的加密器 * @return */ @Bean public PasswordEncoder passwordEncoder(){ return new BCryptPasswordEncoder(); } /** * 这个Bean创建了一个认证管理器对象,它是Spring Security认证的核心组件之一。 * 认证管理器负责协调和管理认证流程,并委托给一个或多个认证提供者(在这里,使用了daoAuthenticationProvider)来进行具体的认证操作。 * 这里通过创建一个ProviderManager对象,将之前配置的daoAuthenticationProvider添加到认证管理器中。 * 还通过setAuthenticationEventPublisher()方法设置了一个事件发布器,用于在认证事件发生时发布相关的事件, * 这里使用了DefaultAuthenticationEventPublisher,并传入了一个applicationEventPublisher对象,可能用于发布认证事件到Spring的事件机制中。 * @return */ @Bean public AuthenticationManager authenticationManager() { ListproviderList = new ArrayList<>(); providerList.add(daoAuthenticationProvider()); ProviderManager providerManager = new ProviderManager(providerList); //在成功或失败的认证事件上发布相应的事件。所以,你可能并不需要显式地创建AuthenticationManager Bean providerManager.setAuthenticationEventPublisher(new DefaultAuthenticationEventPublisher(applicationEventPublisher)); return providerManager; } @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf(AbstractHttpConfigurer::disable)//前后端分离提供接口需要关闭 //添加过滤器并且指定在用户密码认证过滤器前 .addFilterBefore(new JwtAuthenticationTokenFilter(redisCache,jwtUtil), UsernamePasswordAuthenticationFilter.class) .sessionManagement(AbstractHttpConfigurer::disable)//无状态 这里使用的jwt代替session .authorizeHttpRequests(auth -> auth .requestMatchers(HttpMethod.POST,"/auth/login").permitAll() // 对登录接口允许匿名访问 .requestMatchers(HttpMethod.POST,"/user/add").permitAll() // 对登录接口允许匿名访问 .requestMatchers(HttpMethod.OPTIONS).permitAll() // .requestMatchers("**").permitAll() .anyRequest().authenticated()) .exceptionHandling(exception -> exception.authenticationEntryPoint(new MyAuthenticationEntryPoint())) .headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable)) ; return http.build(); } }
DBUserDetailsManager(userMapper)这里我对之前的代码进行了修改,因为配置文件注入ioc比@compoent 优先级一些,所以采用传递参数的形式,并且loaduserByusername 返回我们封装的符合security规范的用户对象
**改造后的DBUserDetailsManage : 回忆这个管理器的作用 主要是实现userdetailsService 实现loadbyusername
@Component @Slf4j @AllArgsConstructor public class DBUserDetailsManager implements UserDetailsManager, UserDetailsPasswordService { private UserMapper userMapper; // 这样就可以按照security的规范来使用用户的管理 @Override public UserDetails updatePassword(UserDetails user, String newPassword) { return null; } @Override public void createUser(UserDetails userDetails) { // 在sql中插入信息 User user = new User(); user.setUsername(userDetails.getUsername()); user.setPassword(userDetails.getPassword()); user.setEnabled(1); userMapper.insert(user); } @Override public void updateUser(UserDetails user) { } @Override public void deleteUser(String username) { } @Override public void changePassword(String oldPassword, String newPassword) { } @Override public boolean userExists(String username) { return false; } //security底层会根据这个方法来对比用户 @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { // 这里用户账户是唯一的 User user = userMapper.selectOne(Wrappers.lambdaQuery().eq(User::getUsername, username)); if (user == null){ throw new UsernameNotFoundException("系统用户不存在"); }else{ // 1表示可用 boolean isenabled = user.getEnabled() == 1; /** * ,任何非零的整数值都会被视为 true,而 0 会被视为 false。 */ log.info("数据库个根据用户名获取用户"+user); //模拟系统权限列表 // Collection authorities = new ArrayList<>(); UserDetail detail = new UserDetail(); detail.setUser(user); return detail; } } }
ProviderManager:并配置了authenticationEventPublisher,这实际上是一个正确的步骤,因为这样可以自定义事件发布器。然而,在这个情况下,可能是由于Spring Security的默认配置,它仍然会发布认证成功和失败的事件,即使你并没有显式地配置。
所以这里添加一个认证成功和失败的处理事件
@Component @AllArgsConstructor @Slf4j public class AuthenticationEvents { @EventListener public void onSuccess(AuthenticationSuccessEvent event) { // 用户信息 UserDetail user = (UserDetail) event.getAuthentication().getPrincipal(); log.info("用户 {} 登录成功", user.getUsername()); } @EventListener public void onFailure(AbstractAuthenticationFailureEvent event) { // 用户名 String username = (String) event.getAuthentication().getPrincipal(); log.info("用户 {} 登录失败", username); } }
过滤器:用于过滤token,然后在上下文对象中存放用户信息,底层也是localthread 和文章中我们自己实现的也是一样的效果
SecurityContextHolder的底层实现是通过ThreadLocal来存储SecurityContext对象的。ThreadLocal是一个线程本地变量,它提供了线程级别的数据隔离,使得每个线程都可以独立地访问自己的数据副本,从而避免了线程安全问题。
/** * OncePerRequestFilter是Spring Security框架提供的一个过滤器基类, * 它确保在一次请求中只被调用一次。这个过滤器可以用来执行一些针对每个请求的操作,例如身份验证、授权、日志记录等。 * * 类标记为@AllArgsConstructor,这意味着在创建该类的实例时,Spring 将通过构造函数注入所有已声明的依赖项(RedisCache和JwtUtil)。在使用构造函数注入时,Spring 使用类型匹配来确定哪些 bean 应该注入到构造函数中。 */ @Slf4j @AllArgsConstructor public class JwtAuthenticationTokenFilter extends OncePerRequestFilter { private final RedisCache redisCache; private final JwtUtil jwtUtil; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { try { String token = request.getHeader("token"); if (tokenNotRequired(request.getRequestURI())) { // 登录接口直接放行 filterChain.doFilter(request, response); return; } if (token == null || token.trim().isEmpty()) { throw new AuthenticationException("需要登录") {}; } String username = jwtUtil.getUsernameFromToken(token); String redisKey = "logintoken:" + username; String jsonString = redisCache.getCacheObject(redisKey); if (jsonString == null || jsonString.trim().isEmpty()) { throw new AuthenticationException("用户登录已过期") {}; } UserDetail userInfo = JSON.parseObject(jsonString, UserDetail.class); UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userInfo, null, userInfo.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(authenticationToken); //设置给上下文对象 filterChain.doFilter(request, response); } //对所有抛出的异常进行处理 catch (AuthenticationException e) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType("application/json; charset=utf-8"); response.getWriter().println(JSON.toJSONString(Result.nologin(e.getMessage()))); } } private boolean tokenNotRequired(String requestURI) { return "/auth/login".equals(requestURI) || "/auth/info".equals(requestURI); } }
过滤器对于没有携带token,或者token不匹配的各种情况做了判断和返回这样的效果和配置中的
.exceptionHandling(exception -> exception.authenticationEntryPoint(new MyAuthenticationEntryPoint()))
是一样的效果
准备工作完成,现在些登录接口
@Autowired JwtUtil jwtUtil; @Autowired RedisCache redisCache; private final AuthenticationManager authenticationManager; @PostMapping("login") public Result login(@RequestBody User uservo) throws ServerException { log.info("接收的参数"+uservo); Authentication authentication; try { // 用户认证 authentication = authenticationManager.authenticate( new UsernamePasswordAuthenticationToken(uservo.getUsername(), uservo.getPassword())); } catch (BadCredentialsException e) { return Result.fail("账户或密码错误"); } log.info("认证通过的信息"+ authentication.getPrincipal()); // 用户信息 UserDetail user = (UserDetail) authentication.getPrincipal(); // 生成 accessToken String token= jwtUtil.createToken(user); //保存到redis 前缀加用户名 redisCache.setCacheObject("logintoken:"+user.getUser().getUsername(), JSON.toJSONString(user),8, TimeUnit.HOURS); return Result.success(token); }
测试
请求头没有携带token字段时
登录获取token
控制台成功输出认证成功的事件,这里可以用来做登录记录保存
携带token 访问我们上文定义好的一个读取上下文对象数据的接口 成功
那么同理登出就是删除reids的数据,并且可以添加登出监听,一样的逻辑不做复述