1. 为什么要用 JWT ?
认证
在谈起 JWT 之前,我们先了解一下什么是认证。
在登录淘宝、微博等软件或者网站之前,我们需要通过填写账号和密码来校验身份。认证是用来验证用户身份合法性的一种方式。
那我们登录成功之后,网站如何记录我们的身份信息呢?
前面我们在学习 servlet 的时候,知道了传统的系统主要是通过 session 来存储用户的信息。session 将用户的信息存储在服务端。
但是随着用户数量的增多,服务端就需要存一堆用户的认证信息,这种方式会不断增加服务端的压力。
如果是分布式系统,用 session 存储用户信息就太拘束了。因为分布式系统一般都会做负载均衡,如果这次认证成功了,那么意味着下次请求必须仍要访问这台服务器才能认证成功。
如果是前后端分离的系统就更难受了,因为前端代码和后端代码放在不同的服务器上,除了会增加服务器的压力,还会产生跨域等一系列问题,有点得不偿失。
那有没有一种工具能帮我们解决这些认证问题?
-
服务端不需要存储用户的认证信息
-
避免跨域
-
保证数据的安全性
JWT闪亮登场。
2.什么是 JWT
JWT 简称: JSON Web Token,又叫做 web 应用中的令牌。它可以帮助我们完成用户的认证、存储信息、加密数据等功能。
那什么是令牌呢?令牌就相当于古代的虎符。
古代将军要想调兵遣将,必须手持虎符。
而用户要想访问系统中的某些页面,在发起的请求中必须携带使用 JWT 生成的令牌。令牌校验通过了,方可访问系统。这里的令牌简称为 token。
3. JWT 的结构
注:这里所说的 JWT 的结构,指的是用 JWT 生成令牌的结构,也就是 token 的结构。
令牌的结构组成:
-
标头(Header)
-
载荷(Payload)
-
签名(Signature)
令牌最终的样子是由这三部分组成的字符串:
Header.Payload.Signature 复制代码
例如:
hjYGH1dajUU.dajhjksfiu2h27jjghg2.kjbhjkf982bhh2lk2 复制代码
3.1 标头
标头是使用 Base64 编码将令牌类型和签名算法经过加工后生成的一段字符串。
标头包含两部分:
-
令牌的类型:JWT(一般是默认的)
-
签名算法:例如 SHA256、HMAC等
{ "alg": "HS256", "typ": "JWT" } 复制代码
3.2 载荷
载荷主要存储一些自定义信息。它也是使用 Base64 编码加工后生成的一段字符串。
3.3 签名
签名是通过一个秘钥和标头中提供的算法再将标头和载荷进行加工后生成的一段字符串。例如:
4. JWT 的认证流程
-
用户点击登录,后台接收用户请求并根据账号和密码从数据库查询用户信息。用户若存在,则使用 JWT 生成 token 并返回给前台。用户若不存在,则返回错误信息。
-
前端在请求其他资源时将 token 放到请求头中。
-
后台从请求头中获取 token 信息,如果 token 校验失败,则返回错误信息。如果校验成功,就将业务数据返回给前端。
5. JWT 的使用
1.引入依赖
<dependency> <groupId>com.auth0</groupId> <artifactId>java-jwt</artifactId> <version>3.10.3</version> </dependency> 复制代码
2.生成token
public static void main(String[] args) { Date date = new Date(System.currentTimeMillis() +1000); Algorithm algorithm = Algorithm.HMAC256("!Secret"); String token = JWT.create() .withClaim("name", "张三") .withExpiresAt(date) // 设置过期时间 .sign(algorithm); // 设置签名算法 System.out.println(token); } 复制代码
生成结果:
"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9. eyJuYW1lIjoi5byg5LiJIiwiZXhwIjoxNjUwNzE1OTg3fQ. -hrxN6RwXCPnI-pmQaIsetx-8iN98XwZczmTFBoV1FI" 复制代码
-
校验 token
校验 token,其实就是比较 token 是否真确,如果不正确程序就会报错。
String token = ""; JWT.require(Algorithm.HMAC256("!Secret")).build().verify(token); 复制代码
-
获取 token 中的载荷信息
String token=" "; DecodedJWT jwt = JWT.decode(token); String name = jwt.getClaim("name").asString(); 复制代码
-
判断 token 是否过期
public boolean isExpire(String token) { DecodedJWT jwt = JWT.decode(token); // 如果token的过期时间小于当前时间,则表示已过期,为true return jwt.getExpiresAt().getTime() < System.currentTimeMillis(); } 复制代码
6. JWT 工具类
因为 JWT 的作用主要是生成 token、校验 token、获取token中存储的自定义信息,所以我们一般会把 JWT 封装成一个工具类。
public class JwtUtil { // 秘钥 private static final String SECRET = "SECRET_PRIVATE!"; private static final long TIME_UNIT = 1000; // 生成包含用户id的token public static String createJwtToken(String userId, long expireTime) { Date date = new Date(System.currentTimeMillis() + expireTime * TIME_UNIT); Algorithm algorithm = Algorithm.HMAC256(SECRET); return JWT.create() .withClaim("userId", userId) .withExpiresAt(date) // 设置过期时间 .sign(algorithm); // 设置签名算法 } // 生成包含自定义信息的token public static String createJwtToken(Map<String, String> map, long expireTime) { JWTCreator.Builder builder = JWT.create(); if (MapUtil.isNotEmpty(map)) { map.forEach((k, v) -> { builder.withClaim(k, v); }); } Date date = new Date(System.currentTimeMillis() + expireTime * TIME_UNIT); Algorithm algorithm = Algorithm.HMAC256(SECRET); return builder .withExpiresAt(date) // 设置过期时间 .sign(algorithm); // 设置签名算法 } // 校验token,其实就是比较token public static DecodedJWT verifyToken(String token) { // 如果校验失败,程序会抛出异常 return JWT.require(Algorithm.HMAC256(SECRET)).build().verify(token); } // 从token中获取用户id public static String getUserId(String token) { try { DecodedJWT jwt = JWT.decode(token); return jwt.getClaim("userId").asString(); } catch (JWTDecodeException e) { return null; } } // 从token中获取定义的荷载信息 public static String getTokenClaim(String token, String key) { try { DecodedJWT jwt = JWT.decode(token); return jwt.getClaim(key).asString(); } catch (JWTDecodeException e) { return null; } } // 判断 token 是否过期 public static boolean isExpire(String token) { DecodedJWT jwt = JWT.decode(token); // 如果token的过期时间小于当前时间,则表示已过期,为true return jwt.getExpiresAt().getTime() < System.currentTimeMillis(); } } 复制代码
7. JWT 案例
这里我们通过一个 springboot 项目来感受一下 JWT 的使用过程。
-
开发工具:IDEA
-
技术栈:SpringBoot、MyBatisPlus、JWT
-
数据库:Mysql
7.1 用户登录
7.1.1 创建 SpringBoot 项目
7.1.2 引入依赖
<!--web--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!--myql--> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <!--lombok--> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <!--mybatis plus--> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.4.0</version> </dependency> <!--引入jwt--> <dependency> <groupId>com.auth0</groupId> <artifactId>java-jwt</artifactId> <version>3.10.3</version> </dependency> <!--hutool--> <dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-all</artifactId> <version>5.5.7</version> </dependency> <!--test--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> 复制代码
7.1.3 application.yml
server: port: 8082 servlet: context-path: /jwt-demo spring: # 数据源 datasource: url: jdbc:mysql://localhost:3306/jwt_demo?allowPublicKeyRetrieval=true&useSSL=false username: root password: 12345678 driver-class-name: com.mysql.cj.jdbc.Driver # MybatisPlus mybatis-plus: global-config: db-config: field-strategy: IGNORED column-underline: true logic-delete-field: isDeleted # 全局逻辑删除的实体字段名 logic-delete-value: 1 # 逻辑已删除值(默认为 1) logic-not-delete-value: 0 # 逻辑未删除值(默认为 0) db-type: mysql id-type: assign_id mapper-locations: classpath*:/mapper/**Mapper.xml type-aliases-package: com.zhifou.entity configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl #日志 复制代码
7.1.4 创建用户表
CREATE TABLE `t_user` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT 'id', `username` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '姓名', `password` varchar(20) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '密码', PRIMARY KEY (`id`) USING BTREE ) ENGINE=InnoDB AUTO_INCREMENT=39 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC; 复制代码
7.1.5 用户实体类
@Data @NoArgsConstructor @AllArgsConstructor @Accessors(chain = true) @TableName("t_user") public class User implements Serializable { private static final long serialVersionUID = 1L; /** * id */ @TableId(value = "id", type = IdType.ASSIGN_ID) private Long id; /** * 姓名 */ private String username; /** * 密码 */ private String password; } 复制代码
7.1.6 Service 和实现类
UserService
public interface UserService extends IService<User> { /** * 登录 * @param user * @return */ User login(User user); } 复制代码 @Service public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService { @Override public User login(User user) { User userOne = this.getOne(new QueryWrapper<User>().eq("username", user.getUsername()) .eq("password", user.getPassword())); return null == userOne ? null : userOne; } } 复制代码
7.1.7 Dao 和 mapper.xml
UserMapper
public interface UserMapper extends BaseMapper<User> { } 复制代码
UserMapper.xml
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.zhifou.mapper.UserMapper"> <!-- 通用查询映射结果 --> <resultMap id="BaseResultMap" type="com.zhifou.entity.User"> <id column="id" property="id" /> <result column="username" property="username" /> <result column="password" property="password" /> </resultMap> <!-- 通用查询结果列 --> <sql id="Base_Column_List"> id, username, password, sex, age </sql> </mapper> 复制代码
7.1.8 登录接口
@PostMapping("/login") public Map<String, Object> login(@RequestBody User user) { Map<String, Object> data = new HashMap<>(); User userOne = userService.login(user); if (null != userOne) { data.put("code", 200); data.put("msg", "登陆成功"); data.put("token", JwtUtil.createJwtToken(userOne.getId().toString(), 24 * 10)); } else { data.put("code", 400); data.put("msg", "账号或者密码错误"); } return data; } 复制代码
7.1.9 测试
登录成功
登录失败
7.2 登录成功访问其他资源
用户登录成功后,我们把 token 返回给了前端。用户再次访问该网站的其他资源,我们怎么判断当前的用户和上次登录成功后的用户是同一个用户呢?
只需要两步:
-
前端:请求头中携带 token
-
后端:配置拦截器,校验 token
7.2.1 创建拦截器
MyInterceptor
/** * @Desc: * @Author: 知否技术 * @date: 下午7:11 2022/4/24 */ public class MyInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("token"); Map<String, Object> result = new HashMap<>(); try { // 校验token,校验失败会抛出异常 JwtUtil.verifyToken(token); return true; } catch (TokenExpiredException e) { e.printStackTrace(); result.put("code", "500"); result.put("msg", "token已过期"); } catch (Exception e) { e.printStackTrace(); result.put("code", "500"); result.put("msg", "token无效"); } response.setContentType("application/json;charset=UTF-8"); response.getWriter().println(JSONUtil.parse(result)); return false; } } 复制代码
7.2.2 拦截器配置类
/** * @Desc: * @Author: 知否技术 * @date: 下午7:18 2022/4/24 */ @Component public class InterceptorConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new MyInterceptor()) // 拦截所有请求 .addPathPatterns("/**") // 排除路径,比如用户登录、退出等 .excludePathPatterns("/user/login"); } } 复制代码
7.2.3 测试
token 校验失败
token 校验成功