学习笔记跟随 哔哩哔哩
一、什么是 JWT?
JWT 及 JSON Web Token,是通过 JSON 形式作为 Web 应用中的令牌,用于在各方之间将信息作为 JSON 对象传输,在传输过程中还可以完成数据加密,签名等处理
二、JWT 能做什么?
2.1 授权
这是使用 JWT 的最常见方案,一旦用户登录,每个后端请求将包括 JWT,从而允许用户访问该令牌允许的路由,服务和资源,单点登录是当今广泛使用的 JWT 的一项功能,因为它的开销很小并且可以在不同的域中轻松和使用 (使用 JWT 允许访问哪些服务器资源)
2.2 信息交换
使用 JWT 可以在各方面之间安全地传输信息,因此可以对 JWT 进行签名(例如:公钥/私钥),所以你可以确保发件人是他们所说的人。此外由于前面是使用标头和有效复杂计算的。因此还可以检验内容是否被篡改
三、为什么是 JWT?
3.1 传统基于 Session 认证
传统的认证方式,我们需要经过 http 协议(一种无状态的协议),这样的话用户就向我们提供了 用户名 和 密码来进行用户认证。那么下一次请求时,用户还要再一次进行用户认证才行。因为使用了 http 协议,我们无法知道是哪个用户发出的请求,所以我们只能在服务端存储一份用户登录的信息。用户的登录信息会在响应时传递给客户端,告诉其保存为 cookie 。以便下次请求时发送给我们的应用,这样我们的应用就能识别请求来自哪个用户,这是传统的基于 session 的认证
3.1.1 传统认证流程
- 用户给服务端发送请求,并携带登录信息时
- 服务端会给用户开启一个会话 session,然后会生成一个 sessionID
- sessionID 会保存在客户端的 cookie 中
3.1.2 基于 session 认证暴露问题
- 每个用户经过我们的应用认证之后我们的应用都要在服务端做一次记录,以便下次请求鉴别,一般而言 session 都是保存在内存找那个,而随着认证用户的增多,服务端的开销就会明显增大。
- 用户认证之后,服务端做认证记录,如果认证记录保存在内存的话,如果用户下次请求还在这台服务器上。这样才拿的到授权资源,这样在分布式的应用中,相应的限制了浮在均衡的能力,这也就意味限制了这台应用的扩展能力。
- 因为是基于 cookie 来进行用户识别的,cookie 如果被恶意截获,用户就会很容易受到扩展请求伪造攻击。
- 如果在前后端分离的系统中就会更加痛苦,
- session 集群共享
- CSRF 攻击
- 增加部署复杂性
- 服务器压力增大
3.2 基于 JWT 认证
- 基于客户端令牌的认证
- 令牌不存在服务端
- 解决了 session 服务端占用的问题
3.2.1 认证流程
- 前端通过 Web 表单将自己的用户名和密码发送到后端接口,这一过程一般是一个 HTTP POST 请求,建议使用 https 协议,从而避免敏感信息被嗅探
- 后端核对用户名和密码成功后,将用户 id 等其他信息作为 JWT Payload(负载),将其与头部分别进行 Base64 编码拼接后签名。形成一个 JWT。形成的 JWT是一个形同 lll.zzz.xxx 的字符串。(token:head.payload.singurater)
- 后端将 JWT 字符串作为登录成功的返回结果给前端。前端可以将结果保存在 localStroage 或 sessionStorage 上,退出登录时删除保存的 JWT 即可。
- 前端在每次请求时将 JWT 放入 HTTP Header 中的 Authorization 位。(解决 XSS 和 XSRF 问题)
- 后端检查是否存在,如存在验证 JWT 的有效性。例如,检查签名是否正确;检查 Token 是否过期;检查 Token 的接收方式是否是自己(可选)
- 验证通过后后端使用 JWT 中包含用户信息进行其他的逻辑操作,返回相应结果
3.2.2 JWT 优势
- 简介(Compact):可以通过 URL,POST 参数或者在 HTTP header发送。因为数据量小传输速度很快
- 自包含(self-contained):负载中包含了所有用户所需的重要信息,避免了多次查询数据库(自己包含自己的信息)
- 因为 Token 是以 JSON 加密的形式保存在客户端的,所以 JWT 是跨语言的。原则上任何 Web 都支持
- 不需要在服务端保存会话信息,特别适用于分布式微服务
四、JWT 结构是什么
4.1 令牌构成
token string ===> header.payload.singnature
- 标头(Header)
- 有效载荷(Payload)
- 签名(Signature)
JWT 通常如下所示 xxx.yyyy.zzzz Header.Payload.Signature
4.2 Header
- 标头通常由两部分组成:令牌的类型 (即 JWT) 和所用的签名算法,例如 HMAC、SHA256或 RSA,它会使用 Base64 编码组成 JWT 结果的第一部分
- 注意:Base64 是一种编码,也就是说,它是可以被翻译回来原来的样子的。它并不是一种加密过程
{
"alg":"HS256",
"typ":"JWT"
}
4.3 Payload
- 令牌的第二部分是有效负载,其中包含声明,声明是有关实体(通常是用户)和其他数据的声明。同样的,它会使用 Base 64 编码组成 JWT 结构的第二部分
- 这里不要放敏感的信息
{
"sub": "1234567890",
"name": "John Smith",
"admin": true
}
4.4 Signature
- 如果前面两部分都使用 Base64 进行编码的,即前端可以解开知道里面的信息,Signature 需要使用编码后的 header he payload 以后我们提供一个密钥,然后使用 header 中指定的签名算法(HS256)进行签名,签名的作用是保证 JWT 没有被篡改过
- 如:
HMACSHA256 (base64UrlEncode(header) + “.” + base64UrlEncode(payload) ,secret) ;
HMACSHA256 (base64UrlEncode(header) + “.” + base64UrlEncode(payload) ,secret) ;
4.5 签名的目的
- 最后一步签名的过程,实际上是对头部以及负载内容进行签名,防止内容被篡改。如果有人对头部以及负载的内容编码之后进行修改,再进行编码,最后加上之前的签名组合形成新的 JWT 得花,那么服务器端会判断出新的头部和负载形成的 JWT 附带上的签名是不一致的。签名不一致就能防止信息被篡改了
- 因此,在 JWT 中不得加入敏感数据。一般传入用户 ID,用户名等等。但是密码不行
未编码的 JWT
4.6 组合在一起
- 输出的三个由点分割的 Base64-URL 字符串,可以在 HTML 和 HTTP 中轻松传递这些字符串,与基于 XML 标准(如SAML)相比,它更紧凑
- 简介(Compact)
- 可以通过 URL,POST 参数或者在 HTTP header 发送,因为数据量小,传输速度快
- 自包含(Self-contained)
- 负载中包含了所有用户所需要的信息,避免了多次查数据库
五、使用 JWT
5.1 导入依赖
<!-- 引入 JWT 依赖 -->
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.8.0</version>
</dependency>
5.2 生成 token
@Test
void contextLoads() {
Calendar instance = Calendar.getInstance();
instance.add(Calendar.SECOND,3600); // 一个小时后过期
HashMap<String,Object> map = new HashMap<>();
// 这里 Chain 可以放多个,也可以放数组
String token = JWT.create()
.withHeader(map) //header 可省略
.withClaim("name",21)//payload
.withClaim("username","John")
.withExpiresAt(instance.getTime())// 令牌的过期时间
.sign(Algorithm.HMAC256("saws123daweda"));// 签名
System.out.println(token);
}
5.3 根据令牌和签名解析数据
// 验证对象
@Test
public void checkJWT() {
// 创建验证对象
JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256("saws123daweda")).build();
// 得到 解码的 JWT
DecodedJWT verify = jwtVerifier.verify("eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoyMSwiZXhwIjoxNTk3MjkzODk0LCJ1c2VybmFtZSI6IkpvaG4ifQ.eIKgUXUQiHqLwkbI-SK5SxzGAcE5wKP_hho7vtMtu_4");
System.out.println(verify.getHeader());
System.out.println(verify.getClaim("name").asInt());
System.out.println(verify.getClaim("username").asString());
System.out.println(verify.getClaims()); // 只能得到后者的 Clain
System.out.println(verify.getPayload());
System.out.println(verify.getSignature());
}
5.4 常见异常信息
- SignatureVerificationException 签名不一致异常
- TokenExpiredException 令牌过期异常
- AlgorithmMismatchException 算法不匹配异常
- InvalidClaimExcption 失效的 payload 异常
六、JWT 封装工具类
下面整合的时候会用到这个
package cn.gorit.util;
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTCreator;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.interfaces.DecodedJWT;
import java.util.Calendar;
import java.util.Map;
public class JWTUtil {
private static final String SINGNATURE = "1Q2w3e43ken!2345dd";
/**
* 生成 token header.payload.sing
* @param map 传入 payload
* @return 返回加密后的 token
*/
public static String getToken(Map<String,String> map) {
Calendar instance = Calendar.getInstance();
instance.add(Calendar.DATE,7); // 默认七天过期
// 创建 JWT builder
JWTCreator.Builder builder = JWT.create();
// builder.withHeader("");
// payload
map.forEach((k,v)-> {
builder.withClaim(k,v);
});
// 指定过期时间, 并返回加密后的签名
return builder.withExpiresAt(instance.getTime())
.sign(Algorithm.HMAC256(SINGNATURE));
}
/**
* 验证 token,有异常会抛出异常,没有异常就返回 Token
* @param token
*/
public static DecodedJWT verifyToekn(String token) {
return JWT.require(Algorithm.HMAC256(SINGNATURE)).build().verify(token);
}
/**
* 获取 token 信息的方法
*/
// public static DecodedJWT getTokenInfo(String token) {
// DecodedJWT verify = JWT.require(Algorithm.HMAC256(SINGNATURE)).build().verify(token));
// return verify;
// }
}
七、整合 SpringBoot + MyBatis + JWT
我们使用一个登陆的案例,实现 JWT 生成以及 API 访问加强的工作(必须携带 token 才能访问)
7.1 pom 依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 引入 JWT 依赖 -->
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.8.0</version>
</dependency>
<!-- 引入 mybatis -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.1.3</version>
</dependency>
<!-- 引入 druid -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
<version>1.1.23</version>
</dependency>
<!-- mysql -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.10</version>
</dependency>
7.2 application.yml 配置文件
spring:
datasource:
driver-class-name: com.mysql.jdbc.Driver
type: com.alibaba.druid.pool.DruidDataSource
url: jdbc:mysql://localhost:3306/test?characterEncoding=UTF-8
username: root
password: root
mybatis:
type-aliases-package: cn.gorit.entity
7.3 数据库配置
7.4 MVC 三层编写
dao 层,我们只用实现一个登陆的查询功能
@Mapper
@Repository
public interface UserDAO {
// 直接读取 user 的字段
@Select("select * from user where username=#{username} and password = #{password}")
User login(User user);
}
service 层,调用 dao 层的访问,
============= UserService 编写
public interface UserService {
User login(User user);
}
============= UserServiceImpl
@Service
@Transactional
public class UserServiceImpl implements UserService {
@Autowired
private UserDAO dao;
@Override
@Transactional(propagation = Propagation.SUPPORTS)
public User login(User user) {
// 根据接收的用户名,密码查询数据库
User userDB = dao.login(user);
if (userDB != null)
return userDB;
throw new RuntimeException("登录失败");
}
}
controller 层
@RestController
public class UserController {
@Autowired
private UserService userService;
@GetMapping("/")
public String index() {
return "Hello World";
}
@GetMapping("/user/login")
public Map<String,Object> login(User user) {
Map<String,Object> map = new HashMap<>();
try {
User userDB = userService.login(user);
//生成 JWT 令牌
Map<String,String> payload = new HashMap<>();
payload.put("username",user.getUsername());
payload.put("userId",String.valueOf(userDB.getId()));
String token = JWTUtil.getToken(payload); // 生成 token 用到了上面的 工具类
map.put("state",true);
map.put("msg","登录成功~");
map.put("code",200);
map.put("token",token); // 响应 token 并保存到客户端
} catch (Exception e) {
map.put("state",false);
map.put("msg","认证失败");
}
return map;
}
// 访问这个接口需要验证 token
@PostMapping("/user/test")
public Map<String,Object> test(String token) {
Map<String,Object> map = new HashMap<>();
System.out.println("当前 token:"+ token);
try {
// 验证令牌
DecodedJWT verify = JWTUtil.verifyToekn(token);
map.put("state",true);
map.put("msg","请求成功");
return map;
} catch (SignatureVerificationException e) {
e.printStackTrace();
map.put("msg", "无效签名");
} catch (TokenExpiredException e) {
e.printStackTrace();
map.put("msg", "token 失效");
} catch (AlgorithmMismatchException e) {
e.printStackTrace();
map.put("msg","两次算法不一致");
} catch (Exception e) {
e.printStackTrace();
map.put("msg", "token 无效");
}
map.put("state",false);
return map;
}
}
7.5 运行效果
直接访问 /user/test
用户登录获得 token
带 token 访问
7.6 问题分析
- 当我们需要 token 的接口越来越多的时候,需要自己不断拦截异常,这样接口数量一多,就会造成代码冗余的情况
- 使用拦截器优化
八、使用拦截器优化项目结构
8.1 编写 JWTInteceptor 拦截器
将上面 Controller 中一系列的 try catch 取出来,放到拦截器当中
package cn.gorit.interceptors;
import cn.gorit.util.JWTUtil;
import com.auth0.jwt.exceptions.AlgorithmMismatchException;
import com.auth0.jwt.exceptions.SignatureVerificationException;
import com.auth0.jwt.exceptions.TokenExpiredException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.HashMap;
import java.util.Map;
/**
* 自定义 JWT 拦截器, 拦截器的实现
*/
public class JWTInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 获取请求头中的令牌
String token = request.getHeader("token");
Map<String,Object> map = new HashMap<>();
try {
// 验证令牌
JWTUtil.verifyToekn(token);
map.put("state",true);
map.put("msg","请求成功");
return true; //放行请求
} catch (SignatureVerificationException e) {
e.printStackTrace();
map.put("msg", "无效签名");
} catch (TokenExpiredException e) {
e.printStackTrace();
map.put("msg", "token 失效");
} catch (AlgorithmMismatchException e) {
e.printStackTrace();
map.put("msg","两次算法不一致");
} catch (Exception e) {
e.printStackTrace();
map.put("msg", "token 无效");
}
map.put("state",false);// 设置状态
// 将 map 转为 JSON,并传给前端 jackson
String json = new ObjectMapper().writeValueAsString(map);
response.setContentType("application/json;charset=UTF-8");;
response.getWriter().print(json);
return false;
}
}
8.2 编写 MVC 拦截器配置类
package cn.gorit.config;
import cn.gorit.interceptors.JWTInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* 拦截器的配置类
*/
@Configuration
public class InterceptorConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new JWTInterceptor())
.addPathPatterns("/**") // 其他接口 token 验证
.excludePathPatterns("/user/**"); // 所有用户都放行
}
}
8.3 重写 controller
package cn.gorit.controller;
import cn.gorit.entity.User;
import cn.gorit.service.UserService;
import cn.gorit.util.JWTUtil;
import com.auth0.jwt.exceptions.AlgorithmMismatchException;
import com.auth0.jwt.exceptions.SignatureVerificationException;
import com.auth0.jwt.exceptions.TokenExpiredException;
import com.auth0.jwt.interfaces.DecodedJWT;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.HashMap;
import java.util.Map;
@RestController
public class UserController {
@Autowired
private UserService userService;
@GetMapping("/")
public String index() {
return "Hello World";
}
@GetMapping("/user/login")
public Map<String,Object> login(User user, HttpServletResponse response) {
Map<String,Object> map = new HashMap<>();
try {
User userDB = userService.login(user);
//生成 JWT 令牌
Map<String,String> payload = new HashMap<>();
payload.put("username",user.getUsername());
payload.put("userId",String.valueOf(userDB.getId()));
String token = JWTUtil.getToken(payload);
map.put("state",true);
map.put("msg","登录成功~");
map.put("code",200);
map.put("token",token); // 响应 token 并保存到客户端, 自动保存到请求头中
} catch (Exception e) {
map.put("state",false);
map.put("msg","认证失败");
}
return map;
}
// 测试的请求接口,需要自己手动将登录得到的 token 添加到 header 当中
@PostMapping("/user/test")
public Map<String,Object> test(HttpServletRequest request) {
Map<String,Object> map = new HashMap<>();
String token = request.getHeader("token");
DecodedJWT verify = JWTUtil.verifyToekn(token);
System.out.println(verify.getClaim("userId").asString());
System.out.println(verify.getClaim("username").asString());
// 处理自己的业务逻辑
map.put("state",true);
map.put("msg","请求成功");
return map;
}
}
8.4 测试
登录,拿到 token
使用测试接口,没有带 token 时
带了 token
访问 根路径也要加上 token