springboot2.3.x整合JWT

1.什么是JWT?

image-20201128143849235

image-20201128144116725

1.官网地址

官网地址: https://jwt.io/introduction/

2.官网译文及白话解释

  • 译文:JSON Web Token(JWT)是一个开放标准(rfc7519);
    它定义了一种紧凑的、自包含的方式,用于在各方之间以JSON对象安全地传输信息。此信息可以验证和信任,因为它是数字签名的。jwt可以使用秘密(使用HMAC算法)或使用RSA或ECDSA的公钥/私钥对进行签名

  • 白话解释:JWT是JSON Web Token的简称,是通过JSON形式作为Web应用中的令牌,用于在各方之间安全地将信息作为JSON对象传输。在数据传输过程中还可以完成对数据加密、签名等相关处理。

2.JWT能做什么

1.授权

  • 是jwt在项目中使用最广的功能,例如我们在用户登录成功后,生成一个该用户的jwt令牌返回前端,前端对用户的后续请求中携带JWT,根据业务内容进行判断用户是否有访问服务和资源的权限。并且当今广泛使用单点登录功能也是要基于JWT的,因为它的开销很小并且可以在不同的域中轻松使用。

2.信息交换

  • JSON Web Token是在各方之间安全地传输信息的好方法。因为可以对JWT进行签名(例如,使用公钥/私钥对),所以您可以确保请求发起人是否合法;并且由于签名是使用标头和有效负载计算的,甚至能够验证您的请求内容是否被第三方拦截后进行了篡改

3.为什么要使用JWT?

一说到jwt,那么咱们就不得不提sessionredis

1.传统session 方式面临的问题

我们最早在做项目的时候,由于功能单一或者用户数量较低,通常是将用户登录后的认证信息存于服务器的session的,每次请求时候呢,都会根据客户端请求携带的cookie(sessionId)去服务器校验比对,这种方式呢,当cookie被截获,用户就会很容易受到跨站请求伪造的攻击,且当用户数量上来的时候,存储用户信息的session会增大服务器的开销!前后端分离,服务器集群都是单session面临的窘境!

2.redis 面临的问题

由于服务器集群需要解决session共享问题,所以呢,后续随着发展,慢慢的将用户信息存于了redis中,但redis也是一个纯吃内存的服务,大用户量下对redis内存扩展的需要又要不断增大!

总结:

session: 消耗服务器资源 安全问题 需要解决分布式session

redis:内存资源消耗问题

3.使用JWT的优势

  • 简洁: 可以通过URL将JWT携带在HTTP请求参数 (param)或者在HTTP 请求头(header)进行发送,数据量小,传输速度快

  • 可自包含,jwt中可自定义存储用户一些信息,适用于大多数业务逻辑场景,避免频繁查库

  • jwt 支持任何web形式请求

  • 保存在客户端,无需在服务端保存用户会话信息,特别适用于分布式微服务

4.JWT认证流程分析

1.文字说明

第一步:前端通过Web表单将自己的用户名和密码发送到后端的接口

第二步:后端根据用户密码查询数据库校验账户合法性,验证成功后将一些用户信息构造进jwt中,此时jwt会进行加密成为一串字符串,前端将jwt生成的字符串返回给前端

第三步:前端后续操作请求将必须携带后端返回的jwt字符串 可存于Header或参数中(根据前后端协商结果,但一般都是header 且使用Authorization位,这样可以防止请求伪造),后端则检查前端是否传递了jwt或者传递过来的jwt是否合法是否过期等等

第四步:如后端接口需要验证才可访问,则对请求校验jwt,验证通过则进行逻辑操作,否则让用户登录

第五步:客户端用户退出时前端可选择删除jwt(也可不删(jwt一般都会设置有效时间))

2.图片说明

整个链路图示如下:

image-20201128152613148

5.JWT结构详解

前边说了一些jwt的各种好各种优点,但仅仅初略提了一嘴jwt就是一个加密的后的字符串…

那么,其具体结构属性是怎样的呢?

令牌组成

JWT(JSON WEB TOKEN)共有三部分组成:标头.负载.签名

jwt: String ====> header.payload.singnature

  • 1.标头(Header)
  • 2.有效负载(Payload)
  • 3.签名(Signature)
  • JWT通常如下所示:xxxxx.yyyyy.zzzzz 三部分,即Header.Payload.Signature

例如这样:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

1.标头

JWT的第一部分是标头(Header),标头通常由两部分组成:令牌的类型(即JWT)和所使用的签名算法,例如HMAC SHA256或RSA。它会使用 Base64 编码组成 JWT 结构的第一部分。

一般我们都会使用默认标头:

{
  "alg": "HS256",
  "typ": "JWT"
}

2.负载

