目录

JAVA后端实现JWT令牌

JAVA后端实现JWT令牌

首先解释一下JWT,在此之前,我们需要明确为什么需要JWT

登陆校验

其实最常见的应用场景就是登陆校验,我们希望某个用户在初次打开网址时,首先应该进行登陆操作,而不是直接访问某个功能,并且在登陆之后,访问其他功能时不需要再次登陆。在此前提下,应运而生三种登陆校验方式,下面我们一一讲解。

会话跟踪

什么是会话,我们摒弃其他科普词条繁杂的专业词汇介绍,我们就这样认为:你去买东西,从你走进了一家商店开始,你就开始了你和“商店”的会话,你买完东西走出商店,此次会话结束。在这里面,“你”就是浏览器,“商店”就是服务器。我们买东西,不可能每买一个,都需要重新进入商店(登录),也不可能不进入商店,就可以买东西(举例,以线下商店为例)。

接下来我们正式讲解什么是会话跟踪,词条解释是这样的:一种维护浏览器状态的方法,服务器需要识别多次请求是否来自于同一浏览器,以便在同一次会话的多次请求间共享数据。

为什么我们需要会话跟踪,这是因为HTTP协议是一种无状态的协议。所谓无状态,指的是每一次请求都是独立的,下一次请求并不会携带上一次请求的数据。而浏览器与服务器之间进行交互,基于HTTP协议也就意味着现在我们通过浏览器来访问了登陆这个接口,实现了登陆的操作,接下来我们在执行其他业务操作时,服务器也并不知道这个员工到底登陆了没有。因为HTTP协议是无状态的,两次请求之间是独立的,所以是无法判断这个员工到底登陆了没有。因此我们需要会话跟踪,去“记录”交互操作。

https://i-blog.csdnimg.cn/blog_migrate/5a197bb582585b1674b3cc63150d8845.png

我们有三种会话跟踪技术,分别是:cookie、session、令牌(只需了解jwt的可以跳过前两项介绍)

cookie 是客户端会话跟踪技术,它是存储在客户端浏览器的,我们使用 cookie 来跟踪会话,我们就可以在浏览器第一次发起请求来请求服务器的时候,我们在服务器端来设置一个cookie。

https://i-blog.csdnimg.cn/blog_migrate/4e27edd8154d9f96a2d11aade28d12ab.png

比如第一次请求了登录接口,登录接口执行完成之后,我们就可以设置一个cookie,在 cookie 当中我们就可以来存储用户相关的一些数据信息。比如我可以在 cookie 当中来存储当前登录用户的用户名,用户的ID。

服务器端在给客户端在响应数据的时候,会自动的将 cookie 响应给浏览器,浏览器接收到响应回来的 cookie 之后,会自动的将 cookie 的值存储在浏览器本地。接下来在后续的每一次请求当中,都会将浏览器本地所存储的 cookie 自动地携带到服务端。

切记,以上三步都是自动进行。那么为什么会自动进行?

因为 cookie 它是 HTP 协议当中所支持的技术,而各大浏览器厂商都支持了这一标准。在 HTTP 协议官方给我们提供了一个响应头和请求头:

  • 响应头 Set-Cookie :设置Cookie数据的
  • 请求头 Cookie:携带Cookie数据的

https://i-blog.csdnimg.cn/blog_migrate/0bf7d7e4e955736988f10dea62fa96d5.png

接下来在服务端我们就可以获取到 cookie 的值。我们可以去判断一下这个 cookie 的值是否存在,如果不存在这个cookie,就说明客户端之前是没有访问登录接口的;如果存在 cookie 的值,就说明客户端之前已经登录完成了。这样我们就可以基于 cookie 在同一次会话的不同请求之间来共享数据。

我们使用代码实现:

@Slf4j
@RestController
public class SessionController {

    //设置Cookie
    @GetMapping("/c1")
    public Result cookie1(HttpServletResponse response){
        response.addCookie(new Cookie("login_username","itheima")); //设置Cookie/响应Cookie
        return Result.success();
    }

    //获取Cookie
    @GetMapping("/c2")
    public Result cookie2(HttpServletRequest request){
        Cookie[] cookies = request.getCookies();
        for (Cookie cookie : cookies) {
            if(cookie.getName().equals("login_username")){
                System.out.println("login_username: "+cookie.getValue()); //输出name为login_username的cookie
            }
        }
        return Result.success();
    }

} 

