全网最细致SpringBoot整合Spring Security + JWT实现用户认证

Posted 小灵宝

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了全网最细致SpringBoot整合Spring Security + JWT实现用户认证相关的知识,希望对你有一定的参考价值。

【全网最细致】SpringBoot整合Spring Security + JWT实现用户认证

  登录和用户认证是一个网站最基本的功能,在这篇博客里,将介绍如何用SpringBoot整合Spring Security + JWT实现登录及用户认证

文章目录

前置知识:Session、Cookie与Token

session与cookie

  在一些传统项目中,我们或许会用session来保存用户信息,进行用户认证。而现在基本上都用token来代替session,为什么会出现这样的变化,我们来聊聊session、cookie以及token
  在谈session和cookie前,首先我们来谈谈会话。http本身是无状态协议,服务器无法识别每一次HTTP请求的出处(不知道来自于哪个终端),它只会接受到一个请求信号,所以就存在一个问题:将用户的响应发送给相应的用户,必须有一种技术来让服务器知道请求来自哪,这就是会话技术。
  会话就是客户端和服务器之间发生的一系列连续的请求和响应的过程。会话状态指服务器和浏览器在会话过程中产生的状态信息,借助于会话状态,服务器能够把属于同一次会话的一系列请求和响应关联起来。
  实现会话有两种方式:session和cookie。Session通过在服务器端记录信息确定用户身份,相应的也增加了服务器的存储压力。客户端浏览器访问服务器的时候,服务器把客户端信息以某种形式记录在服务器上,这就是Session。属于同一次会话的请求都有一个相同的标识符,sessionID,客户端浏览器再次访问时只需要通过sessionID从Session中查找该客户的状态就可以了。那么后端是怎么把sessionID返回给客户端的?可以通过设置cookie的方式返回给客户端,若浏览器禁止cookie,则可以通过URL重写的方式发送。
  刚刚提到了cookie,Cookie是服务端在HTTP响应中附带传给浏览器的一个小的文本文件,一旦浏览器保存了某个Cookie,在之后的请求和响应过程中,会将此Cookie来回传递,这样就可以通过Cookie这个载体完成客户端和服务端的数据交互。
  使用Session进行用户认证时,当用户第一次通过浏览器使用用户名和密码访问服务器时,服务器会验证用户数据,验证成功后在服务器端写入session数据,向客户端浏览器返回sessionid,浏览器将sessionid保存在cookie中,当用户再次访问服务器时,会携带sessionid,服务器会拿着sessionid从服务器获取session数据,然后进行用户信息查询,查询到,就会将查询到的用户信息返回,从而实现状态保持,流程如下图:

session的弊端

  • 服务器压力增大
      通常session是存储在内存中的,每个用户通过认证之后都会将session数据保存在服务器的内存中,而当用户量增大时,服务器的压力增大。
  • CSRF跨站伪造请求攻击
      一般session是基于cookie进行用户识别的, cookie如果被截获,用户就会很容易受到跨站请求伪造的攻击。即使不用cookie,用重写url方式发送sessionId,那就更容易被截获信息了
  • 扩展性不强
      想象这么一个场景,若项目在多个服务器上部署,那我再其中一台登录了,称为A,session也保存到A中,万一下次我访问到另外一台服务器B怎么办?B上没有A的session呢?为了解决这个问题,我们需要将session保存到数据库中,所以每次保存这些session信息就是一个负担了,增加了服务器的存储压力。

token

  token的意思是“令牌”,是服务端生成的一串加密字符串(服务器端并不进行保存),作为客户端进行请求的一个标识。当用户第一次登录后,服务器生成一个token并将此token返回给客户端浏览器,以后客户端只需带上这个token前来请求数据即可,无需再次带上用户名和密码。
  浏览器会将接收到的token值存储在Local Storage中,浏览器再次访问时服务器端时,服务器对浏览器传来的token值进行解密,解密完成后进行用户数据的查询,如果查询成功,则通过认证,实现状态保持,所以,即使有了多台服务器,服务器也只是做了token的解密和用户数据的查询,它不需要在服务端去保留用户的认证信息或者会话信息,这就意味着基于token认证机制的应用不需要去考虑用户在哪一台服务器登录了,这就为应用的扩展提供了便利,同时服务器也不需要保存token,token的出现就解决了session的弊端,成为了session的替代品。
  使用token进行用户认证的流程如下图:

session与token的总结

  token 是无状态的,后端不需要记录信息,每次请求过来进行解密就能得到对应信息。
  session 是有状态的,需要后端每次去检索id的有效性。不同的session都需要进行保存,但也可以设置单点登录,减少保存的数据。
  session与token的选择是空间与时间博弈,为什么这么说呢,是因为token不需要保存,不占存储空间,但每次访问都需要进行解密,消耗了一定的时间。
  在一般的前后端分离项目中,token展现出了它的优势,成为了比session更好的选择

JWT

  JWT其实就是一种被广泛使用的token,它的全称是JSON Web Token,它通过数字签名的方式,以JSON对象为载体,在不同的服务终端之间安全地传输信息。
  JWT最常见的使用场景就是授权认证,一旦用户登录,后续每个请求都将包含JWT,系统在每次处理用户请求之前,都要先进行JWT安全校验,通过之后再进行处理。
  JWT由3部分组成,用.拼接,如:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
  这三部分分别是:

  • Header
      Header中保存了令牌类型type和所使用的的加密算法,例如:

  'typ': 'JWT',
  'alg': 'HS256'

  • Payload
      Payload中包含的是请求体和其它一些数据,例如包含了和用户相关的一些信息

  "sub": "1234567890",
  "name": "John Doe",
  "admin": true

  • Signature
      Signature签名属于jwt的第三部分。主要是把头部的base64UrlEncode与负载的base64UrlEncode拼接起来,再进行HMACSHA256加密等最终得到的结果作为签名部分。例如:
// javascript
var encodedString = base64UrlEncode(header) + '.' + base64UrlEncode(payload);

var signature = HMACSHA256(encodedString, 'secret'); // TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

登录及用户认证流程设计

  假设我们要设计这样一个登录功能:用户输入用户名、密码以及图片验证码进行登录,用户认证功能则是对于用户的每次请求,都需要校验用户信息,若不正确,则拒绝请求
  这里不同于最简单的用户名密码登录,加入了图片验证码,验证码是为了防止非正常用户伪造请求进行登录,是一个较为重要的功能。
  根据我们之前对于token和JWT的介绍,我们知道,对于首次登录,浏览器是没有JWT信息的,用户需输入用户名、密码和验证码完成登录,后端对验证码、用户名、密码进行校验后,若校验成功,则返回JWT给前端,完成登录。登录后的每次请求,请求头都将携带Jwt进行身份认证,若认证成功则能访问后端接口
  我们先来看首次登录,后端需要对验证码和用户信息进行校验,我们不妨先校验验证码,再校验用户名密码。
  对于验证码的校验,我们知道一般的前后端分离使用token,不使用session,那么后台必须保证当前用户输入的验证码是用户开始请求页面时候的验证码,必须保证验证码的唯一性。举个例子:
  A用户看到的验证码是:ABC;B用户看到的验证码是:DEF。后台存储了ABC和DEF这2个验证码,如果不限定A用户输入的验证码是ABC,那么当A用户碰巧输入DEF,然后用户名和密码也是正确的话,A用户也是可以登录系统的。
  也就是说,每个用户的请求都需要对应一个唯一的验证码,这一切的麻烦都源于http本身是无状态协议,我们需要保存用户与验证码之间的对应关系,而现在我们又不能使用session,不能用SessionID的字段和验证码对应,那么我们该如何做呢?
  有一种方式是前端生成一个随机数(UUID形式),保存在localstorage里,对应着某个用户,前端带上随机数参数访问后端接口,后端用加密算法加密该随机数rand,生成验证码,即verify_code = f(rand)。当用户提交验证码的时候,之前的随机数一起带过来,后端再通过之前的加密规则验证输入的验证码是否正确。也即构造了随机数和验证码的对应关系
  其实上述做法有点类似于token的做法,不过这种做法有几个问题,一是验证码强行和一个前端给的一串随机数通过一个算法f产生了联系,前端的请求可以随意伪造,随机数参数也可以五花八门,可能会导致一些意想不到的bug发生,验证码应保持随机性和独立性,不应该和一个随机数强行通过函数f关联。二是采用这种方式,后端需要进行两次加密过程生成验证码,会造成不必要的时间开销。
  我们采用另一种方式类似于session的做法来完成验证码校验过程,首先,我们还是得构造随机数(UUID形式,代表着某个用户),它和验证码一一对应。不过这次我们将随机数的生成把握在后端手里,毕竟老话说得好:作为一个后端,不要相信前端传过来的任何参数(手动狗头),把随机数的生成把握在后端手中,这种方式更加安全。我们仿照session的原理,牺牲一部分存储空间,将随机数和对应的验证码作为key-value键值对形式进行存储,然后将生成的随机数返回给前端,前端在登录请求时将该随机数以及用户输入的验证码传给后端,后端就能通过该随机数进行查询,校验输入的验证码和正确验证码是否一致。我们可以引入redis中间件来完成随机数和验证码的存储,因为一个验证码对应一个用户的一次登录过程,所以当验证成功时,我们将redis中存储的验证码和随机码删除,采用这种方式也不会消耗多少存储空间。
  解决了令人头疼的验证码,登录流程就很清晰了,流程如下:

  在首次登录过后,浏览器将保存jwt,在之后的所有请求中(包括再次登录请求),请求头都将携带Jwt进行身份认证,若认证成功则能访问后端接口。
  弄清楚登录及用户认证流程后,接下来我们将使用SpringBoot整合Spring Security + JWT来实现上述流程,在使用Spring Security之前,我们先来看看它的基本原理,要不然用起来会很懵