令牌的第二部分是有效负载(Payload),其中包含声明。声明是有关实体(通常是用户)和其他数据的声明。同样的,它会使用 Base64 编码组成 JWT 结构的第二部分

这一部分,我们通常会存储一些项目业务所需要的使用的普遍属性

{
  "user_id": "1",
  "name": "tom",
  "organization_id": 1
}

3.签名

令牌的第三部是签名(Signature)签名 需要使用编码后的 header 和 payload 以及我们提供的一个密钥,然后使用 header 中指定的签名算法(HS256)进行签名。签名的作用是防止JWT篡改

例如这样:HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload),secret);

公式:加密算法(标头base64编码+“.”+负载base64编码,密钥)

签名目的:

最后一步签名的过程,实际上是对头部以及负载内容进行签名,防止内容被窜改。如果有人对头部以及负载的内容解码之后进行修改,再进行编码,最后加上之前的签名组合形成新的JWT的话,那么服务器端会判断出新的头部和负载形成的签名和JWT附带上的签名是不一样的。如果要对新的头部和负载进行签名,在不知道服务器加密时用的密钥的话,得出来的签名也是不一样的。

4.JWT使用注意事项

image-20201128155606462

前边也说过 标头(Header)和负载(Payload)是用base64进行编码的,而Base64是可逆的,这就意味着,其可以被外部解码!!!!那么我的信息不是暴露了??这不是很沙雕吗??

确实,JWT中的Header和Payload 可被反编,我不往其中存敏感信息不好了吗,这不是虎吗?我只存个user_id或者user_nick_name,就算被反编码知道了,那又怎样呢???他又能从这无关紧要的属性中推测出啥呢???就像你知道我叫神威马超或者我叫玉麒麟卢俊义,我不违法乱纪你能咋地!

官网也警示了!不要在JWT中填充敏感信息!!!!

请一定要注意:不要在JWT中填入敏感信息!!!

请一定要注意:不要在JWT中填入敏感信息!!!

请一定要注意:不要在JWT中填入敏感信息!!!

强调+∞∞∞∞∞

6.springboot整合JWT

1.依赖引入

我们要使用jwt呢,肯定需要引入其相应的依赖

        <!--引入jwt-->
        <dependency>
            <groupId>com.auth0</groupId>
            <artifactId>java-jwt</artifactId>
            <version>3.5.0</version>
        </dependency>

2.小试牛刀

接下来,咱们就先来简单使用一下jwt

1.构建JWT

首先,咱们来编写一个demo

我们构建jwt 只需使用其JWT.create()方法,一直Build我们的参数即可

header:可不写,其有一个默认参数,当然您也可自行更改,详见其JWT.create()方法

withClaim:方法即为我们的负载(payload)可多次build

withSubject: 也是填充负载的一种方式

sign:即我们的签名方式,起中班包含加密密钥,sign一定是要在最后一个build的

    @Test
    void contextLoads() {
        String jwt = JWT.create()
                .withClaim("username", "王二麻子")
                .sign(Algorithm.HMAC256("secret"));
        System.out.println(jwt);

    }

image-20201128165050811

执行后呢,拿到了一个jwt字符串

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6IjExMSJ9.2g5robitasDRAv4RKEOwn6O7VGbpARjT06zistUGfCI

我们使用在线jwt解析,将我们jwtcopy进去看看

image-20201128171041665

成功获取到了jwt的header以及 payload,这呢,也再次证明和警示了我们,不要将用户敏感信息存入JWT负载中…

2.JWT校验

我们服务器呢,亦可对JWT进行校验

我们需要根据JWT构建时的密钥进行生成一个解析对象JWTVerifier

然后将调用解析对象的verify方法,将我们的JWT传入进去,生成一个JWT解码对象

JWT解码对象调用不同的方法拿到对应的值…

例如调用getClaim("负载中某一字段名") 可拿到某具体字段的值

也可调用getClaims()拿到所有负载内容,返回值为map

    @Test
    void verifyJWT() {
        JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256("secret")).build();
        DecodedJWT decodedJWT = jwtVerifier.verify("eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6IueOi-S6jOm6u-WtkCJ9.Q71pvsTR5TYFePJFIMLvCbO8MGnQ0oiSJNCuSs1JiM4");
        String payload = decodedJWT.getPayload();
        System.out.println("负载:" + payload);
        System.out.println("标头:" + decodedJWT.getHeader());
        System.out.println("签名:" + decodedJWT.getSignature());
        System.out.println("------");
        Map<String, Claim> payloads = decodedJWT.getClaims();
        payloads.forEach((k,v)-> System.out.println( k + ":" + v.asString()));
    }

image-20201128172814151

注意的点:拿到负载字段后,其值时需要强转类型的,比如你原来某一键值对是"username":“zs”,那么你需要as为String… 如果你有键值对为"age": 1,那么你需要对其值asInt…以此类推…需要严格的对应字段值才能成功正确的获取到…否则拿不到真正的值

