SpringBoot引入JWT实现用户校验

一JWT

JWT(JSON Web Token)是一种开放标准(RFC 7519),用于在网络应用环境间安全地传输信息。这些信息以JSON对象的形式存储在令牌中,并且可以被签名和加密。JWT通常用于身份验证和信息交换
主要用途
  1. 身份验证:
    • 当用户登录成功后,服务器会生成一个JWT并返回给客户端。之后,客户端在每次请求时都会带上这个JWT,通常是通过HTTP头部的Authorization字段(例如:Bearer <token>)。服务器接收到请求后,会验证JWT的有效性(包括签名、过期时间等),从而确认用户的身份。
  2. 信息交换:
    • JWT可以在不同的系统或服务之间安全地传递声明(claims)。由于JWT可以被签名(使用HMAC算法或RSA公私钥对),接收方可以验证内容是否被篡改。

JWT的组成结构

Header(头部):

  • 包含令牌的类型(即JWT)和所使用的签名算法(如HMAC SHA256或RSA)。
{
  "alg": "HS256",
  "typ": "JWT"
}

Payload(载荷):

  • 包含声明(claims)。声明是关于实体(通常是用户)和其他数据的声明
{
  "sub": "1234567890",
  "name": "John Doe",
  "iat": 1516239022
}

Signature(签名):

  • 签名用于验证消息在此期间没有被更改,并且对于使用私钥签名的情况,还可以验证发送者的身份。
  • 签名是通过将Base64编码后的Header和Payload与密钥进行加密生成的。
HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret)

JWT导入

 如果您使用的是JDK1.8,那么您只需要导入:

 <!--        JWT依赖-->
        <!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt -->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
            <version>0.9.1</version>
        </dependency>

 

如果您使用的是JDK1.8以上的,则需要多导入一些依赖:

 <!--        JWT依赖-->
        <!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt -->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
            <version>0.9.1</version>
        </dependency>
        <!-- https://mvnrepository.com/artifact/javax.xml.bind/jaxb-api -->
        <dependency>
            <groupId>javax.xml.bind</groupId>
            <artifactId>jaxb-api</artifactId>
            <version>2.3.0</version>
        </dependency>
        <!-- https://mvnrepository.com/artifact/com.sun.xml.bind/jaxb-impl -->
        <dependency>
            <groupId>com.sun.xml.bind</groupId>
            <artifactId>jaxb-impl</artifactId>
            <version>2.3.0</version>
        </dependency>
        <!-- https://mvnrepository.com/artifact/com.sun.xml.bind/jaxb-core -->
        <dependency>
            <groupId>com.sun.xml.bind</groupId>
            <artifactId>jaxb-core</artifactId>
            <version>2.3.0</version>
        </dependency>
        <!-- https://mvnrepository.com/artifact/javax.activation/activation -->
        <dependency>
            <groupId>javax.activation</groupId>
            <artifactId>activation</artifactId>
            <version>1.1.1</version>
        </dependency>

 

二.搭建一个基本环境

前端代码

前端环境可以自定义搭建,可以就使用HTML+ajax进行前后端交互,也可以使用Vue,这里我使用的Vue,需要四个页面就好了:login页面,index页面(登录成功的页面),其它页面(可随意),以及错误页面

我使用的是vur + axios + vue-router

 第一个登录页面,搭建的使用了elementUI框架,需要使用的可以去elementUI官网下载

<template>
  <div id="app">
    <div id="formDiv">
      <el-form :model="ruleForm" status-icon :rules="rules" ref="ruleForm" label-width="100px" class="demo-ruleForm">
        <el-form-item label="用户名" prop="pass">
          <el-input type="text" v-model="ruleForm.pass" autocomplete="off"></el-input>
        </el-form-item>
        <el-form-item label="密码" prop="checkPass">
          <el-input type="password" v-model="ruleForm.checkPass" autocomplete="off"></el-input>
        </el-form-item>
        <el-form-item>
          <el-button type="primary" @click="submitForm('ruleForm')">提交</el-button>
          <el-button @click="resetForm('ruleForm')">重置</el-button>
        </el-form-item>
      </el-form>
    </div>
  </div>
</template>
<script>
import axios from "axios";