前置知识:Spring Security

  Spring Security是Spring家族中的安全框架,可以用来做用户验证和权限管理等。Spring Security是一款重型框架,不过功能十分强大。一般来说,如果项目中需要进行权限管理,具有多个角色和多种权限,我们可以使用Spring Security。如果是较为简单的项目,只需要控制一下某些接口只有登录后才能访问,则可以使用Shiro框架,Shiro也是一款安全框架,它是一款轻量级框架,功能没有Spring Security多,但使用起来要简单不少。

  SpringSecurity 采用的是责任链的设计模式,是一堆过滤器链的组合,它有一条很长的过滤器链。 Spring Security 的执行流程图如下所示:


  现在来一一解释每一个过滤器链的功能是什么:

  • 1、WebAsyncManagerIntegrationFilter:
      将 Security 上下文与 Spring Web 中用于处理异步请求映射的 WebAsyncManager 进行集成。

  • 2、SecurityContextPersistenceFilter:
      在每次请求处理之前将该请求相关的安全上下文信息加载到 SecurityContextHolder 中,然后在该次请求处理完成之后,将 SecurityContextHolder 中关于这次请求的信息存储到一个“仓储”中,然后将 SecurityContextHolder 中的信息清除,例如在Session中维护一个用户的安全信息就是这个过滤器处理的。

  • 3、HeaderWriterFilter:
      用于将头信息加入响应中。

  • 4、CsrfFilter:
      用于处理跨站请求伪造。

  • 5、LogoutFilter:
      用于处理退出登录。

  • 6、UsernamePasswordAuthenticationFilter:
      用于处理基于表单的登录请求,从表单中获取用户名和密码。默认情况下处理来自 /login 的请求。从表单中获取用户名和密码时,默认使用的表单 name 值为 username 和 password,这两个值可以通过设置这个过滤器的usernameParameter 和 passwordParameter 两个参数的值进行修改。

  • 7、DefaultLoginPageGeneratingFilter:
      如果没有配置登录页面,那系统初始化时就会配置这个过滤器,并且用于在需要进行登录时生成一个登录表单页面。

  • 8、BasicAuthenticationFilter:
      检测和处理 http basic 认证。

  • 9、RequestCacheAwareFilter:
      用来处理请求的缓存。

  • 10、SecurityContextHolderAwareRequestFilter:
      主要是包装请求对象request。

  • 11、AnonymousAuthenticationFilter:
      检测 SecurityContextHolder 中是否存在 Authentication 对象,如果不存在为其提供一个匿名 Authentication。

  • 12、SessionManagementFilter:
      管理 session 的过滤器

  • 13、ExceptionTranslationFilter:
      处理 AccessDeniedException 和 AuthenticationException 异常。

  • 14、FilterSecurityInterceptor:
      可以看做过滤器链的出口。

  • 15、RememberMeAuthenticationFilter:
      当用户没有登录而直接访问资源时, 从 cookie 里找出用户的信息, 如果 Spring Security 能够识别出用户提供的remember me cookie, 用户将不必填写用户名和密码, 而是直接登录进入系统,该过滤器默认不开启。

  看到Spring Security这么复杂,我们可能已经崩溃了,但其实它并没有看上去那么吓人,因为Spring Security已经对很多过滤器部分提供了默认实现,程序员只需要按照自己项目的需求增加和修改少量代码即可。不过这么做也有个坏处,那就是不懂Spring Security原理的程序员,可能会看不懂代码中的登录逻辑,觉得莫名其妙就进行完用户验证了。

