一文搞定JWT!

背景

由于http协议是无状态的,互联网中为了区分用户以及保护用户信息,所以产生了会话管理。目前主要的会话管理实现有两种:

  • session:基于服务器存储来认证会话
  • token:基于校验token来认证会话

本文主要讲第二种token的实现方案JWT

JWT介绍

JWT全称为JSON Web Tokens。从它的名称可以看出这是一种基于json的互联网通信认证方案,一个很常见的JWT像下面这样。

1
复制代码eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

注意看一下它被两个.号分隔成了三段,第一段是header, 第二段是payload, 第三段是signature。

WechatIMG297.png
所以整体的形式是:

1
css复制代码header.payload.signature

JWT构成

header是一段base64Url编码的字符串。它的原始内容是json,通常包含两部分内容。第一部分是使用的签名算法,一般可选的有HS256(HMAC-SHA256) 、RS256(RSA-SHA256) 、ES256(ECDSA-SHA256);第二部分是固定的类型,直接就是”JWT”。
比如:

1
2
3
4
json复制代码{
"alg": "HS256", // 使用的签名算法
"typ": "JWT", // 类型,就是JWT,无需改变
}

以上json通过base64Url编码就形成了第一段header。

payload

payload 同样是一段base64Url编码的字符串,一般是用来包含实际传输数据的。payload段官方提供了7个字段可以选择。

  • iss (issuer):签发人
  • exp (expiration time):过期时间
  • sub (subject):主题
  • aud (audience):受众
  • nbf (Not Before):生效时间
  • iat (Issued At):签发时间
  • jti (JWT ID):编号

上面这些字段都不是必选的,除此之外,由于这是载荷段,用户可以添加自定义的字段。实际场景中的一个payload例子:

1
2
3
4
json复制代码{
"exp": 1620887677, // token过期时间
"userId": "xxx" // 自定义的用户id
}

signature

signature是签名字段。它是使用header中声明的签名算法,并使用一个secret(秘钥),对base64Url编码的header json 和 base64Url编码 payload json进行签名后的数据。伪代码如下

1
2
3
4
json复制代码HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)

注意secret必须保存在服务端,不能泄露。
最后将三段内容拼接起来header + “.” + paylod + “.” + signatrue, 就是一个完整的JWT token了。

JWT使用与原理

生成了jwt token之后,接下来就是如何使用了。一般来说,客户端会在通信的时候放在http的请求头
Authrization字段中,形式如下:

1
json复制代码Authorization: Bearer <token>

当然,这不是强制要求,你也可以把它放在一个自定义的头字段中。说实话我之前就对这个前缀Bearer感到好奇,为什么要加这么一个字符串?因为这个Bearer造成我取出header之后还要截取才能拿到token。后来发现有一个具体的RFC文档RFC6750对此作了约定,然而看完这个RFC文件我依然没有理解。最终我得出的一个结论是:某些框架可能是按这个协议实现的,如果你使用现成框架提取token,最好还是按约定的形式来传输;如果是自己写轮子提取的,可以无视。 另外Postman快捷添加鉴权信息也是默认的Authorization: Bearer <token>

服务端拿到token之后,根据同样的算法,将header 和 payload签名之后与signature比对来确认token的有效性。如果比对通过,就取出payload中的用户数据进行后续操作,如果不通过就认证失败。

值得注意的是,有许多朋友认为JWT token的信息都是加密的,实际上这种观点是错误的。除了signature是哈希散列值,header和payload都是可以直接解码的,前面我专门加粗标注了编码就是为了引起大家的注意,随便一个合格的token,不需要secret,使用base64Url 就能解码出header和payload看出里面的数据。token校验的过程也是一个验签的过程,而不是解密的过程。

JWT与session比较

JWT的优点

  • 认证信息保存在token中,不需要服务端存储,节约资源
  • 传输放置在请求头中,天然支持跨域携带,不存在cors问题
  • 支持分布式、集群,无扩展问题
  • 不需要cookie支持,所以不存在csrf(跨站请求伪造)问题

JWT的不足

  • token一经签发,即使用户登出,有效期内还是能使用,有一定安全风险。(可以通过减少token有效期并配合refresh token来减小风险,后续有时间再细讲)
  • token放在请求头,如果payload数据放太多的话,会导致token过长,影响包传输效率。(所以尽量少放自定义数据,我一般放个userId就够了)

session的缺点

  • 一般存储在内存中,用户量大时,占用计算机资源
  • 对于分布式、集群应用来说,需要引入组件处理,如: redis。且redis挂了可能导致整个系统认证不可用
  • 基于cookie实现,用户有禁用cookie的可能
  • 基于cookie实现,所以有csrf的问题,需要处理

