JWT 的使用
1.什么是 JWT?
JWT (JSON Web Token) 是一种基于 Token 的认证授权机制。JWT 有很强的自解释能力,自身包含了身份验证所需要的所有信息。
JWT(JSON Web Token)由三部分组成,本质上就是一个字符串:
这三部分用 . 分割,每一个部分都是一个 JSON 对象,结构是:{}.{}.{}。JSON 对象经过编码后,表现出来的形式是:xxx.yyy.zzz。
- Header:头部。头部由两部分组成:令牌的类型和使用的签名算法。比如:算法是"HS256",类型是"JWT"。头部的内容会通过 Base64 编码成字符串,Base64 是一种编码规则,不是加密方式,可以通过 Base64 解码得到头部原本的内容。
json
{
"alg": "HS256",
"typ": "JWT"
}
- Payload:负载。负载用来保存身份验证需要的信息,同样是通过 Base64 编码将一个 JSON 对象变成字符串。并且,因为 Base64 可以被解码,所以不建议在负载中存放隐私数据,只用来存放一般性的数据。
json
{
"userId": "1959649015717580800"
}
- Signature:签名。Header 和 Payload 是通过 Base64 进行编码的,可以被解码获取里面的内容。而 Signature 是通过编码后的 Header 和 Payload 以及指定的密钥,通过 Header 中指定的签名算法进行签名得到的。\(Signature = 签名算法 ( Base64(Header), Base64(Payload), 密钥 )\)。
Signature 可以用来检查 token 是否被篡改。
已知 Signature = 签名算法 ( Base64(Header), Base64(Payload), 密钥 ),相当于 y = f(x1, x2, x3) 。
- 如果前端提交过来的 Header 或者 Payload 被修改,也就是 Base64(Header) 和 Base64(Payload) 发生改变,则 y* = f(x1*, x2*, x3),后端计算得到的 Signature* != 前端提交过来的 Signature,请求不会放行。
- 如果前端提交过来的 Signature 被修改,也就是 Signature*,则 y = f(x1, x2, x3),后端计算得到的 Signature != Signature*,请求不会放行。
2.JWT 基本使用
引入依赖:
xml
<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.12.6</version>
</dependency>
java
@Test
void generateJWT() {
// 密钥
String key = "qwertyuiopasdfghjklzxcvbnm2345678sdfghjkxdft7823edrfvgh";
// 负荷
HashMap<String, Object> claims = new HashMap<>();
claims.put("userId", 1959649015717580800L);
// 根据提供的字节数组长度选择适当的算法,并返回SecretKey实例
SecretKey secretKey = Keys.hmacShaKeyFor(key.getBytes(StandardCharsets.UTF_8));
// 两小时内有效
Date date = new Date(System.currentTimeMillis() + 60 * 60 * 2 * 1000);
String token = Jwts.builder()
.claims(claims) // 在payload中存入userId
.signWith(secretKey) // 签名加密
.expiration(date) // 有效时间
.compact();
// JWT字符串作为token
// eyJhbGciOiJIUzM4NCJ9.eyJ1c2VySWQiOjE5NTk2NDkwMTU3MTc1ODA4MDAsImV4cCI6MTc1NjE0Nzk4Mn0.JNJVZuDGrHT99tkHBkzTS83zDORbKlWnqvq3riLK8W1PgFYZieYUnBUfZSHR0F31
System.out.println(token);
}
java
@Test
void parseJWT() {
String key = "qwertyuiopasdfghjklzxcvbnm2345678sdfghjkxdft7823edrfvgh";
String token = "eyJhbGciOiJIUzM4NCJ9.eyJ1c2VySWQiOjE5NTk2NDkwMTU3MTc1ODA4MDAsImV4cCI6MTc1NjE0Nzk4Mn0.JNJVZuDGrHT99tkHBkzTS83zDORbKlWnqvq3riLK8W1PgFYZieYUnBUfZSHR0F31";
// 获取SecretKey实例
SecretKey secretKey = Keys.hmacShaKeyFor(key.getBytes(StandardCharsets.UTF_8));
JwtParser parser = Jwts
.parser()
// 用SecretKey加密,同样用SecretKey解密,解密不通过则
.verifyWith(secretKey)
.build();
// 解析token
Claims payload = parser
.parseSignedClaims(token)
.getPayload();
Long userId = (Long) payload.get("userId");
if (userId == null)
throw new RuntimeException();
// 1959649015717580800
System.out.println(userId);
}
3.SpringBoot 集成 JWT 身份验证
下面是一个简单的 demo,演示如何在 SpringBoot 项目中使用 JWT 进行身份验证:
- 当用户访问登录、注册等接口时,不需要验证用户身份。
- 用户登录成功后,返回一个 JWT 作为 token,保存用户的 userId。
- 用户访问其他接口时,需要携带一个自定义请求头 Authorization,Authorization 保存的值就是 token。
- 请求到达后端会经过拦截器(或者过滤器),在拦截器中检查是否携带 token,以及 token 是否有效。如果检查不通过,则不放行该请求。
3.1准备工作
创建 JWT 工具类:
java
public class JwtUtil {
/**
* 生成token
* @param key 密钥
* @param expired 过期时间
* @param map payload保存的数据
* @return
*/
public static String generateToken(String key, long expired, Map<String, Object> map) {
String token = Jwts.builder()
.claims(map)
.signWith(Keys.hmacShaKeyFor(key.getBytes(StandardCharsets.UTF_8)))
.expiration(new Date(System.currentTimeMillis() + expired))
.compact();
return token;
}
/**
* 解析token
* @param token
* @param key
* @return
*/
public static Claims parseToken(String token, String key) {
JwtParser jwtParser = Jwts
.parser()
.verifyWith(Keys.hmacShaKeyFor(key.getBytes(StandardCharsets.UTF_8)))
.build();
Claims claims = jwtParser
.parseSignedClaims(token)
.getPayload();
return claims;
}
}
将 JWT 需要使用的参数放在 application.yml 中方便管理和配置,同时创建一个 properties 读取 application.yml 中的配置:
yml
jwt:
# 密钥
secret-key: qwertyuiopasdfghjklzxcvbnm2345678sdfghjkxdft7823edrfvgh
# 有效时间,毫秒
expired: 7200000
# 请求头名称
authorization: Authorization
java
@Data
@Component
@ConfigurationProperties(prefix = "jwt")
public class JwtProperties {
private String secretKey;
private long expired;
private String authorization;
}
编写简易接口,测试身份验证是否实现:
java
@RestController
@RequestMapping("/api")
@Validated
public class DemoController {
@Autowired
private DemoService demoService;
/**
* 用户登录,不需要身份验证
*
* @param username
* @param password
* @return
*/
@PostMapping("/login")
public Result<String> login(@NotBlank(message = "用户名不能为空") String username, @NotBlank(message = "密码不能为空") String password) {
String token = demoService.login(username, password);
return Result.ok(token);
}
/**
* 用户注销账号,需要进行身份验证
*
* @return
*/
@DeleteMapping("/remove")
public Result remove() {
// 空实现,仅用来测试身份验证是否生效
return Result.ok(null);
}
}
java
@Service
public class DemoService {
@Autowired
private UserMapper userMapper;
@Autowired
private JwtProperties jwtProperties;
public String login(String username, String password) {
// 检查用户是否存在
User user = userMapper.getByUsername(username);
if (user == null)
throw new RuntimeException("用户不存在");
// 检查密码是否正确
String originPassword = user.getPassword(); // 原密码
password = DigestUtils.md5DigestAsHex(password.getBytes(StandardCharsets.UTF_8)); // md5加密
if (!originPassword.equals(password))
throw new RuntimeException("密码不正确");
// 生成jwt
HashMap<String, Object> map = new HashMap<>();
map.put("userId", user.getUserId());
return JwtUtil.generateToken(jwtProperties.getSecretKey(), jwtProperties.getExpired(), map);
}
}
登录成功返回的数据如下,前端获取 token 后每次发起请求都会携带 token。
json
{
"code": 200,
"msg": null,
# eyJhbGciOiJIUzM4NCJ9: Header
# eyJ1c2VySWQiOjE5NTk2NDksImV4cCI6MTc1NjIwNjE4Nn0: Payload
# ckfbYSF7GMT4gYJpla-4-P8dWEgoj6z6BcGTEMO29zzdpkP6dKhtTak5WExwGWMM: Signature
# 三个部分用 . 分隔
"data": "eyJhbGciOiJIUzM4NCJ9.eyJ1c2VySWQiOjE5NTk2NDksImV4cCI6MTc1NjIwNjE4Nn0.ckfbYSF7GMT4gYJpla-4-P8dWEgoj6z6BcGTEMO29zzdpkP6dKhtTak5WExwGWMM"
}
3.2拦截器拦截请求
创建拦截器,在请求到达后端时,检查是否携带 token 并校验 token 是否是一个正确的 JWT 字符串。
java
@Component
public class JwtInterceptor implements HandlerInterceptor {
@Autowired
private JwtProperties jwtProperties;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
try {
// 获取token,Authorization请求头中保存了token
String token = request.getHeader(jwtProperties.getAuthorization());
// 解析token,获取userId
Claims claims = JwtUtil.parseToken(token, jwtProperties.getSecretKey());
Integer userId = (Integer) claims.get("userId");
// 保存userId并放行
UserContext.setUserId(userId);
return true;
} catch (Exception e) {
// 401:认证失败
response.setStatus(401);
return false;
}
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 移除ThreadLocal中的数据
UserContext.removeUserId();
}
}
login 请求不会被拦截,可以顺利登录,得到 token;但是 remove 请求会被拦截,可以使用 Debug 观察。
java
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Autowired
private JwtInterceptor jwtInterceptor;
/**
* 向Spring MVC框架注册自定义拦截器
*
* @param registry
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(jwtInterceptor)
// addPathPatterns: 定义拦截的请求路径
.addPathPatterns("/**")
// excludePathPatterns: 定义直接放行,不拦截的请求路径
.excludePathPatterns("/api/login");
// .addPathPatterns("/api/user/**"): 拦截所有以 /api/user 为前缀的请求
// .excludePathPatterns("/api/user/logout"): 在拦截 /api/user/** 请求的情况下,如果是发向 /api/user/logout 的请求不会被拦截
}
}
3.3过滤器拦截请求
过滤器和拦截器在这里的作用都一样,都是拦截请求。但是拦截器需要注册到 Spring MVC 框架中,过滤器只需要成为一个 Bean 即可,但是需要在过滤器中设置白名单,也就是不需要检查的路径。
java
@Component
public class JwtFilter implements Filter {
@Autowired
private JwtProperties jwtProperties;
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
// 登录请求直接放行
if ("/api/login".equals(request.getRequestURI())) {
chain.doFilter(request, response);
return;
}
try {
// 获取token
String token = request.getHeader(jwtProperties.getAuthorization());
// 解析token
Claims claims = JwtUtil.parseToken(token, jwtProperties.getSecretKey());
Integer userId = (Integer) claims.get("userId");
// 保存userId并放行
UserContext.setUserId(userId);
chain.doFilter(request, response);
} catch (Exception e) {
// 401:认证失败
response.setStatus(401);
}
}
}