export default {
  data() {
    var validatePass = (rule, value, callback) => {
      if (value === '') {
        callback(new Error('请输入密码'));
      } else {
        if (this.ruleForm.checkPass !== '') {
          this.$refs.ruleForm.validateField('checkPass');
        }
        callback();
      }
    };
    var validatePass2 = (rule, value, callback) => {
      if (value === '') {
        callback(new Error('请再次输入密码'));
      }  else {
        callback();
      }
    };
    return {
      ruleForm: {
        pass: '',
        checkPass: '',
      },
      rules: {
        pass: [
          { validator: validatePass, trigger: 'blur' }
        ],
        checkPass: [
          { validator: validatePass2, trigger: 'blur' }
        ]
      }
    };
  },
  methods: {
    submitForm(formName) {
      this.$refs[formName].validate((valid) => {
        let thisL = this;
        if (valid) {
          axios.post("http://localhost:9000/login",{username:this.ruleForm.pass,password:this.ruleForm.checkPass}).then(function (resp) {
            if (resp.data!==null){
              window.localStorage.setItem('userInfo',JSON.stringify(resp.data));
              console.log(resp.data);
              thisL.$router.replace({path:"/main"})
            }

          })
        } else {
          console.log('error submit!!');
          return false;
        }
      });
    },
    resetForm(formName) {
      this.$refs[formName].resetFields();
    }
  }
}
</script>
<style scoped>
#app{

}
#formDiv{
  width: 400px;
  height: 300px;
  margin: 300px auto 0;
}
</style>

 

 第二个页面,首页,也就是跳转成功的页面:

<script>
export default {
  name: 'Main',
  data(){
    return{
      user:''
    }
  },
  created() {
    this.user=JSON.parse(window.localStorage.getItem("userInfo"));
  }
}
</script>

<template>
<div id="app">
  <h1 style="text-align: center">欢迎来到首页!尊敬的{{user.username}}</h1>
  <router-link to="/other">去其它页面</router-link>
</div>
</template>

 

第三个页面,其它的页面,就展示一段文本:

<template>
<h1 style="text-align: center">这是其它的页面</h1>
</template>

 

第四个页面,错误页面,也很简单,主要是能跳转就行:

<div id="app">
  <h1 style="text-align: center">这是错误页面</h1>
</div>

 

vue-router的配置如下:

import Vue from 'vue'
import Router from 'vue-router'
import Login from "../components/Login.vue";
import Main from "../components/Main.vue";
import Other from "../components/other.vue"
import error from "../components/error.vue";
import axios from "axios";
Vue.use(Router);
const router = new Router({
  mode:'history',
  routes:[
    {
      path:"/login",
      component:Login
    },
    {
      path:"/main",
      component:Main
    },
    {
      path:'/other',
      component:Other
    },
    {
      path:'/error',
      component:error
    }
  ]
})
router.beforeEach((to,from,next)=>{
  if (to.path.startsWith('/login')){
    window.localStorage.removeItem('userInfo');
    next();
  }else {
    let admin = JSON.parse(window.localStorage.getItem('userInfo'))
    if (!admin){
      next({path:'login'})
    }else {
      //校验token的合法性
      axios({
        url:'http://localhost:9000/checkToken',
        method:'get',
        headers:{
          token:admin.token
        }
      }).then(resp=>{
        if (resp.data === 'fail'){
          console.log('校验失败');
          next({path:'/error'})
        }
      })
      next();
    }
  }
})
export default router;

 

上面有一段代码使用到了钩子函数,在请求进入路由的时候,需要先验证token(验证也是axios异步请求到后端验证的),才会转发

后端代码

 后端最重要的就是两个代码片,一个是controller中的代码,它负责逻辑处理,还有就是成成JWT的工具类

controller:

import jakarta.servlet.http.HttpServletRequest;
import org.cqust.jwt_springboot2.pojo.User;
import org.cqust.jwt_springboot2.utils.JwtUtil;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class LoginCTRL {
    private final String USERNAME="admin";
    private final String PASSWORD="123456";
    @RequestMapping("/login")
   public User checkUser(@RequestBody User user){
       if (user.getUsername().equals(USERNAME) && user.getPassword().equals(PASSWORD)){
           //生成token装载
           String token = JwtUtil.getJWT(user.getUsername(), user.getPassword());
           user.setToken(token);
           user.setPassword(null);
           return user;
       }
       return null;
   }
   @RequestMapping("/checkToken")
   public String checkToken(HttpServletRequest req){
       String token = req.getHeader("token");
       Boolean result = JwtUtil.checkToken(token);
       System.out.println("校验结果:"+result);
       if (result){
           return "ok";
       }
       return "fail";
   }
}

 

