什么是JWT
在介绍JWT之前,我们先来回顾一下利用token进行用户身份验证的流程:
- 客户端使用用户名和密码请求登录
- 服务端收到请求,验证用户名和密码
- 验证成功后,服务端会签发一个token,再把这个token返回给客户端
- 客户端收到token后可以把它存储起来,比如放到cookie中
- 客户端每次向服务端请求资源时需要携带服务端签发的token,可以在cookie或者header中携带
- 服务端收到请求,然后去验证客户端请求里面带着的token,如果验证成功,就向客户端返回请求数据
这种基于token的认证方式相比传统的session认证方式更节约服务器资源,并且对移动端和分布式更加友好。其优点如下:
- 支持跨域访问:cookie是无法跨域的,而token由于没有用到cookie(前提是将token放到请求头中),所以跨域后不会存在信息丢失问题
- 无状态:token机制在服务端不需要存储session信息,因为token自身包含了所有登录用户的信息,所以可以减轻服务端压力
- 更适用CDN:可以通过内容分发网络请求服务端的所有资料
- 更适用于移动端:当客户端是非浏览器平台时,cookie是不被支持的,此时采用token认证方式会简单很多
- 无需考虑CSRF:由于不再依赖cookie,所以采用token认证方式不会发生CSRF,所以也就无需考虑CSRF的防御
而JWT就是上述流程当中token的一种具体实现方式,其全称是JSON Web Token,官网地址:https://jwt.io/
通俗地说,JWT的本质就是一个字符串,它是将用户信息保存到一个Json字符串中,然后进行编码后得到一个JWT token,并且这个JWT token带有签名信息,接收后可以校验是否被篡改,所以可以用于在各方之间安全地将信息作为Json对象传输。
JWT认证流程
- 首先,前端通过Web表单将自己的用户名和密码发送到后端的接口,这个过程一般是一个POST请求。建议的方式是通过SSL加密的传输(HTTPS),从而避免敏感信息被嗅探
- 后端核对用户名和密码成功后,将包含用户信息的数据作为JWT的Payload,将其与JWT Header分别进行Base64编码拼接后签名,形成一个JWT Token,形成的JWT Token就是一个如同lll.zzz.xxx的字符串
- 后端将JWT Token字符串作为登录成功的结果返回给前端。前端可以将返回的结果保存在浏览器中,退出登录时删除保存的JWT Token即可
- 前端在每次请求时将JWT Token放入HTTP请求头中的Authorization属性中(解决XSS和XSRF问题)
- 后端检查前端传过来的JWT Token,验证其有效性,比如检查签名是否正确、是否过期、token的接收方是否是自己等等
- 验证通过后,后端解析出JWT Token中包含的用户信息,进行其他逻辑操作(一般是根据用户信息得到权限等),返回结果
流程图
JWT结构
JWT由3部分组成:标头(Header)、有效载荷(Payload)和签名(Signature)。在传输的时候,会将JWT的3部分分别进行Base64编码后用.进行连接形成最终传输的字符串J W T S t r i n g = B a s e 64 ( H e a d e r ) . B a s e 64 ( P a y l o a d ) . H M A C S H A 256 ( b a s e 64 U r l E n c o d e ( h e a d e r ) + " . " + b a s e 64 U r l E n c o d e ( p a y l o a d ) , s e c r e t ) JWTString = Base64(Header).Base64(Payload).HMACSHA256(base64UrlEncode(header) + “.” + base64UrlEncode(payload), secret) JWTString=Base64(Header).Base64(Payload).HMACSHA256(base64UrlEncode(header)+“.”+base64UrlEncode(payload),secret)
1.Header
JWT头是一个描述JWT元数据的JSON对象,alg属性表示签名使用的算法,默认为HMAC SHA256(写为HS256);typ属性表示令牌的类型,JWT令牌统一写为JWT。最后,使用Base64 URL算法将上述JSON对象转换为字符串保存
{
"alg": "HS256",
"typ": "JWT"
}
2.Payload
JWT头是一个描述JWT元数据的JSON对象,alg属性表示签名使用的算法,默认为HMAC SHA256(写为HS256);typ属性表示令牌的类型,JWT令牌统一写为JWT。最后,使用Base64 URL算法将上述JSON对象转换为字符串保存
iss:发行人
exp:到期时间
sub:主题
aud:用户
nbf:在此之前不可用
iat:发布时间
jti:JWT ID用于标识该JWT
这些预定义的字段并不要求强制使用。除以上默认字段外,我们还可以自定义私有字段,一般会把包含用户信息的数据放到payload中,如下例:
{
"sub": "1234567890",
"name": "Helen",
"admin": true
}
请注意,默认情况下JWT是未加密的,因为只是采用base64算法,拿到JWT字符串后可以转换回原本的JSON数据,任何人都可以解读其内容,因此不要构建隐私信息字段,比如用户的密码一定不能保存到JWT中,以防止信息泄露。JWT只是适合在网络中传输一些非敏感的信息
3Signature
签名哈希部分是对上面两部分数据签名,需要使用base64编码后的header和payload数据,通过指定的算法生成哈希,以确保数据不会被篡改。首先,需要指定一个密钥(secret)。该密码仅仅为保存在服务器中,并且不能向用户公开。然后,使用header中指定的签名算法(默认情况下为HMAC SHA256)根据以下公式生成签名H M A C S H A 256 ( b a s e 64 U r l E n c o d e ( h e a d e r ) + " . " + b a s e 64 U r l E n c o d e ( p a y l o a d ) , s e c r e t ) HMACSHA256(base64UrlEncode(header) + “.” + base64UrlEncode(payload), secret) HMACSHA256(base64UrlEncode(header)+“.”+base64UrlEncode(payload),secret)在计算出签名哈希后,JWT头,有效载荷和签名哈希的三个部分组合成一个字符串,每个部分用.分隔,就构成整个JWT对象
注意JWT每部分的作用,在服务端接收到客户端发送过来的JWT token之后:
- header和payload可以直接利用base64解码出原文,从header中获取哈希签名的算法,从payload中获取有效数据
- signature由于使用了不可逆的加密算法,无法解码出原文,它的作用是校验token有没有被篡改。服务端获取header中的加密算法之后,利用该算法加上secretKey对header、payload进行加密,比对加密后的数据和客户端发送过来的是否一致。注意secretKey只能保存在服务端,而且对于不同的加密算法其含义有所不同,一般对于MD5类型的摘要加密算法,secretKey实际上代表的是盐值
实际开发中应用
1.引入依赖
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.10.3</version>
</dependency>
2.JwtUtil工具类
public class JwtUtil {
/**
* token私钥,不可以暴露
*/
public static final String TOKEN_SECRET_KEY = "tokenSecretKey";
/**
* token过期时间(秒)
*/
public static final int EXPIRE_TIME = 5;
/**
* 创建token
*
* @param userId 用户ID
* @param userName 用户名
* @return token
*/
public static String createToken(String userId, String userName) {
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.SECOND, EXPIRE_TIME);
String token = JWT.create()
//签发对象
.withAudience(userId + "")
//载荷
.withClaim("token", userName)
//签发时间
.withIssuedAt(new Date())
//有效时间
.withExpiresAt(calendar.getTime())
//加密
.sign(Algorithm.HMAC256(TOKEN_SECRET_KEY));
return token;
}
/**
* 验证token合法性
*
* @param token token
* @return token是否合法
*/
public static DecodedJWT verify(String token) {
//验证token合法性
DecodedJWT verify = JWT.require(Algorithm.HMAC256(TOKEN_SECRET_KEY)).build().verify(token);
// 获取解析后的token中的payload信息
Claim userId = verify.getClaim("userId");
Claim userName = verify.getClaim("userName");
System.out.println(userId.asInt());
System.out.println(userName.asString());
// 输出超时时间
Date expiresAt = verify.getExpiresAt();
System.out.println(expiresAt);
return verify;
}
/**
* 通过token获取userId
*
* @return userId
*/
public static Long getUserIdByToken(HttpServletRequest request) {
String token = request.getHeader("123456789");
String userId = JWT.decode(token).getAudience().get(0);
return Long.valueOf(userId);
}
}
3.定义拦截器
在实际的SpringBoot项目中,一般我们可以用如下流程做登录:
- 在登录验证通过后,给用户生成一个对应的随机token(注意这个token不是指jwt,可以用uuid等算法生成),然后将这个token作为key的一部分,用户信息作为value存入Redis,并设置过期时间,这个过期时间就是登录失效的时间
- 将第1步中生成的随机token作为JWT的payload生成JWT字符串返回给前端
- 前端之后每次请求都在请求头中的Authorization字段中携带JWT字符串
- 后端定义一个拦截器,每次收到前端请求时,都先从请求头中的Authorization字段中取出JWT字符串并进行验证,验证通过后解析出payload中的随机token,然后再用这个随机token得到key,从Redis中获取用户信息,如果能获取到就说明用户已经登录
@Slf4j
public class AuthInterceptor implements HandlerInterceptor {
@Autowired
private RedisTemplate redisTemplate;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
log.info("接口拦截..............");
String token = request.getHeader("Authorization");
try {
// 1.校验JWT字符串
DecodedJWT decodedJWT = JwtUtil.verify(token);
// 2.取出JWT字符串载荷中的随机token,从Redis中获取用户信息
Claim claim = decodedJWT.getClaim("token");
User user = (User)redisTemplate.opsForValue().get(claim.asString());
if(user == null){
throw new BusinessException(400,"登录失效!");
}
//对用户信息进行操作
return true;
}catch (SignatureVerificationException e){
System.out.println("无效签名");
e.printStackTrace();
}catch (TokenExpiredException e){
System.out.println("token已经过期");
e.printStackTrace();
}catch (AlgorithmMismatchException e){
System.out.println("算法不一致");
e.printStackTrace();
}catch (Exception e){
System.out.println("token无效");
e.printStackTrace();
}
return false;
}
}
4.登录测试
@Autowired
private RedisTemplate redisTemplate;
@Override
public String login(User user, HttpServletResponse httpServletResponse){
//创建一个uuid
String uuid = String.valueOf(UUID.randomUUID());
System.out.println("uuid:"+uuid);
user.setUserId("admin");
user.setUserName("测试");
user.setPassword("123456");
//放入缓存当中,设置到期时间 24小时 后过期
redisTemplate.opsForValue().set(uuid,user,60*60*24, TimeUnit.SECONDS);
//生成Jwt
String jwt = JwtUtil.createToken(user.getUserId(), uuid);
return jwt;
}
5.拦截器配置
@Configuration
public class AuthConfig implements WebMvcConfigurer {
@Bean
public AuthInterceptor initAuthInterceptor(){
return new AuthInterceptor();
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
//addPathPatterns:哪些路径下的会被拦截 /api/getTokenByTime
//excludePathPatterns:哪些路径下不会被拦截 /api/insertRedisTest
registry.addInterceptor(initAuthInterceptor()).addPathPatterns("/api/getTokenByTime").addPathPatterns("/users/loginFilter").excludePathPatterns("/api/insertRedisTest");
}
}