一JWT
JWT(JSON Web Token)是一种开放标准(RFC 7519),用于在网络应用环境间安全地传输信息。这些信息以JSON对象的形式存储在令牌中,并且可以被签名和加密。JWT通常用于身份验证和信息交换主要用途
- 身份验证:
- 当用户登录成功后,服务器会生成一个JWT并返回给客户端。之后,客户端在每次请求时都会带上这个JWT,通常是通过HTTP头部的
Authorization
字段(例如:Bearer <token>
)。服务器接收到请求后,会验证JWT的有效性(包括签名、过期时间等),从而确认用户的身份。
- 当用户登录成功后,服务器会生成一个JWT并返回给客户端。之后,客户端在每次请求时都会带上这个JWT,通常是通过HTTP头部的
- 信息交换:
- 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();//设置HeaderString 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;} }
测试
测试主要从三个方面:
- 正常注册一个admin用户,查看前端是否受到其返回的token并且正常登录
- 注册一个其它的用户,查看前端是否可以到首页
- 跳转到其它页面,查看后端是否进行了验证
测试一:
正常注册一个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 @Autowiredprivate UserDao userDao;//注入RedisUtil @Autowiredprivate 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){//解密tokenUser 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){//生成tokenString 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){//由前端传递的用户名和tokenString token = req.getHeader("token");String username = req.getHeader("username");//解密tokenUser 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实例,用于操作RedisRedisTemplate<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 {@Autowiredprivate RedisTemplate redisTemplate;//使用set方法,插入key-value键值对public void setStr(String key,String value){redisTemplate.opsForValue().set(key,value);}//使用get方法,得到key-valuepublic 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方法,用于配置跨域请求 @Overridepublic void addCorsMappings(CorsRegistry registry) {// 添加映射,允许所有路径registry.addMapping("/**")// 允许所有来源.allowedOriginPatterns("*")// 允许所有请求方法.allowedMethods("GET","HEAD","POST","PUT","DELETE","OPTIONS")// 允许发送Cookie.allowCredentials(true)// 预检请求的缓存时间.maxAge(3600)// 允许所有请求头.allowedHeaders("*");}
开始测试
测试点如下:
- 当redis中没有缓存时查询数据库
- 当redis中有缓存,查询redis
- 当token过期查询数据库
测试一:redis没有缓存,直接查询数据
redis中为空:
登录成功:
后端输出,直接查询的就是数据库:
测试二:redis缓存存在,直接查询redis登录
依旧登录成功:
控制台只输出redis相关信息,因为没查询数据库,查询的是redis:
测试三:token过期查询数据库,重新查询数据库
前端依旧登录成功:
查看后台控制台输出,由于token过期,会切换为查询数据库登录:
原理剖析
1. 用户登录流程- 入口:用户访问登录页面,输入用户名和密码。
- 前端校验:系统首先进行前端校验,检查输入是否符合格式要求(如用户名非空、密码长度等)。
- 合法输入:跳转到后端处理。
- 非法输入:返回登录页,提示用户重新填写。
- 数据提交:前端提交的数据到达后端,触发后端处理逻辑。
- 缓存检查:后端首先检查用户信息是否存在于Redis缓存中。
- 缓存命中(用户信息存在):
- 校验Token:
- 从Redis中获取用户的Token,检查是否过期。
- Token过期:生成新Token并更新缓存,跳转至首页。
- Token未过期:校验Token与用户信息是否匹配。
- Token匹配:直接跳转至首页。
- Token不匹配:可能为非法访问,强制跳转至首页或重新登录。
- 从Redis中获取用户的Token,检查是否过期。
- 校验Token:
- 缓存未命中(用户信息不存在于Redis):
- 数据库查询:后端从数据库中查询用户信息。
- 用户存在:将用户信息存入Redis缓存,生成新Token并跳转至首页。
- 用户不存在:跳转至首页或提示登录失败。
- 数据库查询:后端从数据库中查询用户信息。
- 缓存命中(用户信息存在):
- 路由触发:当用户尝试访问其他页面或发生路由切换时,系统会触发异步请求,向后端校验当前Token的有效性。
- 后端Token校验:
- Token合法:允许路由跳转至目标页面。
- Token非法或过期:
- 若用户仍处于登录状态,可能重新生成Token并重试。
- 若Token无效且未登录,强制跳转至登录页或首页。
- Token校验失败:若Token多次校验失败,系统会终止当前流程,引导用户重新登录。
- 循环机制:当Token校验失败时,流程会回到登录页重新开始,形成闭环。
- 成功路径:用户通过校验后,最终跳转至首页或目标页面,流程正常结束。
- 失败路径:若校验失败或用户主动退出,流程结束于登录页或首页
------END------