JWT工具类:

import io.jsonwebtoken.*;

import java.util.Date;
import java.util.UUID;

public class JwtUtil {
    public static String getJWT(String username,String password){
        JwtBuilder builder = Jwts.builder();
        //设置Header
        String compact = builder.setHeaderParam("typ", "JWT")
                .setHeaderParam("alg", "HS256")
                //payload
                .claim("username", username)
                .claim("role", "root")
                .claim("password", password)
                .setSubject("admin-root")
                .setExpiration(new Date(System.currentTimeMillis() + 1000 * 60*10))//十分钟过期
                .setId(UUID.randomUUID().toString())
                //签名signature
                .signWith(SignatureAlgorithm.HS256, "admin")//加密算法和密钥
                .compact();//启动连接三部分
        return compact;
    }
    public static Boolean checkToken(String token){
        try {
            //不抛出异常说明token存在,则token校验成功
            //反之,抛出异常说明token有问题,校验失败
            JwtParser parser = Jwts.parser();
            Jws<Claims> claimsJws = parser.setSigningKey("admin").parseClaimsJws(token);
            Claims body = claimsJws.getBody();
        }catch (Exception e){
            return false;
        }
        return true;
    }
}

 

测试

测试主要从三个方面:

  1. 正常注册一个admin用户,查看前端是否受到其返回的token并且正常登录
  2. 注册一个其它的用户,查看前端是否可以到首页
  3. 跳转到其它页面,查看后端是否进行了验证

测试一:

正常注册一个admin用户,发现可以到达首页,并且也返回了token

 测试二:

随便构造一个用户之后,我们发现登录不上去,控制台输出错误信息

 测试三:

登录admin用户,然后跳转到其它页面,查看后端是否输出验证信息

 我们可以发现进行了验证,而且验证了两次,第一次验证时登录成功后转发到了main页面,第二次时去其它页面的时候进行验证的

三.引入存储层

上面的实验环境使用的是final修饰的变量作为检验用户登录合法性的凭证,很显然在实际开发中用的很少,故而现在需要引入存储层进行校验,校验用户的信息是否合法应该和数据库存储的数据进行校对

这里使用MySQL + Redis来做,刚开始登录的时候查询数据库,后面校验token的合法性就交给Redis;

在数据库查询出来之后就需要将username信息和生成的token信息缓存到Redis中,后面前端会频繁的校验token的合法性,故而压力比较大所以就交给Redis做

环境安装:

MySQL + Redis

  <!--        mysql驱动器-->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>8.0.31</version>
        </dependency>
        <!--        mybatis-plus-->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-spring-boot3-starter</artifactId>
            <version>3.5.10.1</version>
        </dependency>
<!--        Redis 依赖-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>

 

同时填写application.properties文件:

#mysql连接,导入依赖之后就要填,不然启动不了spring容器
spring.datasource.username=root
spring.datasource.password=123456
spring.datasource.url=jdbc:mysql://localhost:3306/mybatis-plus?useUnicode=true&characterEncoding=utf-8
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
#redis的基本配置
spring.data.redis.host=127.0.0.1
spring.data.redis.port=6379
spring.data.redis.database=0

 

修改后端代码

mysql数据库表:

CREATE TABLE `user` (
  `id` bigint(20) NOT NULL,
  `username` varchar(255) NOT NULL,
  `password` varchar(255) NOT NULL,
  `created_time` timestamp NULL DEFAULT NULL,
  `updated_time` timestamp NULL DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

controller代码:

import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import jakarta.servlet.http.HttpServletRequest;
import org.cqust.jwt_springboot2.dao.UserDao;
import org.cqust.jwt_springboot2.pojo.User;
import org.cqust.jwt_springboot2.utils.JwtUtil;
import org.cqust.jwt_springboot2.utils.RedisUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class LoginCTRL {
    //注入UserDao
    @Autowired
    private UserDao userDao;
    //注入RedisUtil
    @Autowired
    private RedisUtil redisUtil;
    //登录接口
    @RequestMapping("/login")
   public User checkUser(@RequestBody User user){
        //获取用户名和密码
        String username = user.getUsername();
        String password = user.getPassword();
        //先查询redis缓存,缓存中有token可以解密出用户名和密码进行校对
        //redis中没有再去数据库查询
        String redisUser = redisUtil.getStr(username);
        User selectUser=null;
        //数据库查询
        if (redisUser == null){
            //查询数据库
            QueryWrapper<User> wrapper = new QueryWrapper<>();
            wrapper.eq("username",username);
            wrapper.eq("password",password);
            selectUser = userDao.selectOne(wrapper);
        }
        //解密token,取出用户名和密码,进行校对
        //redis查询
        if (redisUser != null){
            //解密token
            User willUser = JwtUtil.checkToken(redisUser);
            //token过期了,但是缓存还没有过期,也需要重新查询
            if (willUser == null){
                //查询数据库
                QueryWrapper<User> wrapper = new QueryWrapper<>();
                wrapper.eq("username",username);
                wrapper.eq("password",password);
                selectUser = userDao.selectOne(wrapper);
            }
            //校对用户名和密码
            if (willUser!=null && username.equals(willUser.getUsername()) && password.equals(willUser.getPassword())){
                selectUser=willUser;
                selectUser.setPassword(null);
            }

        }
        //如果查询到用户
        if (selectUser!=null){
            //生成token
            String jwt = JwtUtil.getJWT(username, password);
            selectUser.setToken(jwt);
            selectUser.setPassword(null);
            //缓存到redis
            redisUtil.setStr(username,jwt);
            //设置缓存有效期:三十分钟
            //由于token的有效期是十分钟,如果用户一直在线可以一直刷新过期时间,则一直查询的是redis;如果用户不是一直在线,则会因为Redis缓存失效而查询数据库
            redisUtil.setTime(username,60*30);
            return selectUser;
        }
        return null;
   }
   //校验token接口
   @RequestMapping("/checkToken")
   public String checkToken(HttpServletRequest req){
        //由前端传递的用户名和token
       String token = req.getHeader("token");
       String username = req.getHeader("username");
       //解密token
       User user = JwtUtil.checkToken(token);
       //从redis中查询是否真的有这个token和用户名
       String redisUser = redisUtil.getStr(username);
       if (redisUser != null){
           //校验token是否匹配,用户名是否配置
           if (redisUser.equals(token) && user.getUsername().equals(username)){
               return "ok";
           }
       }
       //这里返回包含了如下情况 :redis中没有缓存token,缓存的token和新的token不匹配,token中的用户名和登录的用户名不匹配
       return "fail";
   }
}

 

 RedisConfig:

import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
public class RedisConfig {
    @Bean
    @SuppressWarnings("all") // 抑制所有警告
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
        // 创建RedisTemplate实例,用于操作Redis
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();

        // 设置Redis连接工厂,以便模板能够连接到Redis服务器
        redisTemplate.setConnectionFactory(redisConnectionFactory);

        // 创建Jackson2JsonRedisSerializer实例,用于将Java对象序列化为JSON格式存储在Redis中
        Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);

        // 创建ObjectMapper实例,用于处理JSON数据
        ObjectMapper mapper = new ObjectMapper();

        // 设置ObjectMapper的可见性策略,使得所有属性都能被序列化和反序列化
        mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);

        // 启用默认的类型信息,以便在反序列化时能够确定确切的类类型
        mapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);

        // 将配置好的ObjectMapper设置到Jackson2JsonRedisSerializer中
        jackson2JsonRedisSerializer.setObjectMapper(mapper);

        // 创建StringRedisSerializer实例,用于将字符串格式的key序列化到Redis中
        StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();

        // 设置RedisTemplate的key序列化器为StringRedisSerializer
        redisTemplate.setKeySerializer(stringRedisSerializer);

        // 设置RedisTemplate的hash key序列化器为StringRedisSerializer
        redisTemplate.setHashKeySerializer(stringRedisSerializer);

        // 设置RedisTemplate的value序列化器为Jackson2JsonRedisSerializer
        redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);

        // 设置RedisTemplate的hash value序列化器为Jackson2JsonRedisSerializer
        redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);

        // 初始化RedisTemplate,使配置生效
        redisTemplate.afterPropertiesSet();

        // 返回配置好的RedisTemplate实例
        return redisTemplate;
    }

}

 