访问 c1 接口,设置 Cookie,

https://i-blog.csdnimg.cn/blog_migrate/ef0a8baeb447fdf8ad4227022b5f2432.png

我们可以看到,设置的 cookie,通过 响应头 Set-Cookie 响应给浏览器,并且浏览器会将 Cookie,存储在浏览器端。

https://i-blog.csdnimg.cn/blog_migrate/c94d503bd2e27947eb52cb18a88d09a4.png

访问 c2 接口 ,此时浏览器会自动的将 Cookie 携带到服务端。

https://i-blog.csdnimg.cn/blog_migrate/96002f883006111bad2b9e52bd8da85f.png

  • 优点:HTTP 协议中支持的技术(像 Set-Cookie 响应头的解析以及 Cookie 请求头数据的携带,都是浏览器自动进行的,是无需我们手动操作的)

  • 缺点:

    • 移动端 APP(Android、IOS)中无法使用 Cookie
    • 不安全,用户可以自己禁用 Cookie
    • Cookie 不能跨域(跨域参考: )
session

其实 session 的底层就是基于我们刚才所介绍的 Cookie 来实现的。

  • 获取 Session
  • 如果我们现在要基于 Session 来进行会话跟踪,浏览器在第一次请求服务器的时候,我们就可以直接在服务器当中来获取到会话对象 Session。如果是第一次请求 Session ,会话对象是不存在的,这个时候服务器会自动的创建一个会话对象 Session 。而每一个会话对象 Session ,它都有一个 ID(示意图中 Session 后面括号中的 1,就表示 ID),我们称之为 Session 的 ID。

https://i-blog.csdnimg.cn/blog_migrate/78d3297172cc1d88055f2ed2040e8756.png

  • 响应 Cookie (JSESSIONID)

    接下来,服务器端在给浏览器响应数据的时候,它会将 Session 的 ID 通过 Cookie 响应给浏览器。其实在响应头当中增加了一个 Set-Cookie 响应头。这个 Set-Cookie 响应头对应的值是不是 cookie? cookie 的名字是固定的 JSESSIONID 代表的服务器端会话对象 Session 的 ID。浏览器会自动识别这个响应头,然后自动将 Cookie 存储在浏览器本地。

https://i-blog.csdnimg.cn/blog_migrate/ec529304edfc4aecb87da585731446ed.png

  • 查找 Session

    接下来,在后续的每一次请求当中,都会将 Cookie 的数据获取出来,并且携带到服务端。接下来服务器拿到 JSESSIONID 这个 Cookie 的值,也就是 Session 的 ID。拿到 ID 之后,就会从众多的 Session 当中来找到当前请求对应的会话对象 Session。

    https://i-blog.csdnimg.cn/blog_migrate/0909b2c0c9bcece988544a95a53f5167.png

    这样我们是不是就可以通过 Session 会话对象在同一次会话的多次请求之间来共享数据了?好,这就是基于 Session 进行会话跟踪的流程。

我们使用代码实现:

@Slf4j
@RestController
public class SessionController {

    @GetMapping("/s1")
    public Result session1(HttpSession session){
        log.info("HttpSession-s1: {}", session.hashCode());

        session.setAttribute("loginUser", "tom"); //往session中存储数据
        return Result.success();
    }

    @GetMapping("/s2")
    public Result session2(HttpServletRequest request){
        HttpSession session = request.getSession();
        log.info("HttpSession-s2: {}", session.hashCode());

        Object loginUser = session.getAttribute("loginUser"); //从session中获取数据
        log.info("loginUser: {}", loginUser);
        return Result.success(loginUser);
    }

}

访问 s1 接口,

https://i-blog.csdnimg.cn/blog_migrate/a284d7b493df4bbc5d2f641b6b222ae3.png

请求完成之后,在响应头中,就会看到有一个 Set-Cookie 的响应头,里面响应回来了一个 Cookie,就是 JSESSIONID,这个就是服务端会话对象 Session 的 ID。

访问 s2 接口,

https://i-blog.csdnimg.cn/blog_migrate/483790ac3f1b116d54035d7b9f2334ff.png