根据自己的项目需求实现SpringSecurity中的部分过滤器

  我们可以根据自己的项目需求来设计一个security的认证方案,结合我们之前提到的登录和认证需求,可以得到这样一个流程:

  需要注意的是,SpringSecurity不提供图片验证码过滤器,因此我们在UsernamePasswordAuthenticationFilter前加入自定义的图片验证码过滤器
  根据上述流程,我们列出需要自己实现的过滤器和处理器等:

  • 1、LogoutSuccessHandler:
      表示登出处理器
  • 2、验证码过滤器Filter
  • 3、登录认证成功、失败处理器
  • 4、BasicAuthenticationFilter:
      该过滤器用于普通http请求进行身份认证
  • 5、AuthenticationEntryPoint:
      表示认证失败处理器
  • 6、AccessDenieHandler:
      用户发起无权限访问请求的处理器
  • 7、UserServiceDatils 接口:
      该接口十分重要,用于从数据库中验证用户名密码
  • 8、PasswordEncoder密码验证器

正式开始整合Spring Security和JWT

  弄清了我们需要实现哪些代码,接下来我们就正式开始整合过程

pom.xml添加相应依赖

  我们需添加Spring Security和JWT依赖,还需添加redis依赖,以及一些工具类,例如hutool,编码工具类,以及google的验证码工具类等:

        <!-- springboot security -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <!-- jwt -->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
            <version>0.9.1</version>
        </dependency>
        <dependency>
            <groupId>com.github.axet</groupId>
            <artifactId>kaptcha</artifactId>
            <version>0.0.9</version>
        </dependency>
        <!-- hutool工具类-->
        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>5.3.3</version>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
            <version>3.11</version>
        </dependency>

        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
        </dependency>
        <dependency>
            <groupId>commons-codec</groupId>
            <artifactId>commons-codec</artifactId>
            <version>1.15</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>

写一个JWT工具类

  我们需要写一个JWT工具类JwtUtils,该工具类需要有3个功能:生成JWT、解析JWT、判断JWT是否过期。直接上代码:

import io.jsonwebtoken.*;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

import java.util.Date;

@Data
@Component
@ConfigurationProperties(prefix = "xiaolinbao.jwt")
public class JwtUtils 

    private long expire;
    private String secret;
    private String header;

    // 生成JWT
    public String generateToken(String username) 

        Date nowDate = new Date();
        Date expireDate = new Date(nowDate.getTime() + 1000 * expire);

        return Jwts.builder()
                .setHeaderParam("typ", "JWT")
                .setSubject(username)
                .setIssuedAt(nowDate)
                .setExpiration(expireDate)    // 7天过期
                .signWith(SignatureAlgorithm.HS512, secret)
                .compact();
    

    // 解析JWT
    public Claims getClaimsByToken(String jwt) 
        try 
            return Jwts.parser()
                    .setSigningKey(secret)
                    .parseClaimsJws(jwt)
                    .getBody();
         catch (Exception e) 
            return null;
        
    

    // 判断JWT是否过期
    public boolean isTokenExpired(Claims claims) 
        return claims.getExpiration().before(new Date());
    


  我们可以配置JWT的有效时间和加密算法所需使用的秘钥,以及返回给前端时在Http response的Header中所叫的名字。这种配置项我们需写入application.yml中,然后使用@ConfigurationProperties注解接收,这样能便于我们日后修改配置。
  使用@ConfigurationProperties注解可以读取配置文件中的信息,只要在 Bean 上添加上了这个注解,指定好配置文件中的前缀,那么对应的配置文件数据就会自动填充到 Bean 的属性中
  application.yml中的配置如下:

