JWT是什么?如何使用?
- 前言
- 什么是JWT?
- 概念
- 工作方式
- JWT的组成
- Header
- Payload
- Signatrue
- 实战
- 引入依赖
- 自定义注解
- 定义实体类
- 定义一个JWT工具类
- 业务校验并生成token
- 定义拦截器
- 配置拦截器
- 定义接口方法并添加注解
- 开始验证
- 使用场景
- 注意事项
- JWT与传统session认证的区别
前言
搜索这篇文章的小伙伴一定对【基于传统的session认证方式】有所了解,这里就不过多介绍了,我们直奔主题。
基于传统的session认证方式有几个显著的缺点:
- 每一个登录的用户都需要在服务端存储一个session信息,一般都存储在服务器内存中,当用户数量过多,会过度占用服务器内存,也会增加服务器压力。
- 对于分布式系统,还要需要解决共享session的问题。
那么,谁能够解决传统session认证方式存在的问题呢,他就是JWT。
什么是JWT?
概念
JWT(JSON WEB TOKEN):它是一种紧凑、安全的表示双方之间传输声明的方法。JWT是一个包含头部(Header)、负载(Payload)和签名(Signature)的JSON对象。JWT可用于认证和授权用户,它们是自包含的,意味着验证它们所需的所有信息都包含在令牌本身中。
-
紧凑型:数据体积小,可通过POST请求参数或HTTP请求头发送。
-
自包含:JWT 包含了主体的所有信息,避免了每个请求都需要向 Uaa 服务验证身份,降低了服务器的负载。
工作方式
- 客户端登录时将用户信息传递给服务器,服务器使用用户信息通过密钥创建JWT,服务器不需要进行存储,直接将JWT返回给浏览器。
- 客户端浏览器拿到JWT 后,存储在浏览器中,对于以后的每次请求,都不需要再通过授权服务来判断该请求的用户 以及该用户的权限。在微服务系统中,可以利用 JWT 实现单点登录。
认证流程图如下:
JWT的组成
我们先来看看实际的JWT长什么样子:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
很复杂,看不懂是不是?其实这一串是经过加密之后的密文字符串,中间通过.来分割。每个.之前的字符串分别表示JWT的三个组成部分:头部(Header)、负载(Payload)和签名(Signature)。
Header
Header的主要作用是用来标识。通常是两部分组成:
- typ:type 的简写,令牌类型,也就是JWT。
- alg:Algorithm 的简写,加密签名算法。一般使用HS256,jwt官网提供了12种的加密算法,截图如下:
Header的明文示例:
{"alg": "HS256","typ": "jwt"
}
经过Base64编码之后的明文,变为:
eyJhbGciOiJIUzI1NiIsInR5cCI6Imp3dCJ9
也就是第一个.之前的密文串。以下是Header部分常用部分的声明:
Payload
也称为JWT claims,放置需要传输的信息,有三类:
- 保留claims:主要包括iss发行者、exp过期时间、sub主题、aud用户等。
- 公共claims:定义新创的信息,比如用户信息和其他重要信息。
- 私有claims:用于发布者和消费者都同意以私有的方式使用的信息。
以下是Payload的官方定义内容:
Payload明文示例:
{"sub": "12344321","name": "Mars酱", // 私有claims"iat": 1516239022
}
经过Base64加密之后的明文,变为:
eyJzdWIiOiIxMjM0NDMyMSIsIm5hbWUiOiJNYXJz6YWxIiwiaWF0IjoxNTE2MjM5MDIyfQ
也就是第一个.和第二个. 之间的密文串内容。
Signatrue
Signature 部分是对Header和Payload两部分的签名,作用是防止 JWT 被篡改。这个部分的生成规则主要是是公式(伪代码)是:
Header中定义的签名算法(base64编码(header) + "." + base64编码(payload), secret
)
secret是存放在服务端加密使用到的盐。
得到签名之后,把Header的密文、Payload的密文、Signatrue的密文按顺序拼接成为一个字符串,中间通过.来连接并分割,整个串就是JWT了。
实战
这里使用SpringBoot框架。
引入依赖
引入JWT依赖,由于是基于Java,所以需要的是java-jwt。
<dependency> <groupId>com.auth0</groupId> <artifactId>java-jwt</artifactId> <version>3.5.0</version>
</dependency>
自定义注解
在这一步,我们在annotation包下定义一个用户需要登录才能进行其他接口访问等一系列操作的注解TokenRequired。
@Target({ElementType.METHOD,ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface TokenRequired { boolean required() default true;
}
@Target旨意为我们自定义注解@TokenRequired的作用目标,因为我们本次注解的作用目标为方法层级,因此使用 ElementType.METHOD。
@Retention旨意为我们自定义注解 @TokenRequired的保留位置,@TokenRequired的保留位置被定义为RetentionPolicy.RUNTIME这种类型的注解将被JVM保留,他能在运行时被JVM或其他使用反射机制的代码所读取和使用。
定义实体类
在entity包中,我们使用lombok,简单自定义一个实体类User。
@Data
@AllArgsConstructor
@NoArgsConstructorpublic
class User {String Id;String username;String password;
}
定义一个JWT工具类
在这一步,我们在util包下面创建一个JwtUtil工具类,用于生成token和校验token。
public class JwtUtil {//过期时间15分钟 private static final long EXPIRE_TIME = 15*60*1000; //生成签名,15分钟后过期 public static String sign(String username,String userId,String password){ //过期时间 Date date = new Date(System.currentTimeMillis() + EXPIRE_TIME); //使用用户密码作为私钥进行加密 Algorithm algorithm = Algorithm.HMAC256(password); //设置头信息 HashMap<String, Object> header = new HashMap<>(2); header.put("typ", "JWT"); header.put("alg", "HS256"); //附带username和userID生成签名 return JWT.create().withHeader(header).withClaim("userId",userId) .withClaim("username",username).withExpiresAt(date).sign(algorithm); }//校验token public static boolean verity(String token,String password){ try { Algorithm algorithm = Algorithm.HMAC256(password); JWTVerifier verifier = JWT.require(algorithm).build(); verifier.verify(token); return true; } catch (IllegalArgumentException e) { return false; } catch (JWTVerificationException e) { return false; } }
}
业务校验并生成token
在service包下,我们创建一个UserService,并定义一个login方法,用于做登录接口的业务层数据校验,并调取JwtUtil中方法生成token。
@Service("UserService")
public class UserService {
@Autowired UserMapper userMapper; public String login(String name, String password) {String token = null;try {//校验用户是否存在 User user = userMapper.findByUsername(name);if (user == null) {ResultDTO.failure(new ResultError(UserError.EMP_IS_NULL_EXIT));} else {//检验用户密码是否正确 if (!user.getPassword().equals(password)) {ResultDTO.failure(new ResultError(UserError.PASSWORD_OR_NAME_IS_ERROR));} else {// 生成token,将 user id 、userName保存到 token 里面 token = JwtUtil.sign(user.getUsername(), user.getId(), user.getPassword());}}} catch (Exception e) {e.printStackTrace();}return token;}
}
Algorithm.HMAC256():使用HS256生成token,密钥则是用户的密码,唯一密钥的话可以保存在服务端。
withAudience()存入需要保存在token的信息,这里我把用户ID存入token中。
定义拦截器
接下来我们需要写一个拦截器去获取token并验证token。
public class AuthenticationInterceptor implements HandlerInterceptor {@AutowiredUserService userService;@Overridepublic boolean preHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object object) throws Exception {// 从 http 请求头中取出 tokenString token = httpServletRequest.getHeader("token");// 如果不是映射到方法直接通过if (!(object instanceof HandlerMethod)) {return true;}HandlerMethod handlerMethod = (HandlerMethod) object;Method method = handlerMethod.getMethod();//检查有没有需要用户权限的注解if (method.isAnnotationPresent(TokenRequired.class)) {TokenRequired userLoginToken = method.getAnnotation(TokenRequired.class);if (userLoginToken.required()) {// 执行认证if (token == null) {throw new RuntimeException("无token,请重新登录");}// 获取 token 中的 user idString userId;try {userId = JWT.decode(token).getClaim("userId").asString();} catch (JWTDecodeException j) {throw new RuntimeException("401");}User user = userService.findUserById(userId);if (user == null) {throw new RuntimeException("用户不存在,请重新登录");}// 验证 tokentry {if (!JwtUtil.verity(token, user.getPassword())) {throw new RuntimeException("无效的令牌");}} catch (JWTVerificationException e) {throw new RuntimeException("401");}return true;}}return true;}@Overridepublic void postHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, ModelAndView modelAndView) throws Exception {}@Overridepublic void afterCompletion(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, Exception e) throws Exception {}
}
AuthenticationInterceptor拦截器实现了HandlerInterceptor接口的三个方法:
-
boolean preHandle ():
预处理回调方法,实现处理器的预处理,第三个参数为响应的处理器,自定义Controller返回值,返回值为true会调用下一个拦截器或处理器,或者接着执行postHandle()和afterCompletion();false表示流程中断,不会继续调用其他的拦截器或处理器,中断执行。 -
void postHandle():
后处理回调方法,实现处理器的后处理(DispatcherServlet进行视图返回渲染之前进行调用),此时我们可以通过modelAndView对模型数据进行处理或对视图进行处理,modelAndView也可能为null。 -
void afterCompletion():
整个请求处理完毕回调方法,该方法也是需要当前对应的Interceptor的preHandle()的返回值为true时才会执行,也就是在DispatcherServlet渲染了对应的视图之后执行。用于进行资源清理。
该拦截器的执行流程为:
- 从 http 请求头中取出 token;
- 检查有没有需要用户权限的注解,如果需要,检验token是否为空;
- 如果token不为空,查询用户信息并校验token;
- 校验通过,则进行业务访问处理,校验失败则返回token失效信息。
配置拦截器
在配置类上添加了注解@Configuration,标明了该类是一个配置类并且会将该类作为一个SpringBean添加到IOC容器内。
@Configuration
public class InterceptorConfig implements WebMvcConfigurer {@Beanpublic AuthenticationInterceptor authenticationInterceptor() {return new AuthenticationInterceptor();}@Overridepublic void addInterceptors(InterceptorRegistry registry) {// 将我们上步定义的实现了HandlerInterceptor接口的拦截器实例authenticationInterceptor添加InterceptorRegistration中,并设置过滤规则,所有请求都要经过authenticationInterceptor拦截。registry.addInterceptor(authenticationInterceptor()).addPathPatterns("/**");}
}
WebMvcConfigurer接口是Spring内部的一种配置方式,采用JavaBean的形式来代替传统的xml配置文件来实现基本的配置需要。
InterceptorConfig内的addInterceptor需要一个实现HandlerInterceptor接口的拦截器实例,addPathPatterns方法用于设置拦截器的过滤路径规则。
在addInterceptors方法中,我们将第6步定义的实现了HandlerInterceptor接口的拦截器实例authenticationInterceptor,添加至InterceptorRegistration中,并设置过滤路径。现在,我们所有请求都要经过authenticationInterceptor的拦截,拦截器authenticationInterceptor通过preHandle方法的业务过滤,判断是否有@TokenRequired 来决定是否需要登录。
定义接口方法并添加注解
@RestController
@RequestMapping("user")
public class UserController {@AutowiredUserService userService;/*** 用户登录 * @param user * @return*/@PostMapping("/login")public ResultDTO login(User user) {String token = userService.login(user.getUsername(), user.getPassword());if (token == null) {return ResultDTO.failure(new ResultError(UserError.PASSWORD_OR_NAME_IS_ERROR));}Map<String, String> tokenMap = new HashMap<>();tokenMap.put("token", token);return ResultDTO.success(tokenMap);}@TokenRequired@GetMapping("/hello")public String getMessage() {return "你好哇,我是小码仔";}
}
不加注解的话默认不验证,登录接口一般是不验证的。所以我在getMessage()中加上了登录注解,说明该接口必须登录获取token后,在请求头中加上token并通过验证才可以访问。
开始验证
我在代码中对getMessage()添加了@TokenRequired注解,此刻访问该方法时必须要通过登录拿取到token值,并在请求头中添加token才可以访问。我们现在做以下校验:
-
直接访问,不在请求头里添加token:
如上图所示,请求结果显示:无token,请重新登录。 -
访问登录接口,获取token,并在请求头中添加token信息:
此时,访问成功。 -
15分钟后,token失效,我们再次在请求头中添加token信息访问:
此时token已失效,返回:无效的令牌。
使用场景
- 授权:这是使用 JWT 的最常见的使用场景。用户登录后,每个后续请求都将包含 JWT,允许用户访问使用该令牌允许的路由、服务和资源。单点登录是当今广泛使用 JWT 的一项功能,因为它的开销很小,并且能够跨不同域轻松使用,还能解决CSRF问题。
- 信息交换:JWT是在各方之间安全传输信息的比较便捷的方式。由于 JWT 可以签名(例如,使用公钥/私钥对),因此可以确定发送者是否是在您的授权范围之内。并且,由于签名是使用标头和有效负载计算的,因此还可以验证内容是否未被篡改。
注意事项
- 不要存储敏感信息:JWT的Payload是可解码的,不应存储敏感信息,一般存储用户id。
- 保护密钥:确保用于签名JWT的密钥安全且不可预测。
- 设置合理的过期时间:避免JWT有效期过长。
- HTTPS:在通过网络发送JWT时应使用HTTPS,以保护其免受中间人攻击。
JWT与传统session认证的区别
- 工作原理不同:
- Session 机制依赖于服务器端的存储。当用户首次登录时,服务器会创建一个会话,并生成一个唯一的会话 ID,然后将这个 ID 返回给客户端(通常是通过Cookie)。客户端在后续的请求中会携带这个会话 ID,服务器根据会话ID来识别用户并获取其会话信息;
- JWT 是一种无状态的认证机制,它通过在客户端存储令牌(Token)来实现认证。当用户登录时,服务器会生成一个包含用户信息和有效期的 JWT,并将其返回给客户端。客户端在后续的请求中会携带这个 JWT,服务器通过验证 JWT 的有效性来识别用户。
- 存储方式不同:
- Session 信息存储在服务器端,通常是保存在内存或数据库中。这种方式需要服务器维护会话状态,因此在分布式系统或微服务架构中,会话信息的共享和同步可能会成为问题;
- JWT信息存储在客户端,通常是保存在浏览器的本地存储或 HTTP 请求的头部中。这种方式无需服务器维护会话状态,使得 JWT 在分布式系统或微服务架构中更加灵活和易于扩展。
- 有效期和灵活性不同:
- Session 的有效期通常由服务器控制,并且在会话期间用户状态可以在服务器端动态改变。但这也意味着服务器需要管理会话的生命周期;
- JWT 的有效期可以在令牌生成时设置,并且可以在客户端进行缓存和重复使用。这使得 JWT 在需要频繁访问资源且不需要频繁更改用户状态的场景中更加适用。此外,JWT 还支持在令牌中包含自定义的用户信息,提供了更大的灵活性。