简介
JSON Web Token(JWT)是一种开放标准(RFC 7519),它使用一种紧凑且自包含的方式在各方之间作为JSON对象安全地传输信息。此信息经过数字签名,可以被验证和信任。JWT可以使用密钥(使用HMAC算法)或使用RSA或ECDSA的公钥/私钥对进行签名。
使用场景
JWT的主要使用场景:
Authorization (授权) : 这是使用 JWT 的最常见场景。一旦用户登录,后续每个请求都将包含JWT,允许用户访问该令牌允许的路由、服务和资源。单点登录是现在JWT广泛使用的场景,因为它的开销很小,并且可以轻松地跨域使用。
Information Exchange (信息交换) : 由于JWT中包含签名,因此可以在传输信息中进行安全控制,主要是进行身份确认和防止内容被篡改。
组成结构
JSON Web Token由三部分组成,它们之间用圆点(.)连接。这三部分分别是:
- Header
- Payload
- Signature
JWT看起来格式如下:
xxxxx.yyyyy.zzzzz
例如:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
Header
JWT 的 Header 包含两部分信息:
- 声明类型,统一是 JWT。
- 声明加密算法,可以使用HMAC SHA256或者RSA等。
例如:
{
"alg": "HS256",
"typ": "JWT"
}
后,用Base64对这个JSON编码就得到JWT的第一部分。
Payload
Payload 是存放有效信息的地方。这些有效信息包含三个部分
-
标准中注册的声明(Registered claims)。这些是一组预定义的声明,它们不是强制性的,而是推荐的,以提供一组有用的、可互操作的声明。包括:
- iss: JWT签发者。
- sub: JWT所面向的用户。
- aud: 接收JWT的一方。
- exp: JWT的过期时间,这个过期时间必须要大于签发时间。
- nbf: 定义在什么时间之前,该JWT都是不可用的。
- iat: JWT的签发时间。
- jti: JWT的唯一身份标识,主要用来作为一次性Token,从而回避重放攻击。
公共的声明(Public claims)。公共的声明可以添加任何的信息,一般添加用户的相关信息或其他业务需要的必要信息。为了避免冲突,建议参考 IANA JSON Web Token Registry 进行定义,或者将它们定义为包含防冲突命名空间的URI。
私有的声明(Private claims)。私有声明是使用各方共同定义的声明。
Payload示例:
{
"sub": "1234567890",
"name": "John Doe",
"admin": true
}
有效负载经过 Base64 编码,形成 JWT 的第二部分。
注意,不要将未经加密的敏感信息放在JWT的Payload或Header中。
Signature
JWT的第三部分是一个签证信息。这个部分需要base64加密后的Header和base64加密后的Payload使用 .
连接组成的字符串,然后通过Header中声明的加密方式进行加盐secret组合加密,然后就构成了JWT的第三部分。
密钥secret是保存在服务端的,服务端会根据这个密钥进行生成token和进行验证,所以需要保护好。
工作方式
一般是在请求头里加入Authorization,并加上Bearer标注(只要各方约定好即可,不一定非要按此方式):
fetch('api/user/1', {
headers: {
'Authorization': 'Bearer ' + token
}
})
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中包含的用户信息,进行其他逻辑操作(一般是根据用户信息得到权限等),返回结果
优势
传统基于 Session 方式的弊端
在使用 JWT 之前,一般使用基于Session的认证,即我们在用户首次登录成功后,在服务器存储一份用户登录的信息,这份登录信息会在响应时传递给浏览器,告诉其保存为cookie,以便下次请求时发送给我们的应用,这样我们的应用就能识别请求来自哪个用户。
这种方式的弊端是:
每个用户的登录信息都会保存到服务器的Session中,随着用户的增多,服务器开销会明显增大。
由于Session是存在与服务器的物理内存中,所以在分布式系统中,这种方式将会失效。虽然可以将Session统一保存到Redis中,但是这样做无疑增加了系统的复杂性,对于不需要Redis的应用也会白白多引入一个缓存中间件。
对于非浏览器的客户端、手机移动端等不适用,因为Session依赖于Cookie,而移动端经常没有Cookie。
因为Session认证本质基于Cookie,所以如果Cookie被截获,用户很容易收到跨站请求伪造攻击。并且如果浏览器禁用了Cookie,这种方式也会失效。
前后端分离系统中更加不适用,后端部署复杂,前端发送的请求往往经过多个中间件到达后端,Cookie中关于Session的信息会转发多次。
由于基于Cookie,而Cookie无法跨域,所以Session的认证也无法跨域,对单点登录不适用。
JWT的优势
对比传统的session认证方式,JWT的优势是:
简洁:JWT Token数据量小,传输速度也很快。
因为JWT Token是以JSON加密形式保存在客户端的,所以JWT是跨语言的,原则上任何web形式都支持。
不需要在服务端保存会话信息,也就是说不依赖于Cookie和Session,所以没有了传统Session认证的弊端,特别适用于分布式微服务。
单点登录友好:使用Session进行身份认证的话,由于Cookie无法跨域,难以实现单点登录。但是,使用Token进行认证的话, Token可以被保存在客户端的任意位置的内存中,不一定是Cookie,所以不依赖Cookie,不会存在这些问题.
适合移动端应用:使用Session进行身份认证的话,需要保存一份信息在服务器端,而且这种方式会依赖到Cookie(需要 Cookie 保存 SessionId),所以不适合移动端。
实战
下面做一个 SpringBoot 集成JWT的实战。
创建项目
创建一个 SpringBoot 项目,引入 JWT 依赖。
org.springframework.boot
spring-boot-starter-parent
2.3.1.RELEASE
org.springframework.boot
spring-boot-starter-web
com.auth0
java-jwt
3.4.0
com.fasterxml.jackson.core
jackson-core
2.11.0
开发
定义一个用来进行权限验证的注解。
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface UserLoginToken {
boolean required() default true;
}
定义一个用户实体。
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;
}
}
编写 token 的生成方法。Algorithm.HMAC256():使用HS256生成token,密钥则是用户的密码,withAudience()存入需要保存在token的信息,这里把用户ID存入了token中。
@Service
public class TokenService {
// 根据用户生成token
public String getToken(User user) {
String token = "";
token = JWT.create().withAudience(user.getId())
.sign(Algorithm.HMAC256(user.getPassword()));
return token;
}
}
编写一些用户方法。由于是demo,也没有数据库,这里就随便写了下。
@Service
public class UserService {
public User findUserById(String id) {
User user = new User();
user.setId("1");
user.setUsername("wyk");
user.setPassword("password");
return user;
}
public User findByUserName(User user) {
user.setId("1");
return user;
}
}
接下来需要写一个拦截器去获取 Token并验证 Token。
public class AuthInterceptor implements HandlerInterceptor {
@Autowired
UserService userService;
@Override
public boolean preHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse,
Object object) throws Exception {
// 设置返回编码,这个应该有单独的地方设置,本项目为了省事放到了这里
httpServletResponse.setCharacterEncoding("UTF-8");
// 从 http 请求头token字段中取出token
// 这个字段应该是约定的,可以使用其他的字段名
String token = httpServletRequest.getHeader("token");
// 如果不是映射到方法直接通过
if(!(object instanceof HandlerMethod)) {
return true;
}
HandlerMethod handlerMethod = (HandlerMethod)object;
Method method = handlerMethod.getMethod();
// 检查有没有需要用户权限的注解
if(method.isAnnotationPresent(UserLoginToken.class)) {
UserLoginToken userLoginToken = method.getAnnotation(UserLoginToken.class);
if(userLoginToken.required()) {
// 执行认证
if(token == null) {
httpServletResponse.getWriter().write("未发现token,请重新登录");
return false;
}
// 获取 token 中的 user id
String userId = "";
try {
userId = JWT.decode(token).getAudience().get(0);
} catch(JWTDecodeException e) {
httpServletResponse.getWriter().write("token不正确");
return false;
}
User user = userService.findUserById(userId);
if(user == null) {
httpServletResponse.getWriter().write("用户不存在");
return false;
}
//验证 token
JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256(user.getPassword())).build();
try {
jwtVerifier.verify(token);
} catch(JWTVerificationException e) {
httpServletResponse.getWriter().write("token校验失败");
return false;
}
return true;
}
}
return true;
}
@Override
public void postHandle(HttpServletRequest httpServletRequest,
HttpServletResponse httpServletResponse,
Object o, ModelAndView modelAndView) throws Exception {
}
@Override
public void afterCompletion(HttpServletRequest httpServletRequest,
HttpServletResponse httpServletResponse,
Object o, Exception e) throws Exception {
}
}
这里主要实现了 HandlerInterceptor
接口的 preHandle
方法。它是预处理回调方法,实现处理器的预处理,第三个参数为响应的处理器,自定义Controller,返回值为true表示继续流程(如调用下一个拦截器或处理器)或者接着执行 postHandle()
和 afterCompletion()
;false表示流程中断,不会继续调用其他的拦截器或处理器,中断执行。
再配置一下拦截器。
@Configuration
public class InterceptorConfig implements WebMvcConfigurer {
// 添加拦截器
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(authInterceptor())
.addPathPatterns("/**");
}
// 生成拦截器实例
@Bean
public AuthInterceptor authInterceptor() {
return new AuthInterceptor();
}
}
最好实现外部调用的接口。/login
是登陆接口,/getMessage
使用来测试权限校验效果的接口。
@RestController
public class UserController {
@Autowired
UserService userService;
@Autowired
TokenService tokenService;
@PostMapping("/login")
public Object login(@RequestBody User user) {
ObjectMapper objectMapper = new ObjectMapper();
ObjectNode node = objectMapper.createObjectNode();
User userForBase = userService.findByUserName(user);
if(userForBase == null) {
node.put("message", "登陆失败,用户不存在");
return node;
} else {
if(!userForBase.getPassword().equals(user.getPassword())) {
node.put("message", "登陆失败,密码错误");
return node;
} else {
String token = tokenService.getToken(userForBase);
node.put("token", token);
node.put("username", userForBase.getUsername());
node.put("id", userForBase.getId());
return node;
}
}
}
@UserLoginToken
@GetMapping("getMessage")
public String getMessage() {
return "通过验证!";
}
}
测试
运行项目,在未登陆的情况下尝试访问 /getMessage
,结果如下。
使用 /login
登录,获取 Token 。
在请求报文头中添加 Token,再次访问/getMessage
。
访问成功!
参考文章:
- Introduction to JSON Web Tokens
- 什么是 JWT -- JSON WEB TOKEN
- SpringBoot集成JWT实现token验证
- JWT详解