xiaolinbao:
  jwt:
    header: Authorization
    expire: 604800 # 7天,s为单位
    secret: abcdefghabcdefghabcdefghabcdefgh

写登录认证成功、失败处理器LoginSuccessHandler、LoginFailureHandler

  登录失败后,我们需要向前端发送错误信息,登录成功后,我们需要生成JWT,并将JWT返回给前端
  我们先定义后端返回给前端的统一封装结果Result:

import lombok.Data;

import java.io.Serializable;

@Data
public class Result implements Serializable 

    private int code;
    private String msg;
    private Object data;

    public static Result succ(Object data) 
        return succ(200, "操作成功", data);
    

    public static Result fail(String msg) 
        return fail(400, msg, null);
    

    public static Result succ (int code, String msg, Object data) 
        Result result = new Result();
        result.setCode(code);
        result.setMsg(msg);
        result.setData(data);
        return result;
    

    public static Result fail (int code, String msg, Object data) 
        Result result = new Result();
        result.setCode(code);
        result.setMsg(msg);
        result.setData(data);
        return result;
    

  为什么要定义这个,以及为什么这么写,可以看我的这篇博客:SpringBoot + Vue前后端分离开发:全局异常处理及统一结果封装

  接下来我们来写LoginSuccessHandler、LoginFailureHandler,直接上代码:

@Component
public class LoginSuccessHandler implements AuthenticationSuccessHandler 

    @Autowired
    JwtUtils jwtUtils;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException 
        httpServletResponse.setContentType("application/json;charset=UTF-8");
        ServletOutputStream outputStream = httpServletResponse.getOutputStream();

        // 生成JWT,并放置到请求头中
        String jwt = jwtUtils.generateToken(authentication.getName());
        httpServletResponse.setHeader(jwtUtils.getHeader(), jwt);

        Result result = Result.succ("SuccessLogin");

        outputStream.write(JSONUtil.toJsonStr(result).getBytes(StandardCharsets.UTF_8));
        outputStream.flush();
        outputStream.close();
    