接下来,在后续的每次请求时,都会将 Cookie 的值,携带到服务端,那服务端呢,接收到 Cookie 之后,会自动的根据 JSESSIONID 的值,找到对应的会话对象 Session。 两次请求,获取到的 Session 会话对象的 hashcode 是一样的,就说明是同一个会话对象。而且,第一次请求时,往 Session 会话对象中存储的值,第二次请求时,也获取到了。 那这样,我们就可以通过 Session 会话对象,在同一个会话的多次请求之间来进行数据共享了。

优缺点

  • 优点:Session 是存储在服务端的,安全

  • 缺点:

    • 服务器集群环境下无法直接使用 Session
    • 移动端 APP(Android、IOS)中无法使用 Cookie
    • 用户可以自己禁用 Cookie
    • Cookie 不能跨域

是的,想必你们也发现了,cookie 的缺点,session 也有,毕竟 session 就是基于 cookie 实现的。

下面我们开始本文目标:JWT

令牌

这里我们所提到的令牌,其实它就是一个用户身份的标识,看似很高大上,很神秘,其实本质就是一个字符串。想必很多 90 后都曾经拥有过一个实体的 QQ 令牌,并且现在 Steam 也有登录的令牌。实质上他们的功能作用是一样的。其实它就是一个用户身份的标识,本质就是一个字符串。

如果通过令牌技术来跟踪会话,我们就可以在浏览器发起请求。在请求登录接口的时候,如果登录成功,我就可以生成一个令牌,令牌就是用户的合法身份凭证。接下来我在响应数据的时候,我就可以直接将令牌响应给前端。

接下来我们在前端程序当中接收到令牌之后,就需要将这个令牌存储起来。这个存储可以存储在 cookie 当中,也可以存储在其他的存储空间(比如:localStorage)当中。

接下来,在后续的每一次请求当中,都需要将令牌携带到服务端。携带到服务端之后,接下来我们就需要来校验令牌的有效性。如果令牌是有效的,就说明用户已经执行了登录操作,如果令牌是无效的,就说明用户之前并未执行登录操作。

此时,如果是在同一次会话的多次请求之间,我们想共享数据,我们就可以将共享的数据存储在令牌当中就可以了。

https://i-blog.csdnimg.cn/blog_migrate/5be0b85eed77c72869f61a9d5fd4d114.png

优缺点

  • 优点:

    • 支持 PC 端、移动端
    • 解决集群环境下的认证问题
    • 减轻服务器的存储压力(无需在服务器端存储)
  • 缺点:需要自己实现(包括令牌的生成、令牌的传递、令牌的校验)

针对于这三种方案,现在企业开发当中使用的最多的就是第三种令牌技术进行会话跟踪。而前面的这两种传统的方案,现在企业项目开发当中已经很少使用了。所以在我们的课程当中,我们也将会采用令牌技术来解决案例项目当中的会话跟踪问题。

JWT

JWT 全称:JSON Web Token (官网: )

  • 定义了一种简洁的、自包含的格式,用于在通信双方以 json 数据格式安全的传输信息。由于数字签名的存在,这些信息是可靠的。

    简洁:是指 jwt 就是一个简单的字符串。可以在请求参数或者是请求头当中直接传递。

    自包含:指的是 jwt 令牌,看似是一个随机的字符串,但是我们是可以根据自身的需求在 jwt 令牌中存储自定义的数据内容。如:可以直接在 jwt 令牌中存储用户的相关信息。

    简单来讲,jwt 就是将原始的 json 数据格式进行了安全的封装,这样就可以直接基于 jwt 在通信双方安全的进行信息传输了。

JWT 的组成: (JWT 令牌由三个部分组成,三个部分之间使用英文的点来分割)

  • 第一部分:Header(头), 记录令牌类型、签名算法等。 例如:{“alg”:“HS256”,“type”:“JWT”}

  • 第二部分:Payload(有效载荷),携带一些自定义信息、默认信息等。 例如:{“id”:“1”,“username”:“Tom”}

  • 第三部分:Signature(签名),防止 Token 被篡改、确保安全性。将 header、payload,并加入指定秘钥,通过指定签名算法计算而来。

    签名的目的就是为了防 jwt 令牌被篡改,而正是因为 jwt 令牌最后一个部分数字签名的存在,所以整个 jwt 令牌是非常安全可靠的。一旦 jwt 令牌当中任何一个部分、任何一个字符被篡改了,整个令牌在校验的时候都会失败,所以它是非常安全可靠的。

https://i-blog.csdnimg.cn/blog_migrate/e13464b3a9cdaef9b553a049c1096ba0.png

