一文搞定jwt

背景

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

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

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

JWT介绍

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

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

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

所以整体的形式是:

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,你学废了吗?

ps: 欢迎关注我的个人商城项目: 体验, github, gitee

微信搜索公众号「Frank的梦呓」,回复「100pdf」获取100本计算机相关电子书!!!

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值