@Component
public class LoginFailureHandler implements AuthenticationFailureHandler 

    @Override
    public void onAuthenticationFailure(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException 
        httpServletResponse.setContentType("application/json;charset=UTF-8");
        ServletOutputStream outputStream = httpServletResponse.getOutputStream();

        String errorMessage = "用户名或密码错误";
        Result result;
        if (e instanceof CaptchaException) 
            errorMessage = "验证码错误";
            result = Result.fail(errorMessage);
         else 
            result = Result.fail(errorMessage);
        
        outputStream.write(JSONUtil.toJsonStr(result).getBytes(StandardCharsets.UTF_8));
        outputStream.flush();
        outputStream.close();
    

  LoginSuccessHandler、LoginFailureHandler分别需要实现AuthenticationSuccessHandler和AuthenticationFailureHandler接口,需分别重写接口的onAuthenticationSuccess、onAuthenticationFailure方法,onAuthenticationSuccess方法的参数为HttpServletRequest、HttpServletResponse以及Authentication,onAuthenticationFailure方法的第三个参数与其不同,是AuthenticationException,表示登录失败对应的异常

  onAuthenticationFailure方法用于向前端返回错误信息,登录失败有可能是用户名密码错误,有可能是验证码错误,这里我们自定义了验证码错误的异常,它继承了Spring Security的AuthenticationException:

public class CaptchaException extends AuthenticationException 

    public CaptchaException(String msg) 
        super(msg);
    

  SpringSecurity中的接口Authentication继承了接口Principal,Principal接口表示主体的抽象概念,可用于表示任何实体,例如个人、公司和登录 ID,一般用来表示用户认证相关信息,调用其getName方法可以获得用户名

写RedisUtil工具类以及验证码配置

  RedisUtil工具类没什么好说的,网上有很多现成的

  验证码生成使用的是谷歌的验证码工具类,配置类如下:

@Configuration
public class KaptchaConfig 

    @Bean
    DefaultKaptcha producer() 
        Properties properties = new Properties();
        properties.put("kaptcha.border", "no");
        properties.put("kaptcha.textproducer.font.color", "black");
        properties.put("kaptcha.textproducer.char.space", "4");
        properties.put("kaptcha.image.height", "40");
        pro

深入总结SpringBoot整合JWT,这应该是全网讲的最通俗易懂的了

前言

我们都知道JWT是一个JSON信息传输的开放标准,它可以使用密钥对信息进行数字签名,以确保信息是可验证和可信任的。今天我们来深入总结SpringBoot整合JWT

举例登录过程

image

认证流程

  1. 首先,前端通过Web表单将自己的用户名和密码发送到后端的接口。这一过程一般是一个HTTP POST请求。建议的方式是通过SSL加密的传输(https协议) ,从而避免敏感信息被嗅探。
  2. 后端核对用户名和密码成功后,将用户的id等其他信息作为JWT Payload (负载) ,将其与头部分别进行Base64编码拼接后签名,形成一个JWT(Token)。形成的JWT就是一 个形同111.zzz . xxx的字符串。 token head. payload . singurater
  3. 后端将JWT字符串作为登录成功的返回结果返回给前端。前端可以将返回的结果保存在localStor age或sessionStorage上,退出登录时前端删除保存的JWT即可。
  4. 前端在每次请求时将JWT放入HTTP Header中的Authorization位。 (解决XSS和XSRF问题) HEADER
  5. 后端检查是否存在,如存在验证JWT的有效性。例如,检查签名是否正确;检查Token是否过期;检查Token的接收方是否是自己(可选)
  6. 验证通过后后端使用JWT中包含的用户信息进行其他逻辑操作,返回相应结果。

jwt优势

  • 不需要在服务端保存会话信息,特别适用于分布式微服务。
  • 自包含(Self-contained):负载中包含了所有用户所需要的信息,避免了多次查询数据库
  • 简洁(Compact):可以通过URL, POST参数或者在HTTP header发送,因为数据量小,传输速度也很快
  • 因为Token是以JSON加密的形式保存在客户端的,所以JWT是跨语言的,原则上任何web形式都支持。

组成

JWT具体长什么样呢? JWT是由三段信息构成的,将这三段信息文本用.链接在一起就构成了JWT字符串。就像这样:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

结构

JWT的头部承载两部分信息:

  • 声明类型,这里是JWT;
  • 声明加密的算法,通常直接使用 HMAC SHA256;
  • 完整的头部就像下面这样的JSON:
{
  \'typ\': \'JWT\',
  \'alg\': \'HS256\'
}

使用base64加密,构成了第一部分。

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9

playload(重点)

载荷就是存放有效信息的地方,这些有效信息包含三个部分:

  • 标准中注册的声明;
  • 公共的声明;
  • 私有的声明;

其中,标准中注册的声明 (建议但不强制使用)包括如下几个部分 :

  • iss: jwt签发者;
  • sub: jwt所面向的用户;
  • aud: 接收jwt的一方;
  • exp: jwt的过期时间,这个过期时间必须要大于签发时间;
  • nbf: 定义在什么时间之前,该jwt都是不可用的;
  • iat: jwt的签发时间;
  • jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击;

公共的声明部分:公共的声明可以添加任何的信息,一般添加用户的相关信息或其他业务需要的必要信息,但不建议添加敏感信息,因为该部分在客户端可解密。

私有的声明部分:私有声明是提供者和消费者所共同定义的声明,一般不建议存放敏感信息,因为base64是对称解密的,意味着该部分信息可以归类为明文信息。

定义一个payload:

{
  "sub": "1234567890",
  "name": "John Doe",
  "admin": true
}

然后将其进行base64加密,得到Jwt的第二部分:

eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9

signature

jwt的第三部分是一个签证信息,这个签证信息由三部分组成:

var encodedString = base64UrlEncode(header) + \'.\' + base64UrlEncode(payload);
 
 
var signature = HMACSHA256(encodedString, \'密钥\');
加密之后,得到signature签名信息。

TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

jwt最终格式

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

secret用来进行jwt的签发和jwt的验证,所以,在任何场景都不应该流露出去。

SpringBoot整合JWT【正片】

引入依赖

<!--token-->
    <!-- jwt支持 -->
    <dependency>
        <groupId>com.auth0</groupId>
        <artifactId>java-jwt</artifactId>
    </dependency>

创建JWT工具类

注意静态属性的配置文件注入方式:

package com.neuq.common.util;

import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.TokenExpiredException;
import com.neuq.common.exception.ApiException;
import com.neuq.common.response.ResultInfo;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

import java.util.Date;

/**
 * @Author: xiang
 * @Date: 2021/5/11 21:11
 * <p>
 * JwtToken生成的工具类
 * JWT token的格式:header.payload.signature
 * header的格式(算法、token的类型),默认:{"alg": "HS512","typ": "JWT"}
 * payload的格式 设置:(用户信息、创建时间、生成时间)
 * signature的生成算法:
 * HMACSHA512(base64UrlEncode(header) + "." +base64UrlEncode(payload),secret)
 */

@Component
@ConfigurationProperties(prefix = "jwt")
public class JWTUtils {

    //定义token返回头部
    public static String header;

    //token前缀
    public static String tokenPrefix;

    //签名密钥
    public static String secret;

    //有效期
    public static long expireTime;

    //存进客户端的token的key名
    public static final String USER_LOGIN_TOKEN = "USER_LOGIN_TOKEN";

    public void setHeader(String header) {
        JWTUtils.header = header;
    }

    public void setTokenPrefix(String tokenPrefix) {
        JWTUtils.tokenPrefix = tokenPrefix;
    }

    public void setSecret(String secret) {
        JWTUtils.secret = secret;
    }

    public void setExpireTime(int expireTimeInt) {
        JWTUtils.expireTime = expireTimeInt*1000L*60;
    }

    /**
     * 创建TOKEN
     * @param sub
     * @return
     */
    public static String createToken(String sub){
        return tokenPrefix + JWT.create()
                .withSubject(sub)
                .withExpiresAt(new Date(System.currentTimeMillis() + expireTime))
                .sign(Algorithm.HMAC512(secret));
    }


    /**
     * 验证token
     * @param token
     */
    public static String validateToken(String token){
        try {
            return JWT.require(Algorithm.HMAC512(secret))
                    .build()
                    .verify(token.replace(tokenPrefix, ""))
                    .getSubject();
        } catch (TokenExpiredException e){
            throw new ApiException(ResultInfo.unauthorized("token已经过期"));
        } catch (Exception e){
            throw new ApiException(ResultInfo.unauthorized("token验证失败"));
        }
    }

    /**
     * 检查token是否需要更新
     * @param token
     * @return
     */
    public static boolean isNeedUpdate(String token){
        //获取token过期时间
        Date expiresAt = null;
        try {
            expiresAt = JWT.require(Algorithm.HMAC512(secret))
                    .build()
                    .verify(token.replace(tokenPrefix, ""))
                    .getExpiresAt();
        } catch (TokenExpiredException e){
            return true;
        } catch (Exception e){
            throw new ApiException(ResultInfo.unauthorized("token验证失败"));
        }
        //如果剩余过期时间少于过期时常的一般时 需要更新
        return (expiresAt.getTime()-System.currentTimeMillis()) < (expireTime>>1);
    }
}

yaml属性配置

jwt:
  header: "Authorization" #token返回头部
  tokenPrefix: "Bearer " #token前缀
  secret: "qwertyuiop7418520" #密钥
  expireTime: 1 #token有效时间 (分钟) 建议一小时以上

登录方法将用户信息存入token中返回

  @Override
  public Map<String,Object> login(User user) {
      //phone是除id外的唯一标志 需要进行检查
      if (user.getPhone() == null || user.getPhone().equals(""))
          throw new ApiException("手机号不合法");
      User selectUser = userDao.selectUserByPhone(user.getPhone());
      if (selectUser == null) {
          //注册用户
          int count = userDao.insertUser(user);
          if (count < 1) throw new ApiException(ResultInfo.serviceUnavailable("注册异常"));
      }
      //将userId存入token中
      String token = JWTUtils.createToken(selectUser.getUserId().toString());
      Map<String,Object> map = new HashMap<>();
      map.put("user",selectUser);
      map.put("token",token);
      return map;
  }

注意将token保存到Http 的 header

    @GetMapping("/login")
    public ResultInfo login(User user, HttpServletResponse response) {
        Map<String, Object> map = userService.login(user);
        //将token存入Http的header中
        response.setHeader(JWTUtils.USER_LOGIN_TOKEN, (String) map.get("token"));
        return ResultInfo.success((User)map.get("user"));
    }

拦截器验证每次请求的token

/**
 * @Author: xiang
 * @Date: 2021/5/7 20:56
 * <p>
 * 拦截器:验证用户是否登录
 */
public class UserLoginInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //http的header中获得token
        String token = request.getHeader(JWTUtils.USER_LOGIN_TOKEN);
        //token不存在
        if (token == null || token.equals("")) throw new ApiException("请先登录");
        //验证token
        String sub = JWTUtils.validateToken(token);
        if (sub == null || sub.equals(""))
            throw new ApiException(ResultInfo.unauthorized("token验证失败"));
        //更新token有效时间 (如果需要更新其实就是产生一个新的token)
        if (JWTUtils.isNeedUpdate(token)){
            String newToken = JWTUtils.createToken(sub);
            response.setHeader(JWTUtils.USER_LOGIN_TOKEN,newToken);
        }
        return true;
    }
}
@Configuration
@ComponentScan(basePackages = "com.neuq.common") //全局异常处理类需要被扫描才能
public class WebMvcConfig implements WebMvcConfigurer {
    /**
     * 注册自定义拦截器
     *
     * @param registry
     */
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new UserLoginInterceptor())
                .addPathPatterns("/user/**")
                .addPathPatterns("/userInfo/**")
                .excludePathPatterns("/user/login");//开放登录路径
    }

}