Redis存储层:

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

import java.util.concurrent.TimeUnit;

@Component
@SuppressWarnings("all")
public class RedisUtil {
    @Autowired
    private RedisTemplate redisTemplate;

    //使用set方法,插入key-value键值对
    public void setStr(String key,String value){
        redisTemplate.opsForValue().set(key,value);
    }
    //使用get方法,得到key-value
    public String getStr(String key){
        return (String) redisTemplate.opsForValue().get(key);
    }
    //设置key的过期时间
    public Boolean setTime(String key,int seconds){
        // 设置过期时间
        Boolean result = redisTemplate.expire(key, seconds, TimeUnit.SECONDS);
        return result;
    }
    //查看剩余过期时间
    public Long getTTL(String key){
        return redisTemplate.getExpire(key);
    }
}

 

解决springBoot和Redis的跨源问题:

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

//解决前后端跨域配置
@Configuration
@MapperScan("org.cqust.jwt_springboot2.dao")
public class CrosConfig implements WebMvcConfigurer {
    // 重写addCorsMappings方法,用于配置跨域请求
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        // 添加映射,允许所有路径
        registry.addMapping("/**")
                // 允许所有来源
                .allowedOriginPatterns("*")
                // 允许所有请求方法
                .allowedMethods("GET","HEAD","POST","PUT","DELETE","OPTIONS")
                // 允许发送Cookie
                .allowCredentials(true)
                // 预检请求的缓存时间
                .maxAge(3600)
                // 允许所有请求头
                .allowedHeaders("*");
    }

 

 开始测试

 测试点如下:

  1. 当redis中没有缓存时查询数据库
  2. 当redis中有缓存,查询redis
  3. 当token过期查询数据库

 测试一:redis没有缓存,直接查询数据

redis中为空:

 登录成功:

 后端输出,直接查询的就是数据库:

 测试二:redis缓存存在,直接查询redis登录

依旧登录成功:

 控制台只输出redis相关信息,因为没查询数据库,查询的是redis:

测试三:token过期查询数据库,重新查询数据库

前端依旧登录成功:

 查看后台控制台输出,由于token过期,会切换为查询数据库登录:

 原理剖析

1. 用户登录流程
  • 入口:用户访问登录页面,输入用户名和密码。
  • 前端校验:系统首先进行前端校验,检查输入是否符合格式要求(如用户名非空、密码长度等)。
    • 合法输入:跳转到后端处理。
    • 非法输入:返回登录页,提示用户重新填写。
2. 后端处理与缓存校验
  • 数据提交:前端提交的数据到达后端,触发后端处理逻辑。
  • 缓存检查:后端首先检查用户信息是否存在于Redis缓存中。
    • 缓存命中(用户信息存在):
      • 校验Token:
        • 从Redis中获取用户的Token,检查是否过期。
          • Token过期:生成新Token并更新缓存,跳转至首页。
          • Token未过期:校验Token与用户信息是否匹配。
            • Token匹配:直接跳转至首页。
            • Token不匹配:可能为非法访问,强制跳转至首页或重新登录。
    • 缓存未命中(用户信息不存在于Redis):
      • 数据库查询:后端从数据库中查询用户信息。
        • 用户存在:将用户信息存入Redis缓存,生成新Token并跳转至首页。
        • 用户不存在:跳转至首页或提示登录失败。
3. 路由切换与Token校验
  • 路由触发:当用户尝试访问其他页面或发生路由切换时,系统会触发异步请求,向后端校验当前Token的有效性。
  • 后端Token校验:
    • Token合法:允许路由跳转至目标页面。
    • Token非法或过期:
      • 若用户仍处于登录状态,可能重新生成Token并重试。
      • 若Token无效且未登录,强制跳转至登录页或首页。
4. 异常处理与循环逻辑
  • Token校验失败:若Token多次校验失败,系统会终止当前流程,引导用户重新登录。
  • 循环机制:当Token校验失败时,流程会回到登录页重新开始,形成闭环。
5. 流程终点
  • 成功路径:用户通过校验后,最终跳转至首页或目标页面,流程正常结束。
  • 失败路径:若校验失败或用户主动退出,流程结束于登录页或首页

 

 

------END------

 

posted @ 2025-03-19 18:49  回忆也交给时间  阅读(107)  评论(0)    收藏  举报