session的优点

  • 框架支持友好,很多框架直接set、get就行了。
  • 登出即可失效sessionID

JWT结合springboot实践

这里提供一个快捷的springboot使用jwt完成认证的方案,详细的实现可以参考这个项目: 体验, bytemall

定义JWT工具类

pom中导入jwt包

1
2
3
4
5
6
json复制代码<!-- jwt -->
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.10.3</version>
</dependency>

定义工具类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
java复制代码@Component
public class JwtUtil {
// 读取配置的secret
@Value(value = "${jwt.secret}")
public String SECRET;

// 生成token
public String createToken(Long userId, Integer expireHours){
Algorithm algorithm = Algorithm.HMAC256(SECRET);
Map<String, Object> map = new HashMap<String, Object>();
LocalDateTime now = LocalDateTime.now();
// 过期时间:2小时
LocalDateTime expireDate = now.plusHours(expireHours);
map.put("alg", "HS256");
map.put("typ", "JWT");
return JWT.create()
// 设置头部信息 Header
.withHeader(map)
// 设置 载荷 Payload
.withClaim("userId", userId)
// 生成签名的时间
.withIssuedAt(Date.from(now.atZone(ZoneId.systemDefault()).toInstant()))
// 签名过期的时间
.withExpiresAt(Date.from(expireDate.atZone(ZoneId.systemDefault()).toInstant()))
// 签名 Signature
.sign(algorithm);
}

// 验证token
public Long verifyTokenAndGetUserId(String token) {
Algorithm algorithm = Algorithm.HMAC256(SECRET);
JWTVerifier verifier = JWT.require(algorithm).build();
DecodedJWT jwt = verifier.verify(token);
Map<String, Claim> claims = jwt.getClaims();
Claim claim = claims.get("userId");
return claim.asLong();
}
}

定义登录注解

1
2
3
4
java复制代码@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginRequire {
}

增加一个自定义ArgumentResolver

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
java复制代码@Component
public class LoginArgumentResolver implements HandlerMethodArgumentResolver {
private final Logger logger = LoggerFactory.getLogger(getClass());

// 自定义一个header来交互token
public static final String LOGIN_TOKEN_KEY = "Token";

// 注入上面定义的jwt工具
@Autowired
private JwtUtil jwtUtil;

// 重写参数支持方法
@Override
public boolean supportsParameter(MethodParameter methodParameter) {
return Long.class.isAssignableFrom(methodParameter.getParameterType()) && methodParameter.hasParameterAnnotation(LoginRequire.class);
}

// 重写参数处理方法
@Override
public Object resolveArgument(MethodParameter methodParameter, ModelAndViewContainer modelAndViewContainer, NativeWebRequest nativeWebRequest, WebDataBinderFactory webDataBinderFactory) throws Exception {
String token = nativeWebRequest.getHeader(LOGIN_TOKEN_KEY);
if (token == null || token.isEmpty()) {
throw new AuthException("没有token");
}

try {
return jwtUtil.verifyTokenAndGetUserId(token);
} catch (JWTVerificationException e) {
logger.error("token解码失败" + e.getMessage(), e);
throw new AuthException("认证失败");
}

}
}

将自定义的resolver 配置到mvcConfiguration

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码@Configuration
public class BytemallMvcConfiguration implements WebMvcConfigurer {

@Autowired
private LoginArgumentResolver loginArgumentResolver;

// 添加自定义的参数处理器
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(loginArgumentResolver);
}
}

controller中使用

登录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
java复制代码@RestController
@RequestMapping("/api/user")
public class ApiUserController {

// 注入jwt工具
@Resource
private JwtUtil jwtUtil;

@PostMapping("/login")
public Object login(@RequestBody AdminLoginParamVO adminLoginParamVO) {
String username = adminLoginParamVO.getUsername();
String password = adminLoginParamVO.getPassword();

BytemallAdmin admin = adminService.findByUsernameAndPwd(username, Md5Util.md5Hash(password));
if (admin == null) {
throw new BizException(ErrorCodeEnum.FAILED.getErrCode(), "账号或密码错误");
}

AdminLoginResultVO respInfo = new AdminLoginResultVO();
respInfo.setUsername(admin.getUsername());
// 生成token及有效期
respInfo.setToken(jwtUtil.createToken(admin.getId(), 24));
return ResponseUtil.ok(respInfo);
}
}

使用

1
2
3
4
5
6
java复制代码// 在具体的路由函数中使用定义的注解就可以了
@GetMapping("/list")
public Object userList(@LoginRequire Long userId) {
System.out.println(userId);
return "ok";
}

总结

套用一个装逼的词,会话管理没有银弹! 无论是session还是JWT都有各自优缺点,正确的做法是根据实际的业务场景需求,选择合适的方案。JWT,你学废了吗?

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

0%