JWT 是如何将原始的 JSON 格式数据,转变为字符串的呢?

其实在生成 JWT 令牌时,会对 JSON 格式的数据进行一次编码:进行 base64 编码

Base64:是一种基于 64 个可打印的字符来表示二进制数据的编码方式。既然能编码,那也就意味着也能解码。所使用的 64 个字符分别是 A 到 Z、a 到 z、 0- 9,一个加号,一个斜杠,加起来就是 64 个字符。任何数据经过 base64 编码之后,最终就会通过这 64 个字符来表示。当然还有一个符号,那就是等号。等号它是一个补位的符号

需要注意的是 Base64 是编码方式,而不是加密方式。

https://i-blog.csdnimg.cn/blog_migrate/b7e7bc37f9e3885b39408855da692c7c.png

JWT 令牌最典型的应用场景就是登录认证:

  1. 在浏览器发起请求来执行登录操作,此时会访问登录的接口,如果登录成功之后,我们需要生成一个 jwt 令牌,将生成的 jwt 令牌返回给前端。

  2. 前端拿到 jwt 令牌之后,会将 jwt 令牌存储起来。在后续的每一次请求中都会将 jwt 令牌携带到服务端。

  3. 服务端统一拦截请求之后,先来判断一下这次请求有没有把令牌带过来,如果没有带过来,直接拒绝访问,如果带过来了,还要校验一下令牌是否是有效。如果有效,就直接放行进行请求的处理。

在 JWT 登录认证的场景中我们发现,整个流程当中涉及到两步操作:

  1. 在登录成功之后,要生成令牌。

  2. 每一次请求当中,要接收令牌并对令牌进行校验。

简单介绍了 JWT 令牌以及 JWT 令牌的组成之后,接下来我们就来学习基于 Java 代码如何生成和校验 JWT 令牌。

首先我们先来实现 JWT 令牌的生成。要想使用 JWT 令牌,需要先引入 JWT 的依赖:

<!-- JWT 依赖-->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>

接下来我们生成算法:

@Test
public void genJwt(){
Map<String,Object> claims = new HashMap<>();
claims.put("id",1);
claims.put("username","HongKongDoll");

    String jwt = Jwts.builder()
        .setClaims(claims) //自定义内容(载荷)
        .signWith(SignatureAlgorithm.HS256, "bug,start") //签名算法
        .setExpiration(new Date(System.currentTimeMillis() + 24*3600*1000)) //有效期
        .compact();

    System.out.println(jwt);

}

走你:

https://i-blog.csdnimg.cn/blog_migrate/9d89abe4a76f4c360cc49e6907327bfc.png

这时我们使用 utools 上的插件进行解码(或者找任一在线解码网站都可)

https://i-blog.csdnimg.cn/blog_migrate/8a0504a8b125f4a0406f41410560a76c.png

第一部分解析出来,看到 JSON 格式的原始数据,所使用的签名算法为 HS256。

第二个部分是我们自定义的数据,之前我们自定义的数据就是 id,还有一个 exp 代表的是我们所设置的过期时间。

由于前两个部分是 base64 编码,所以是可以直接解码出来。但最后一个部分并不是 base64 编码,是经过签名算法计算出来的,所以最后一个部分是不会解析的。

实现了 JWT 令牌的生成,下面我们接着使用 Java 代码来校验 JWT 令牌(解析生成的令牌):

 @Test
public void parseJwt(){
Claims claims = Jwts.parser()
.setSigningKey("bug,start")//指定签名密钥(必须保证和生成令牌时使用相同的签名密钥)
.parseClaimsJws("eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwiZXhwIjoxNzA0ODU1NTk5LCJ1c2VybmFtZSI6IkhvbmdLb25nRG9sbCJ9.JLg62R07Zr_IEZtaZ4oAQNkGoNIdGKrLbcy-OUCTTPU")
.getBody();

        System.out.println(claims);
    }

走我:

https://i-blog.csdnimg.cn/blog_migrate/43c1ada210556a01f57677e94464c619.png

令牌解析后,我们可以看到id和过期时间,如果在解析的过程当中没有报错,就说明解析成功了。

接下来,为了验证JWT的可靠性,我们修改JWT中其中任一字母,重新进行解析:

走他:

https://i-blog.csdnimg.cn/blog_migrate/5502b525d72bc8b77c0df5efecdeebe7.png