单点登录

将token或者一个唯一标识UUID=UUID.randomUUID().toString()存进Cookie中(别存在Http的header中了),设置路径为整个项目根路径/*; 往往以这个唯一标识为key,用户信息为value缓存在服务器中!!!

最后

对一个 Java 程序员而言,并发编程能否熟练掌握是判断他是不是优秀的重要标准之一。因为并发编程在 Java 语言中最为晦涩的知识点,它涉及内存、CPU、操作系统、编程语言等多方面的基础能力,更加考验一个程序员的内功深厚程度。

特别是当大数据时代的来临,高并发更加成为了家常便饭,在工作中,我们总是绕不开并发编程的任务。比如说,你想写个程序,一边从文件中读取数据,一边还要做实时计算…所以,想成为一名资深的 Java 后端工程师,并发编程是必须要牢牢把握的。那我们到底应该如何深入学习Java并发编程呢?

推荐一篇文章:作为一名双非本科毕业的Java程序员,我该如何在日益严重的内卷化中避免被裁?

以上是关于全网最细致SpringBoot整合Spring Security + JWT实现用户认证的主要内容,如果未能解决你的问题,请参考以下文章

深入总结SpringBoot整合JWT,这应该是全网讲的最通俗易懂的了

秒懂SpringBoot之全网最易懂的Spring Security教程

最细致的Spring Boot结合Vue前后端分离项目打包部署步骤(搭配Nginx)

最细致的Spring Boot结合Vue前后端分离项目打包部署步骤(搭配Nginx)

SpringBoot+Mybatis-Plus整合Sharding-JDBC5.1.1实现单库分表全网最新

SpringBoot2.0之四 简单整合MyBatis