JWT的简单理解
什么是JWT(what)
简单介绍
官网地址: https://jwt.io/introduction/
翻译: json web token(JWT)是一个开放标准,它定义了一种紧凑的、自包含的方式,用于各方之间以JSON对象安全地传输信息。此信息可以验证和信任,因为它是数字签名的。JWT可以秘密(使用HMAC算法)或者使用RSA或者ECDSA的公钥/私钥对进行签名
通俗来讲:
JWT简称 JSON Web Token,也就是通过JSON形式作为Web应用中的令牌,用于在各之间安全地将信息作为JSON对象传输。在数据传输过程中还可以完成数据加密、签名等相关处理。
JWT能做什么
1、用户认证
这是使用JWT的最常见方案。一旦用户登录,每个后序请求将包含JWT,从而允许用户访问该令牌允许的路由、服务和资源。单点登录是当今广泛使用JWT的一项功能,因为它的开销很小并且可以在不同的域中轻松使用。
2、信息交换
JSON Web Token 是在各方之间安全地传输信息的好方法。因为可以对JWT进行签名(例如,使用公钥/私钥对),所以你可以确保发送人是他们所说的人。此外,由于数字签名是使用标头和有效负载计算的,因此你还可以验证内容是否遭到篡改。(学习过HTTPS协议的应该容易理解)
为什么使用JWT(why)
传统Session认证的弊端
我们知道HTTP本身是一种无状态的协议,这就意味着如果用户向我们的应用提供了用户名和密码来进行用户认证,认证通过后HTTP协议不会记录下认证后的状态,那么下一次请求时,用户还要再一次进行认证,因为根据HTTP协议,我们并不知道是哪个用户发出的请求,所以为了让我们的应用能识别是哪个用户发出的请求,我们只能在用户首次登录成功后,在服务器存储一份用户登录的信息,这份登录信息会在响应时传递给浏览器,告诉其保存为cookie
,以便下次请求时发送给我们的应用,这样我们的应用就能识别请求来自哪个用户了,这是传统的基于session
认证的过程
然而,传统的session认证有如下的问题:
- 每个用户的登录信息都会保存到服务器的session中,随着用户的增多,服务器开销会明显增大
- 由于session是存在与服务器的物理内存中,所以在分布式系统中,这种方式将会失效。虽然可以将session统一保存到Redis中,但是这样做无疑增加了系统的复杂性,对于不需要redis的应用也会白白多引入一个缓存中间件
- 因为session认证本质基于cookie,所以如果cookie被截获,用户很容易收到跨站请求伪造攻击(XSS)。并且如果浏览器禁用了cookie,这种方式也会失效
- 前后端分离系统中更加不适用,后端部署复杂,前端发送的请求往往经过多个中间件到达后端,cookie中关于session的信息会转发多次
- 由于基于Cookie,而cookie无法跨域,所以session的认证也无法跨域,对单点登录不适用
JWT的认证流程
JWT的本质就是一个字符串,它是将用户信息保存到一个Json字符串中,然后进行编码后得到一个JWT token,并且这个JWT token带有签名信息,接收后可以校验是否被篡改,所以可以用于在各方之间安全地将信息作为Json对象传输。JWT的认证流程如下:
- 首先,前端通过Web表单将自己的用户名和密码发送到后端的接口,这个过程一般是一个
POST
请求。建议的方式是通过SSL加密的传输(HTTPS
),从而避免敏感信息被嗅探 - 后端核对用户名和密码成功后,将包含用户信息的数据作为
JWT
的Payload
,将其与JWT Header
分别进行Base64
编码拼接后签名,形成一个JWT Token
的字符串 - 后端将
JWT Token
字符串作为登录成功的结果返回给前端。前端可以将返回的结果保存在浏览器中,退出登录时删除保存的JWT Token
即可 - 前端在每次请求时将
JWT Token
放入HTTP请求头中的Authorization
属性中(解决XSS
和XSRF
问题) - 后端检查前端传过来的
JWT Token
,验证其有效性,比如检查签名是否正确、是否过期、token
的接收方是否是自己等等 - 验证通过后,后端解析出
JWT Token
中包含的用户信息,进行其他逻辑操作(一般是根据用户信息得到权限等),返回结果
JWT认证的优势
- 简洁:JWT Token数据量小,传输速度也很快
- 因为JWT Token是以JSON加密形式保存在客户端的,所以JWT是跨语言的,原则上任何web形式都支持
- 不需要在服务端保存会话信息,也就是说不依赖于cookie和session,所以没有了传统session认证的弊端,特别适用于分布式微服务
- 单点登录友好:使用Session进行身份认证的话,由于cookie无法跨域,难以实现单点登录。但是,使用token进行认证的话, token可以被保存在客户端的任意位置的内存中,不一定是cookie,所以不依赖cookie,不会存在这些问题
JWT的结构
JWT由3部分组成:标头(Header)
、有效载荷(Payload)
和签名(Signature)
。在传输的时候,会将JWT的3部分分别进行Base64编码后用.进行连接形成最终传输的字符串
1、Header
JWT头是一个描述JWT元数据的JSON对象,alg属性表示签名使用的算法,默认为HMAC SHA256(写为HS256);typ属性表示令牌的类型,JWT令牌统一写为JWT。最后,使用Base64 URL算法将上述JSON对象转换为字符串保存
{
"alg": "HS256",
"typ": "JWT"
}
2、Payload
有效载荷部分,是JWT的主体内容部分,也是一个JSON对象,包含需要传递的数据。 JWT指定七个默认字段供选择
iss:发行人
exp:到期时间
sub:主题
aud:用户
nbf:在此之前不可用
iat:发布时间
jti:JWT ID用于标识该JWT
这些预定义的字段并不要求强制使用。除以上默认字段外,我们还可以自定义私有字段,一般会把包含用户信息的数据放到payload中,如下例:
{
"sub": "1234567890",
"name": "coderzpw",
"admin": true
}
默认情况下JWT是未加密的,因为只是采用base64算法,拿到JWT字符串后可以转换回原本的JSON数据,任何人都可以解读其内容,因此不要构建隐私信息字段,比如用户的密码一定不能保存到JWT中,以防止信息泄露。JWT只是适合在网络中传输一些非敏感的信息
3、Signature
signature由于使用了不可逆的加密算法,无法解码出原文,它的作用是校验token有没有被篡改。服务端获取header中的加密算法之后,利用该算法加上secretKey对header、payload进行加密,比对加密后的数据和客户端发送过来的是否一致。
签名
哈希部分是对上面两部分数据签名
,需要使用base64
编码后的header和payload数据,通过指定的算法生成哈希,以确保数据不会被篡改。首先,需要指定一个密钥(secret
)。然后,使用header中指定的签名算法(默认情况下为HMAC SHA256)根据以下公式生成签名
HMACSHA256(base64UrlEncode(header)+"."+base64UrlEncode(payload),secret)
在计算出签名哈希后,JWT头,有效载荷和签名哈希的三个部分组合成一个字符串,每个部分用.分隔,就构成整个JWT对象
如何在Java中使用JWT(how)
入门使用
https://jwt.io/libraries官网推荐了6个Java使用JWT的开源库,我们这里使用java-jwt
1、引入依赖
<!--JWT依赖-->
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.10.3</version>
</dependency>
2、生成token
@Test
void getToken() {
HashMap<String, Object> header = new HashMap<>();
Calendar instance = Calendar.getInstance();
instance.add(Calendar.SECOND, 60); // 60秒
String token = JWT.create()
.withHeader(header) // header (这个值默认可以不加)
.withClaim("userId", 21) // payload
.withClaim("userName", "coderzpw") // payload
.withExpiresAt(instance.getTime()) // 指定令牌过期时间
.sign(Algorithm.HMAC256("%shjhd&"));// 签名
System.out.println(token);
}
3、根据令牌、签名解析数据
@Test
void verify() {
String token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyTmFtZSI6ImNvZGVyenB3IiwiZXhwIjoxNjQ4NzE4MjI4LCJ1c2VySWQiOjIxfQ.ZQFR-4CZ-UuuCAn9iMBNU_L9wAfJbr6qr_tzExZKWt4";
// 创建验证对象
JWTVerifier verifier = JWT.require(Algorithm.HMAC256("%shjhd&")) // 这个签名要保证和生成时的一样 否则会解析错误
.build();
DecodedJWT verify = verifier.verify(token); // 若验证成功则返回一个解码之后的DecodedJWT对象, 可以根据这个对象获取对应信息
Integer userId = verify.getClaim("userId").asInt(); // 存的什么类型就指定解析什么类型 ,这点要注意,否则取出null值
String userName = verify.getClaim("userName").asString();
System.out.println("用户ID:"+userId);
System.out.println("用户名称:"+userName);
}
4、常见异常信息
AlgorithmMismatchException
: 算法不一致
SignatureVerificationException
:签名不一致
TokenExpiredException
:令牌已过期
JWT整合SpringBoot
JWT相关依赖
<!--JWT依赖-->
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.10.3</version>
</dependency>
封装一个JWTUtils工具类,包括 生成token、校验token两个方法
public class JWTUtils {
private static final String SIGN = "1&2H3J4P5G789"; // 签名
/**
* 生成token
* @return
*/
public static String getToken(Map<String, String > payload) {
Calendar instance = Calendar.getInstance();
instance.add(Calendar.DATE, 7); // 7天过期
// 创建jwt builder
JWTCreator.Builder builder = JWT.create();
// 设置payload
payload.forEach((k,v) -> {
builder.withClaim(k, v);
});
String token = builder.withExpiresAt(instance.getTime()) // 指定令牌过期时间
.sign(Algorithm.HMAC256(SIGN));// 签名
return token;
}
/**
* 解析token
* @param token token字符串
* @return 解析后的token
*/
public static DecodedJWT decode(String token){
JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256(SIGN)).build();
DecodedJWT decodedJWT = jwtVerifier.verify(token);
return decodedJWT;
}
}
创建User用户实体类
public class User {
private String id;
private String userName;
private String password;
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getUserName() {
return userName;
}
public void setUserName(String userName) {
this.userName = userName;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
}
创建一个UserService(这里我们不走数据库了,用户名、密码写死)
public interface UserService {
/**
* 模拟登录
* @param user
* @return
*/
public Boolean login(User user);
}
@Service
public class UserServiceImpl implements UserService {
/**
* 模拟登录
* @param user
* @return
*/
@Override
public Boolean login(User user) {
String userName = user.getUserName();
String password = user.getPassword();
// 模拟登录,实际开发中肯定是走的数据库,这里不是重点
return "coderzpw".equals(userName) && "1234".equals(password);
}
}
增加控制层接口方法
@RestController
public class UserController {
@Autowired
private UserService userService;
@PostMapping("/login")
public Object login(User user) {
Boolean login = userService.login(user);
HashMap<String ,Object> res = new HashMap<>();
if (login) { // 登录成功
// 将用户信息放入到payload中,后面生成jwt
HashMap<String,String> payload = new HashMap<>();
payload.put("id", user.getId());
payload.put("userName", user.getUserName());
// 生成JWT的令牌
String token = JWTUtils.getToken(payload);
res.put("code",200);
res.put("msg","登录成功!");
res.put("token", token);
} else { // 登录失败
res.put("code",500);
res.put("msg","登录失败!");
}
return res;
}
@GetMapping("/user/hello")
public Object userTest() {
return "hello哈!!!";
}
}
其中login("/login")
方法用来登录
若登录成功,则返回前端“登录成功!
” 以及 token
信息;
若登录失败,则返回“登录失败!
”信息。
userTest("/user/hello")
就是一个测试接口
自定义拦截器,拦截请求
@Component
public class JWTInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String token = request.getHeader("token");
HashMap<String, Object> map = new HashMap<>();
try {
JWTUtils.decode(token);
return true; // 验证成功,则返回true,表示放行
} catch (TokenExpiredException e) {
map.put("state", false);
map.put("msg", "Token已经过期!!!");
}catch (SignatureVerificationException e) {
map.put("state", false);
map.put("msg", "签名错误!!!");
}catch (AlgorithmMismatchException e) {
map.put("state", false);
map.put("msg", "加密算法不匹配!!!");
}catch (Exception e) {
map.put("state", false);
map.put("msg", "无效token ~~~~");
}
// jackson 将 map 转成 json字符串
String json = new ObjectMapper().writeValueAsString(map);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().println(json);
return false;
}
}
将拦截器添加到MVC配置文件中
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private JWTInterceptor jwtInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(jwtInterceptor)
.addPathPatterns("/**")
.excludePathPatterns("/login");
}
}
测试结果:
login接口
/user/hello接口
结语
其实项目实际开发中,并非单独使用JWT来实现认证这个流程。
一般流程为:
- 在登录验证通过后,给用户生成一个对应的随机token(注意这个token不是指jwt,可以用uuid等算法生成),然后将这个token作为key的一部分,用户信息作为value存入Redis,并设置过期时间,这个过期时间就是登录失效的时间
- 将第1步中生成的随机token作为JWT的payload生成JWT字符串返回给前端
- 前端之后每次请求都在请求头中的Authorization字段中携带JWT字符串
- 后端定义一个拦截器,每次收到前端请求时,都先从请求头中的Authorization字段中取出JWT字符串并进行验证,验证通过后解析出payload中的随机token,然后再用这个随机token得到key,从Redis中获取用户信息,如果能获取到就说明用户已经登录
可能有人会有疑问:
1、明明JWT已经可以存储用户信息了,为什么还要将用户信息存入到Redis中呢,这样岂不是又白白引入一个中间件?没有必要呀!
答: 其实这里引入Redis并非仅仅看中它存储用户信息的功能,最主要的是我们可以灵活的设置过期时间。引入Redis主要是为了解决JWT的一个问题:无法主动让其失效。假如不对JWT进行存储的话,在其过期之前,只要前端正确携带过来,后端的校验总是通过的,此时会认为用户一直是已登录状态。但是如果业务场景需求用户可以主动退出登录,或者管理员可以踢人下线,此时只能将JWT存储一份到redis,主动退出登录时删除redis中的数据,后端即可认为该用户已不在登录状态
2、当用户登录时校验信息通过,后端生成一个唯一编码当做key存入redis并设置过期时间。下次系统再校验用户合法性是,通过redis的key是否存在就可以完成整个流程的操作了。那么JWT是不是就没有必要了?
答: 其实只要保证key唯一,确实也可以实现认证。 但是如果引入JWT,后端可以校验token是否被篡改,如果是被篡改的,直接返回错误,无需再查询redis;如果脱离JWT,那么后端无法校验出token是否被篡改,即使被篡改也会去redis查询,此时查不到,则认为token无效。
学习视频链接:https://www.bilibili.com/video/BV1i54y1m7cP?p=6&spm_id_from=pageDriver
借鉴优秀文章链接:【JWT详解】