JWT如何实现登录、鉴权

1 背景

1.1 什么是JWT

       JWT(JSON WEB TOKEN):JSON网络令牌,JWT是一个轻便的安全跨平台传输格式,定义了一个紧凑的自包含的方式在不同实体之间安全传输信息(JSON格式)。它是在Web环境下两个实体之间传输数据的一项标准。实际上传输的就是一个字符串。广义上讲JWT是一个标准的名称;狭义上JWT指的就是用来传递的那个token字符串。

1.2 JWT的作用

       由于http协议是无状态的,所以可以认为客户端和服务端的所有交互都是新的请求,这就意味着当我们通过账号密码验证用户时,当下一个request请求时它就不会携带刚刚的资料,于是程序只能再次重新识别。JWT就是实现了以JSON的格式,在客户端和服务端安全的传输供认证使用的信息。

1.3 传统方式

1.3.1 基于session身份认证方案:

基于session身份认证方案

       根据http协议,我们并不能知道是哪个用户发出的请求,所以为了让应用能识别,我们只能在服务器存储一份用户登录的信息,这份登录信息会在响应时传递给浏览器保存为cookie,以便下次请求时发送给应用,这样应用就能识别请求来自哪个用户了,这就是传统的基于session认证。
       但是session是保存到服务器内存当中的,不能跨应用服务器共享,使得应用很难扩展,随着客户端用户量增加,独立的服务器已无法承载更多的用户,这是基于session身份认证方案的问题就会暴露出来,并且这种方案存在CSRF风险,因此随着技术的发展就有了基于Token身份认证的方案去解决这些问题。

1.3.2 基于Token身份认证方案:

基于 Token 身份认证方案

       基于token的鉴权机制类似于http协议也是无状态的,它不需要在服务端去保留用户的认证信息或者会话信息。这就意味着基于token认证机制的应用不需要去考虑用户在哪一台服务器登录了,这就为应用的扩展提供了便利,另外因为用户的信息是保存在分布式缓存中,这种方式就支持分布式水平扩展,支持高并发。
       由于token是保存在Redis服务器中,使用这种方式无疑加大了对Redis缓存组件的依赖和增加了硬件资源的投资。
那么我们开始介绍JWT的特点。

1.4 基于JWT的token身份认证方案

基于JWT的token身份认证方案

       服务端验证后,将部分的用户信息存放到JWT中,也就是存在token的字符串中,比如用户的email和用户的姓名等。在鉴权的流程当中,是直接从JWT中直接获取用户信息,这样减少了对Redis缓存组件的依赖,也减少了硬件资源的投入。

优点:
安全性高,防止token被伪造和篡改
自包含,减少存储开销
跨语言,支持多种语言实现
支持过期,发布者等校验

缺点:
JWT不适用存放大量信息,会造成token过长
无法作废未过期的JWT,所以需要搭配Redis使用,达到用户登出操作token即失效的要求。

2 JWT的结构

一个JWT是一个字符串,其由Header(头部)、Payload(负载)和Signature(签名)三个部分组成,中间以.号分隔,其格式为Header.Payload.Signature。

Header:

typ顾名思义就是type的意思,例如上面这里就指明是JWT的类型。alg顾名思义是algorithm的意思,指代一个加密算法,例如上面指代HS256(HMAC-SHA256),这个算法会在生成第三部分signature的时候用到。

Payload:

Payload: 用来承载要传递的数据,它的json结构实际上是对JWT要传递的数据的一组声明,这些声明被JWT标准称为claims,它的一个“属性值对”其实就是一个claim,每一个claim的都代表特定的含义和作用。比如上面结构中的userId代表这个所有人的ID。

Signature:

Signature一般就是用一些算法生成一个能够认证身份的字符串,具体算法就是上面表示的,另外需要说明的一点是上面hash方法用到了一个secret,这个东西需要客户端和服务端双方都知道,相当于约好了同一把验证的钥匙,最终才好做认证。

按照header.payload.signature这个格式串起来,串之前注意,header和payload也要做一个base64url encoded的转换。那么最终拼出来的一个例子是:

JWT对象

3 JWT的代码实现

3.1 导入依赖

3.2 生成token



JWT指定了七个默认claims字段供选择。
iss:发行人
sub:主题
aud:用户
exp:到期时间
nbf:在此之前不可用
iat:发布时间
jti:JWT ID用于标识该JWT

除以上默认字段外,我们还可以自定义私有字段,如下例:
{
"sub":"8208208820",
"name":"hubert",
"role":"admin"
}

按照JWT标准的说明:保留的claims都是可选的,在生成payload不强制用上面的那些claim,另外你可以按照自己的想法来定义payload的结构,不过这样搞根本没必要:第一是,如果把JWT用于认证, 那么JWT标准内规定的几个claim就足够用了,假如想往JWT里多存一些用户业务信息,比如用户名(name)和角色(role)等才需要考虑添加自定义claim;第二是,JWT标准里面针对它自己规定的claim都提供了有详细的验证规则描述,每个实现库都会参照这个描述来提供JWT的验证实现,所以如果是自定义的claim名称,那么你用到的实现库就不会主动去验证这些claim。

3.3 校验token


根据源码可以发现在校验过程中主要是校验JWT字符串的格式、过期时间、加密算法正确性等。
具体校验的源码:

public Jwt parse(String jwt) throws ExpiredJwtException, MalformedJwtException, SignatureException {
    Assert.hasText(jwt, "JWT String argument cannot be null or empty.");
    String base64UrlEncodedHeader = null;
    String base64UrlEncodedPayload = null;
    String base64UrlEncodedDigest = null;
    int delimiterCount = 0;
    StringBuilder sb = new StringBuilder(128);
    char[] var7 = jwt.toCharArray();
    int var8 = var7.length;

    for(int var9 = 0; var9 < var8; ++var9) {
        char c = var7[var9];
        if (c == '.') {  // 以"."作为分隔符号切割JWT字符串
            CharSequence tokenSeq = Strings.clean(sb);
            String token = tokenSeq != null ? tokenSeq.toString() : null;
            if (delimiterCount == 0) {
                base64UrlEncodedHeader = token;
            } else if (delimiterCount == 1) {
                base64UrlEncodedPayload = token;
            }

            ++delimiterCount;
            sb.setLength(0);
        } else {
            sb.append(c);
        }
    }

    if (delimiterCount != 2) { // 判断JWT字符串是否由两部分组成
        String msg = "JWT strings must contain exactly 2 period characters. Found: " + delimiterCount;
        throw new MalformedJwtException(msg);
    } else {
        if (sb.length() > 0) {
            base64UrlEncodedDigest = sb.toString();
        }
        
        // 判断Payload信息
        if (base64UrlEncodedPayload == null) { 
            throw new MalformedJwtException("JWT string '" + jwt + "' is missing a body/payload.");
        } else {
            Header header = null;
            CompressionCodec compressionCodec = null;
            String payload;
            if (base64UrlEncodedHeader != null) {
                payload = TextCodec.BASE64URL.decodeToString(base64UrlEncodedHeader);
                Map<String, Object> m = this.readValue(payload);
                if (base64UrlEncodedDigest != null) {
                    header = new DefaultJwsHeader(m);
                } else {
                    header = new DefaultHeader(m);
                }

                compressionCodec = this.compressionCodecResolver.resolveCompressionCodec((Header)header);
            }

               /** 此处省略部分代码 */

                if (algorithm == null || algorithm == SignatureAlgorithm.NONE) { // 加密算法有没指定
                    object = "JWT string has a digest/signature, but the header does not reference a valid signature algorithm.";
                    throw new MalformedJwtException(object);
                }

                if (this.key != null && this.keyBytes != null) { // 不能同时指定两种类型的签名秘钥
                    throw new IllegalStateException("A key object and key bytes cannot both be specified. Choose either.");
                }

                /** 此处省略部分代码 */

                Assert.notNull(key, "A signing key must be specified if the specified JWT is digitally signed.");
                String jwtWithoutSignature = base64UrlEncodedHeader + '.' + base64UrlEncodedPayload;

                JwtSignatureValidator validator;
                try {
                    // 验证算法结果正确性
                    validator = this.createSignatureValidator(algorithm, (Key)key);
                } 
            }

            boolean allowSkew = this.allowedClockSkewMillis > 0L;
            if (claims != null) {
                Date now = this.clock.now();
                long nowTime = now.getTime();
                Date exp = claims.getExpiration();
                String nbfVal;
                SimpleDateFormat sdf;
                // 验证JWT设置的过期时间
                if (exp != null) {
                    long maxTime = nowTime - this.allowedClockSkewMillis;
                    Date max = allowSkew ? new Date(maxTime) : now;
                    if (max.after(exp)) {
                        sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'");
                        String expVal = sdf.format(exp);
                        nbfVal = sdf.format(now);
                        long differenceMillis = maxTime - exp.getTime();
                        String msg = "JWT expired at " + expVal + ". Current time: " + nbfVal + ", a difference of " + differenceMillis + " milliseconds.  Allowed clock skew: " + this.allowedClockSkewMillis + " milliseconds.";
                        throw new ExpiredJwtException((Header)header, claims, msg);
                    }
                }

                Date nbf = claims.getNotBefore();
                if (nbf != null) {
                    long minTime = nowTime + this.allowedClockSkewMillis;
                    Date min = allowSkew ? new Date(minTime) : now;
                    if (min.before(nbf)) {
                        sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'");
                        nbfVal = sdf.format(nbf);
                        String nowVal = sdf.format(now);
                        long differenceMillis = nbf.getTime() - minTime;
                        String msg = "JWT must not be accepted before " + nbfVal + ". Current time: " + nowVal + ", a difference of " + differenceMillis + " milliseconds.  Allowed clock skew: " + this.allowedClockSkewMillis + " milliseconds.";
                        throw new PrematureJwtException((Header)header, claims, msg);
                    }
                }

                this.validateExpectedClaims((Header)header, claims);
            }

           /** 此处省略部分代码 */
        }
    }
}

3.4 获得claims(即Payload的信息)

通过这个方法可以解析出前面保存到Payload里的claims,config.getJwtSecret()是自定义的secret 秘钥,用于加密解密。

4 实践使用建议

1 发送JWT要用https,因为JWT本身无法保证数据安全性
2 JWT的payload中不要包含太多用户信息,特别是权限角色的信息。
3 JWT的payload中建议设定一个expire时间,且不能设置太长,为什么要设置其实和cookie为什么设置过期时间一样,都是为了安全,JWT一旦生成发出去就不可以更改,在有效期内就可以永久使用。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

友情链接更多精彩内容