背景
由于http协议是无状态的,互联网中为了区分用户以及保护用户信息,所以产生了会话管理。目前主要的会话管理实现有两种:
- session:基于服务器存储来认证会话
- token:基于校验token来认证会话
本文主要讲第二种token的实现方案JWT
JWT介绍
JWT全称为JSON Web Tokens。从它的名称可以看出这是一种基于json的互联网通信认证方案,一个很常见的JWT像下面这样。
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
复制代码
注意看一下它被两个.
号分隔成了三段,第一段是header, 第二段是payload, 第三段是signature。
所以整体的形式是:
header.payload.signature
复制代码
JWT构成
header
header是一段base64Url编码的字符串。它的原始内容是json,通常包含两部分内容。第一部分是使用的签名算法,一般可选的有HS256(HMAC-SHA256) 、RS256(RSA-SHA256) 、ES256(ECDSA-SHA256);第二部分是固定的类型,直接就是”JWT”。
比如:
{
"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例子:
{
"exp": 1620887677, // token过期时间
"userId": "xxx" // 自定义的用户id
}
复制代码
signature
signature是签名字段。它是使用header中声明的签名算法,并使用一个secret(秘钥),对base64Url编码的header json 和 base64Url编码 payload json进行签名后的数据。伪代码如下
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)
复制代码
注意secret必须保存在服务端,不能泄露。
最后将三段内容拼接起来header + “.” + paylod + “.” + signatrue, 就是一个完整的JWT token了。
JWT使用与原理
生成了jwt token之后,接下来就是如何使用了。一般来说,客户端会在通信的时候放在http的请求头
Authrization字段中,形式如下:
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包
<!-- jwt -->
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.10.3</version>
</dependency>
复制代码
定义工具类
@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();
}
}
复制代码
定义登录注解
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginRequire {
}
复制代码
增加一个自定义ArgumentResolver
@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
@Configuration
public class BytemallMvcConfiguration implements WebMvcConfigurer {
@Autowired
private LoginArgumentResolver loginArgumentResolver;
// 添加自定义的参数处理器
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(loginArgumentResolver);
}
}
复制代码
controller中使用
登录
@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);
}
}
复制代码
使用
// 在具体的路由函数中使用定义的注解就可以了
@GetMapping("/list")
public Object userList(@LoginRequire Long userId) {
System.out.println(userId);
return "ok";
}
复制代码
总结
套用一个装逼的词,会话管理没有银弹! 无论是session还是JWT都有各自优缺点,正确的做法是根据实际的业务场景需求,选择合适的方案。JWT,你学废了吗?