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一旦生成发出去就不可以更改,在有效期内就可以永久使用。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,053评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,527评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,779评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,685评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,699评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,609评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,989评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,654评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,890评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,634评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,716评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,394评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,976评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,950评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,191评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,849评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,458评论 2 342