我只是把第一位的e换成了E,结果就报错:JWT解析异常。说明解析JWT只要修改其中任一字符,就会解析失败。

这时候又有一位未来首付要问了,那么还有其他的解析失败因素吗?当然有啦,那就是过期时间,我们在上面设置了过期时间,那么我们现在把生成策略中过期时间换成1秒过期:

https://i-blog.csdnimg.cn/blog_migrate/2461d97caa2ca84eb833022b470f22e5.png

我们重新解析一下:

https://i-blog.csdnimg.cn/blog_migrate/246cc3a71837d865d1bdea7a6f74679c.png

这次报的是JWT过期异常。

通过以上测试,我们在使用JWT令牌时需要注意:

  • JWT校验时使用的签名秘钥,必须和生成JWT令牌时使用的秘钥是配套的。
  • 如果JWT令牌解析校验时报错,则说明 JWT令牌被篡改 或 过期失效了,令牌非法。

接下来,我们进入实战部分:

JWT令牌的生成和校验的基本操作我们已经学习完了,接下来我们就需要在案例当中通过JWT令牌技术来跟踪会话。具体的思路我们前面已经分析过了,主要就是两步操作:

  1. 生成令牌

    在登录成功之后来生成一个JWT令牌,并且把这个令牌直接返回给前端

  2. 校验令牌

    拦截前端请求,从请求中获取到令牌,对令牌进行解析校验

那我们首先来完成:登录成功之后生成JWT令牌,并且把令牌返回给前端。

https://i-blog.csdnimg.cn/blog_migrate/ded3d50b55daded883f6a1bb1d18c0af.png

在controller同级下,建一个包,用来放我们的工具类,然后新建一个class:

/**

- @Description JWT 工具类
- @Author QingNing
- @Date 2024/1/9
  */
  package com.ycg.vue.utils;
  import io.jsonwebtoken.Claims;
  import io.jsonwebtoken.Jwts;
  import io.jsonwebtoken.SignatureAlgorithm;
  import java.util.Date;
  import java.util.Map;

public class JwtUtils {

    private static final String signKey = "bug,start";//签名密钥
    private static final Long expire = 3600000L; //有效时间

    /**
     * 生成JWT令牌
     * @param claims JWT第二部分负载 payload 中存储的内容
     * @return
     */
    public static String generateJwt(Map<String, Object> claims){
        String jwt = Jwts.builder()
                .addClaims(claims)//自定义信息(有效载荷)
                .signWith(SignatureAlgorithm.HS256, signKey)//签名算法(头部)
                .setExpiration(new Date(System.currentTimeMillis() + expire))//过期时间
                .compact();
        return jwt;
    }

    /**
     * 解析JWT令牌
     * @param jwt JWT令牌
     * @return JWT第二部分负载 payload 中存储的内容
     */
    public static Claims parseJWT(String jwt){
        Claims claims = Jwts.parser()
                .setSigningKey(signKey)//指定签名密钥
                .parseClaimsJws(jwt)//指定令牌Token
                .getBody();
        return claims;
    }

}

然后在登陆功能中添加 JWT 生成策略:

以下是本人的登陆代码,仅做参考

 @ApiOperation(value = "用户登录")
@PostMapping("/login")
public result user(@RequestBody UserVo userVo){
User user = userService.login(userVo);
//非空判断
if(!Objects.isNull(user)){
//生成
Map<String , Object> claims = new HashMap<>();
claims.put("id", user.getId());
claims.put("username",user.getUsername());

            //使用JWT工具类,生成身份令牌
            String token = JwtUtils.generateJwt(claims);
            return result.success(token);
        }
        return result.error("用户名或密码错误,请重新输入");
    }

我们打开swagger进行查看是否成功。

https://i-blog.csdnimg.cn/blog_migrate/6d47ab09464e1a1e091e9de11e055416.png

我们发现,已经返回了token,后续的每一次请求当中,都会将这个令牌携带到服务端。

此时我们只需要在前端的返回方法中,添加回调方法:

https://i-blog.csdnimg.cn/blog_migrate/06e1b9d7b9497d9551b4571c85b9cf01.png

即可将jwt令牌存到请求头中,在进行其他操作时就会携带jwt令牌,直至本次会话结束。后续可以使用拦截器进行判断。

至此,我们就完成了JWT令牌的实现。

后续文章中我还会继续更新拦截器和过滤器,各位请听下回分解。