【SpringBoot】1、SpringBoot整合JWT实现Token验证
作者:mmseoamin日期:2023-12-25

这里写目录标题

  • 1.单点登录
    • 1.1 单系统登录
      • 1.1.1 单系统登录流程(使用Session实现单系统登录)
      • 1.2 多系统(单点)登录
        • 1.2.1 单点登录实现方案
          • 1.2.1.1 Session跨域
          • 1.2.1.2 Spring Session共享
          • 1.3 Token机制
            • 1.3.1 传统身份认证
            • 1.3.2 基于Token的身份认证
            • 1.4 JWT机制
              • 1.4.1 JWT数据结构
                • 1.4.1.1 header
                • 1.4.1.2 payload
                • 1.4.1.3 signature
                • 1.4.2 JWT执行流程
                • 1.4.3 JWT代码案例

                  1.单点登录

                  单点登录(Single Sign On), 简称为SSO, 是目前比较流行的企业业务整合的解决方案之一.

                  SSO的定义:在多个应用系统中, 用户只需要登录一次就可以访问所有相互信任的应用系统, 企业间需要相互授信


                  1.1 单系统登录

                  众所皆知, HTTP是无状态的协议, 这意味着服务器无法确认用户的信息。 于是乎,W3C就提出了:给每一个用户都发一个通行证,无论谁访问的时候都需要携带通行证,这样服务器就可以从通行证上确认用户的信息。通行证就是Cookie。

                  如果说Cookie是检查用户身上的”通行证“来确认用户的身份,那么Session就是通过检查服务器上的”客户明细表“来确认用户的身份的。Session相当于在服务器中建立了一份“客户明细表”。

                  【SpringBoot】1、SpringBoot整合JWT实现Token验证,在这里插入图片描述,第1张


                  1.1.1 单系统登录流程(使用Session实现单系统登录)

                  一、登录

                  用户登录成功后, 通过request获取Session(本质是根据Cookie中携带的JSESSIONID寻找Session)

                  【SpringBoot】1、SpringBoot整合JWT实现Token验证,在这里插入图片描述,第2张

                  • 如果没有携带JSESSIONID或者JSESSIONID找不到Session, 说明用户未登录, 此时将用户信息保存到Session对象中(默认会以Cookie方式向客户端返回JSESSIONID, 但此JSESSIONID是会话级别的)
                  • 如果Session能找到, 说明当前用户已经登录

                    二、记住我(关闭掉浏览器后,重新打开浏览器还能保持登录状态)

                    因为默认返回的JSESSIONID是会话级别的, 我们可以手动为Cookie中添加JSESSIONID信息,设置Cookie的过期时间, 此时不管你的浏览器是否关闭,Cookie中都会携带JSESSION信息

                    //登录成功后,手动添加cookie,保存JSESSIONID信息
                    Cookie cookie = new Cookie("JSESSIONID", session.getId());
                    //300年后过期(永久有效)
                    cookie.setMaxAge(60 * 60 * 24 * 30 * 12 * 300); //设置cookie 和 session生命周期同步.
                    response.addCookie(cookie);
                    

                    三、注销(退出登录):从Session中删除用户的信息

                     session.removeAttribute("user");
                    

                    1.2 多系统(单点)登录

                    多系统、单一位置登录, 实现多系统同时登录的一种技术


                    1.2.1 单点登录实现方案

                    • Session跨域
                    • Spring Session共享
                    • Toekn机制(主要方案)


                      1.2.1.1 Session跨域

                      所谓的Session跨域就是摒弃了系统提供的Session. 而使用自定义的类似Session的机制来保存客户端数据的一种方案

                      如:通过设置cookie的domain来实现cookie的跨域传递。在cookie中传递一个自定义的session_id。这个session_id是客户端的唯一标记。将这个标记作为key,将客户端需要保存的数据作为value,在服务端进行保存(数据库保存或NoSQL保存)。这种机制就是Session的跨域解决。

                      一、什么是跨域

                      客户端请求的时候, 请求的服务器, 不是同一个ip、端口、域名、主机名的时候, 都称为跨域

                      二、什么是域

                      在应用模型中, 一个完整的有独立访问路径的功能集合称为一个域

                      三、Session跨域可以解决的两个问题

                      • 同一个域名下, 不同的服务实例共享Session,
                      • 不同的域名共享Session, 如: 百度的若干域名, 搜索引擎(www.baidu.com), 百度贴吧(tie.baidu.com), 百度知道(zhidao.baidu.com), 百度地图(map.baidu.com)等

                        四、Session跨域的实现流程

                        1. 用户登录成功后, 判断是否携带CookieName, 此处的CookieName作用等同于系统提供的JSESSIONID,我们可以将CookieName自定义为任何值,它在所有的Cookie中起唯一标识的作用(标识这个key-value是用来实现SSO的), 然后用过CookieName获取value
                        2. 我们可以将这个value理解为sessionId, 之前系统默认根据这个sessionId寻找session, 如果session不存在, 则代表没有登录过, 如果存在则代表已经登录. 我们现在拿这个value去持久化库(mysql或nosql)中匹配
                          • 如果通过value找到数据, 则代表已登录, 刷新redis中这条数据的生命, 重新设置Cookie
                          • 如果通过value找不到数据, 则代表首次登录
                            • 1.首先将value和用户信息以hash的形式存储到redis中, 并设置生命(30分钟)
                            • 2.重新设置Cookie(名称和value不变, 主要设置domain这个值, 将domain设置为.baidu.com, 这样就能保证这个cookie会发送到www.baidu.com、tie.baidu.com等等

                        代码实现:

                        • 登录部分
                          @Controller
                          @RequestMapping("sso")
                          public class UserController {
                              @Autowired
                              private RedisTemplate redisTemplate;
                              private static final String COOKIE_NAME = "sso_session_id";
                              @GetMapping("login")
                              @ResponseBody
                              public String login(@RequestParam("un") String userName, @RequestParam("pw") String password,
                                                  HttpServletRequest request, HttpServletResponse response) {
                                  String token = getToken(userName, password);
                                  if (StringUtils.isNotEmpty(token)) {
                                      // 登录成功, 将token存入Cookie
                                      setCookie(request, response, COOKIE_NAME, token);
                                      return "success";
                                  }
                                  // 没有token, 代表用户名或密码错误
                                  return "error";
                              }
                              // 获取token(类似于JSESSIONID)
                              private String getToken(String userName, String password) {
                                  if (userName.equals("wxf") && password.equals("123456")) {
                                      // 登录成功
                                      // 步骤一:生成token(此处的token的作用类似于JSESSIONID)
                                      String token = UUID.randomUUID().toString();
                                      // 步骤二:将用户信息保存到redis中
                                      User user = new User(userName, password);
                                      redisTemplate.opsForValue().set(token, JSON.toJSONString(user), 1800);
                                      return token;
                                  }
                                  // 登录失败
                                  return null;
                              }
                          	
                          	/**
                          	* 设置cookie
                          	*/
                              private void setCookie(HttpServletRequest request, HttpServletResponse response, String cookieName, String cookieValue) {
                                  // 创建cookie
                                  Cookie cookie = new Cookie(cookieName, cookieValue);
                                  if (null != request) {
                                      // 分析解析域名
                                      String domainName = getDomainName(request);
                                      // 设置域名的cookie
                                      cookie.setDomain(domainName);
                                  }
                                  // 从根路径下的后面任意路径地址,cookie都有效
                                  cookie.setPath("/");
                                  // response响应写入到客户端即可
                                  response.addCookie(cookie);
                              }
                          	
                          	/**
                          	* 根据url来设置domain
                          	*/
                              private static final String getDomainName(HttpServletRequest request) {
                                  // 定义一个变量domainName
                                  String domainName = null;
                                  // 获取完整的请求URL地址。请求url,转换为字符串类型
                                  String serverName = request.getRequestURL().toString();
                                  // 判断如果请求url地址为空或者为null
                                  if (serverName == null || serverName.equals("")) {
                                      domainName = "";
                                  } else {
                                      // 不为空或者不为null,将域名转换为小写。域名不敏感的。大小写一样
                                      serverName = serverName.toLowerCase();
                                      // 判断开始如果以http://开头的
                                      if (serverName.startsWith("http://")) {
                                          // 截取前七位字符
                                          serverName = serverName.substring(7);
                                      } else if (serverName.startsWith("https://")) {
                                          // 否则如果开始以https://开始的。//截取前八位字符
                                          serverName = serverName.substring(8);
                                      }
                                      // 找到/开始的位置,可以判断end的值是否为-1,如果为-1的话说明没有这个值
                                      // 如果存在这个值,找到这个值的位置,否则返回值为-1
                                      final int end = serverName.indexOf("/");
                                      // .test.com www.test.com.cn/sso.test.com.cn/.test.com.cn spring.io/xxxx/xxx
                                      // 然后截取到0开始到/的位置
                                      if (end != -1) {
                                          // end等于-1。说明没有/。否则end不等于-1说明有这个值
                                          serverName = serverName.substring(0, end);
                                          // 这是将\\.是转义为.。然后进行分割操作。
                                          final String[] domains = serverName.split("\\.");
                                          // 获取到长度
                                          int len = domains.length;
                                          // 注意,如果是两截域名,一般没有二级域名。比如spring.io/xxx/xxx,都是在spring.io后面/拼接的
                                          // 如果是三截域名,都是以第一截为核心分割的。
                                          // 如果是两截的就保留。三截以及多截的就
                                          // 多截进行匹配域名规则,第一个相当于写*,然后加.拼接后面的域名地址
                                          if (len > 3) {
                                              // 如果是大于等于3截的,去掉第一个点之前的。留下剩下的域名
                                              domainName = "." + domains[len - 3] + "." + domains[len - 2] + "." + domains[len - 1];
                                          } else if (len <= 3 && len > 1) {
                                              // 如果是2截和3截保留
                                              domainName = "." + domains[len - 2] + "." + domains[len - 1];
                                          } else {
                                              domainName = serverName;
                                          }
                                      }
                                  }
                                  // 如果域名不为空并且有这个:
                                  if (domainName != null && domainName.indexOf(":") > 0) {
                                      // 将:转义。去掉端口port号,cookie(cookie的domainName)和端口无关。只看域名的。
                                      String[] ary = domainName.split("\\:");
                                      domainName = ary[0];
                                  }
                                  // 返回域名
                                  System.out.println("==============================================" + domainName);
                                  return domainName;
                              }
                          }
                          
                          • 校验登录

                            我们在拦截器中, 根据请求携带的Cookie校验用户是否登录

                            1.2.1.2 Spring Session共享

                            Spring-Session技术是Spring提供的用于处理集群会话共享的解决方案. Spring-Session技术是将用户Session数据保存到第三方存储容器中, 例如Mysql、redis等

                            Spring-Session技术是解决同域名下多服务器集群session共享问题的, 不能解决跨域名Session共享问题

                            利用spring session解决共享Session问题

                            SpringBoot+SpringSession


                            1.3 Token机制

                            1.3.1 传统身份认证

                            HTTP是一种没有状态的协议,也就是它并不知道是谁在访问应用,这里我们把用户看成是客户端,客户端使用用户名还有密码通过了身份验证,不过下回这个客户端再发送请求时,还得再校验一次。这明显是不合适的,如果客户每次访问应用都需要登录验证、登录验证…客户体验会非常差

                            解决方式:当客户请求登录的时候,如果没有问题,我们在服务端生成一条记录,这个记录里可以说明一下登录的用户是谁,然后把这条记录的ID编号发给客户端,客户端收到以后将ID编号存储在Cookie中,下次这个用户再向服务端发送请求的时候,可以携带这个Cookie,这样服务端就会验证这个Cookie里的信息,看看能不能在服务端这里找到对应的记录,如果可以,说明用户已经通过了身份验证,就把用户请求的数据返回给客户端。

                            上面说的就是HTTP Session,我们需要在服务端存储为登录的用户生成的Session,这些Session可能会存储在内存、磁盘、或者数据库里。我们可能需要在服务端定期的去清理过期的Session

                            这种认证出现的问题是:

                            • 内存开销问题:每次认证用户发起请求时,服务器需要去创建一个记录来存储信息。当越来越多的用户发请求时,内存的开销也会不断增加。
                            • 可扩展性:在服务器的内存中使用Session存储用户信息,伴随而来的是可扩展性问题。

                              1.3.2 基于Token的身份认证

                              使用基于Token的身份认证方式, 在服务端不需要存储用户的登录记录, 大概的流程是这样的:

                              1. 客户端使用用户名、密码请求登录
                              2. 服务端收到请求, 验证用户名、密码
                              3. 验证成功后, 服务端会签发一个Token, 再把这个Token发送给客户端
                              4. 客户端收到Token以后可以把它存储起来, 比如放在Cookie里或者Local Storage里
                              5. 客户端每次向服务端请求资源时携带服务端签发的Token
                              6. 服务器收到请求, 然后去验证客户端请求里面带着的Token, 如果验证成功, 就向客户端返回请求的数据

                              使用Token的优势:

                              • 无状态、可扩展

                                在客户端存储的Tokens是无状态的, 并且能够被扩展. 基于这种无状态和不存储Session信息, 负载均衡器能够将用户信息从一个服务器传到其他服务器上

                              • 安全性

                                请求中发送Token而不再是发送Cookie能够防止CSRF(跨站请求伪造). 即使在客户端使用Cokkie存储Token, Cookie也仅仅是一个存储机制而不是用于认证, 不将信息存储在Session中, 让我们少了对Session的操作


                                1.4 JWT机制

                                JWT(JSON Web Token)

                                JWT是一种紧凑、自包含的, 用于在多方传递JSON对象的技术. 传递的数据可以使用数字签名增加其安全性. 可以使用HMAC加密算法或RSA公钥/私钥加密方式

                                • 紧凑:数据小, 可以通过URL, POST参数, 请求头发送. 且数据小代表传输速度快
                                • 自包含:使用payload数据块记录用户必要且不隐私的数据, 可以有效的减少数据库访问次数, 提高代码能力

                                  JWT是一般用于处理用户身份验证、数据信息交换

                                  • 用户身份验证:一旦用户登录, 每个后续请求都将包含JWT, 允许用户访问该令牌允许的路由、服务和资源. 单点登录是当今广泛使用JWT的一项功能, 因为它的开销很小, 能够轻松的跨域使用
                                  • 数据信息交换:JWT是一种非常方便的多方传递数据的载体, 因为其可以使用数据签名来保证数据的有效性和安全性

                                    1.4.1 JWT数据结构

                                    JWT的数据结构是:A.B.C

                                    由字符点"."来分隔三部分数据

                                    • A:header 头信息
                                    • B:payload 有效荷载
                                    • C:Signature 签名
                                      1.4.1.1 header

                                      数据结构:

                                      {"alg":"加密算法名称", "typ": "JWT"}
                                      
                                      • alg是加密算法定义内容, 例如HMAC、SHA256、RSA
                                      • typ是token类型, 这里固定为JWT
                                        1.4.1.2 payload

                                        在payload数据块中一般用于记录实体(通常为用户信息)或其他数据的, 主要分为三个部分. 分别是

                                        • 已注册信息(registered claims).
                                        • 公开数据(public claims)
                                        • 私有数据(private claims)

                                          已注册信息(最重要的数据), 包括: iss(发行者), exp(到期时间), sub(主题), aud(受众)等…

                                          公开数据部分一般都会在JWT注册表中增加定义, 避免和已注册信息冲突

                                          公开数据和私有数据可以由程序员任意定义

                                          1.4.1.3 signature

                                          签名信息, 这是一个由开发者提供的信息, 是服务器验证的传递的数据是否有效安全的标准, 在生成JWT最终数据之前, 先使用header中定义的加密算法, 将header和payload进行加密, 并使用点进行连接, 如:加密后的head.加密后的payload, 再使用相同的加密算法, 对加密后的数据和签名信息进行加密, 得到最终结果

                                          1.4.2 JWT执行流程

                                          【SpringBoot】1、SpringBoot整合JWT实现Token验证,在这里插入图片描述,第3张

                                          1.客户端(browser)发起post请求, 请求的路径为/users/login,需要提供用户名和密码

                                          2.服务端(server)对用户名和密码校验, 校验通过后, 会生成一个JWT的加密字符串(secret), 这个字符串就是token

                                          3.把JWT返回给客户端浏览器(browser), 浏览器将JWT存储起来

                                          4.后期, 客户端(browser)再发起请求, 需要在请求头里加上JWT(建议是将JWT放header中)

                                          5.服务端(server)校验JWT, 判断是否登录

                                          1.4.3 JWT代码案例