image-20201128172939154

例如,我之前的是"“username”, "王二麻子”, 那么我拿到值需要再asString…如果类型不对,是无法获取到正确的值…

image-20201128173032084

如此,校验这一步也基本走通了

3.JWT过期时间制定

事实上,我们会对服务器token做一个过期限制,比如十二小时自动过期或者每天凌晨自动过期等等…

JWT呢,其构造方法默认即可设置构造的时间

    @Test
    void contextLoads() {
        String jwt = JWT.create()
                .withClaim("username", "admin")
                .withExpiresAt(new Date(System.currentTimeMillis()+60000))
                .sign(Algorithm.HMAC256("secret"));
        System.out.println(jwt);

    }

我这里呢,是设置了构造后的一分钟的过期,那么此token仅仅只有一分钟的有效时间

我们来测试一下

丢到JWT解析器中,发现其负载中新增了一个exp字段,其值便是我们这个TOKEN过期时间点的时间戳(秒单位)…

image-20201128173525944

时隔一会,代码解析JWT 报错JWT超时异常

image-20201128173746128

3.正式整合

1.连接数据库

用的是mysql数据库,mybatisplus ORM框架

        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.4.1</version>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <scope>runtime</scope>
        </dependency>

yml

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/test?serverTimezone=UTC&useUnicode=true&characterEncoding=utf-8
    driver-class-name: com.mysql.cj.jdbc.Driver
    username: root
    password: root

用户表实体

@Data
@TableName("user")
public class User {
    private Integer id;
    private String username;
    private String password;
}
@Data
public class UserSub {
    private Integer id;
    private String username;
}

2.JWT封装工具类

我们不可能每次都对jWT做如此复杂的操作,为了高效开发以及复用,我们需要对JWT的使用做一定的代码封装

注意:这里的Jwt构建与解析与上方展示方式做了一些微调,主要是针对于payLoad,一般生产情况我们会直接将一个json格式的数据(注意不要填充敏感感信息)直接填充至payload,解析时拿到payLoad反序列化为对象

package com.leilei.jwt;

import com.alibaba.fastjson.JSON;
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTCreator;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.leilei.entity.UserSub;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.util.Date;

/**
 * @author lei
 * @version 1.0
 * @date 2020/11/28 17:43
 * @desc
 */
@Service
public class JwtSupport {
    @Value("${jwt.secret}")
    private String secret;
    @Value("${jwt.expireTime}")
    private Long expireTime;

    /**
     * 生成token
     *
     * @param payload 载荷
     * @return 返回token
     */
    public String buildToken(UserSub payload) {
        JWTCreator.Builder jwt = JWT.create();
        jwt.withSubject(JSON.toJSONString(payload));
        jwt.withExpiresAt(new Date(System.currentTimeMillis() + expireTime * 60 * 60 * 1000));
        return jwt.sign(Algorithm.HMAC256(secret));
    }

    /**
     * 验证token
     *
     * @param token
     * @return
     */
    public void verify(String token) {
        JWT.require(Algorithm.HMAC256(secret)).build().verify(token);
    }

    /**
     * 获取token中payload
     *
     * @param jwt
     * @return
     */
    public UserSub parseJwt(String jwt) {
        DecodedJWT decodedJwt = JWT.require(Algorithm.HMAC256(secret)).build().verify(jwt);
        String subject = decodedJwt.getSubject();
        return JSON.parseObject(subject, UserSub.class);
    }
}

yml新增配置

jwt:
  # 密钥 后续对密码配置需要加密
  secret: 'lei#ae86..'
  #token过期时间  单位小时
  expireTime: 12

这里也要注意:我们的密钥后续需要做一些安全配置,例如加密,使用启动参数覆盖等等,一定要保护密钥的安全,不然JWT加密就成了空壳…

接下来我们简单的模拟一下登录

    public AjaxResult login(User user) {
        User existUser = userMapper.selectOne(new QueryWrapper<User>()
                .lambda()
                .eq(User::getUsername, user.getUsername())
                .eq(User::getPassword, user.getPassword()));
        if (existUser == null) {
            return AjaxResult.error("账户或密码错误");
        }
        UserSub userSub = new UserSub();
        BeanUtils.copyProperties(existUser, userSub);
        return AjaxResult.success(jwtSupport.buildToken(userSub));
    }

image-20221019145030509

image-20221019145109927

3.自定义校验注解

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface AccPermission {
    /**
     * jwt校验,如果设置为false则表示接口不需要校验token合法性
     * @return
     */
    boolean jwt() default true;
}

4.自定义校验拦截器

package com.leilei.jwt;

import com.alibaba.fastjson.JSON;
import com.baomidou.mybatisplus.core.toolkit.StringUtils;
import com.leilei.common.AjaxResult;
import lombok.extern.log4j.Log4j2;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * @author lei
 * @version 1.0
 * @date 2020/11/28 18:35
 * @desc
 */
@Component
@Log4j2
public class JwtInterceptor implements HandlerInterceptor {
    @Autowired
    private JwtSupport jwtSupport;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        if (handler instanceof HandlerMethod) {
            String url = request.getRequestURI();
            HandlerMethod handlerMethod = (HandlerMethod) handler;
            AccPermission annotation = handlerMethod.getMethod().getAnnotation(AccPermission.class);
            if (annotation == null) {
                annotation = handlerMethod.getMethodAnnotation(AccPermission.class);
                //无校验注解,则仍默认校验JWT合法性
                if (annotation == null) {
                    return true;
                    // return checkJWT(request,response,url);
                }
            }
            //如果设置了jwt=false,则直接放行
            if (!annotation.jwt()) {
                return true;
            }
            //jwt=true,则开始校验
            return checkJwt(request, response, url);
        }
        return true;
    }

    /**
     * 校验JWT
     *
     * @param request
     * @param response
     * @param url
     * @return
     * @throws Exception
     */
    public boolean checkJwt(HttpServletRequest request, HttpServletResponse response, String url) throws Exception {
        String authToken = request.getHeader("Authorization");
        if (StringUtils.isBlank(authToken)) {
            return checkError(response, url);
        }
        jwtSupport.verify(authToken);
        return true;
    }

    /**
     * 校验失败
     *
     * @param response
     * @param url
     * @return
     * @throws Exception
     */
    public boolean checkError(HttpServletResponse response, String url) throws Exception {
        response.setHeader("Content-Type", "application/json");
        response.setCharacterEncoding("UTF-8");
        log.error("当前接口-{}需要登录!", url);
        response.getWriter().print(JSON.toJSONString(AjaxResult.error("Token校验失败,当前请求需要登录!!!", 401)));
        return false;
    }
}

5.web配置,定义放开拦截路径以及配置拦截器生效

@Configuration
public class WebConfiguration implements WebMvcConfigurer {

    /**
     * 添加;拦截器 以及拦截 或者放行规则
     * @param registry
     */
  @Override
  public void addInterceptors(InterceptorRegistry registry) {
    //将自定义的拦截器添加进去 必须是使用方法获取,不可直接New 否则该拦截器中中无法注入Bean
    registry.addInterceptor(getJwtInterceptor())
        //拦截所有
        .addPathPatterns("/**")
        //排除路径  排除中的路径 不需要进行拦截
        .excludePathPatterns("/login/**");
  }

  /**
   * 定义方法获取自定义的 JWTInterceptor
   * @return
   */
  @Bean
  public JWTInterceptor getJwtInterceptor() {
    return new JWTInterceptor();
  }
  
}

6.全局异常拦截

package com.leilei.config;

import com.auth0.jwt.exceptions.JWTVerificationException;
import com.auth0.jwt.exceptions.TokenExpiredException;
import com.leilei.common.AjaxResult;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

/**
 * @author lei
 * @create 2022-10-19 14:59
 * @desc 全局异常拦截
 **/
@RestControllerAdvice
public class ExceptionAdvice {

    @ExceptionHandler(TokenExpiredException.class)
    public AjaxResult tokenExpiredException() {
        return AjaxResult.error("token已过期", -2);
    }

    @ExceptionHandler(JWTVerificationException.class)
    public AjaxResult jwtVerificationException() {
        return AjaxResult.error("token解析异常", -3);
    }

}

7.controller 接口

现有接口如下,一个登录,一个测试,我们测试接口打上了自定的的注解AccPermission,且对该url进行了拦截,因此,当前/testurl 需要携带登录后获取的token令牌

image-20221019151633338

7.测试

1.未携带token 访问需要校验token的接口

…抛出需要登录信息

image-20201128195748199

2.未携带token访问无需校验token的接口

…可正常访问

image-20201128200036435

3.输入正确账户密码,拿到token

…成功拿到token信息,这里前端需要保存起来

image-20221019145246365

4.用拿到的token访问需要验证token的接口

…可正常访问接口

image-20221020134426985

5.更改token后访问需要校验token的接口

…抛出token错误,需要登录信息

image-20221019151243298

6.重启项目后,用之前正确token访问需要校验token的接口

…token仍然有效,无需再次登录服务器,直到token过期为止

image-20201128200612885

image-20221020134458657

7.结语

从以上案例中,我们已然看出了JWT的强大之处…例如轻量级,无需每次查询数据库,凭证由客户端存储,项目重起服务端无需从新颁发凭证等等

2.项目源码

源码:sprinboot-jwt

验证工具:jwt在线解码

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值