概述

  1. 回顾登录的流程:

  2. 接下来的问题是:这个出入证(令牌)里面到底存啥?一种比较简单的办法就是直接存储用户信息的 JSON 串,这会造成下面的几个问题:

    • 非浏览器环境,如何在令牌中记录过期时间
    • 如何防止令牌被伪造
  3. JWT 就是为了解决这些问题出现的:

    • JWT 全称 Json Web Token ,本质就是一个字符串;
    • 它要解决的问题,就是在互联网环境中,提供 统一的、安全的 令牌格式;
    • 因此 jwt 只是一个令牌格式而已,你可以把它存储到 cookie ,也可以存储到 localstorage ,没有任何限制!
    • 同样的,对于传输,可以使用任何传输方式来传输 jwt ,一般来说,会使用消息头来传输它,比如,当登录成功后,服务器可以给客户端响应一个 jwt
  4. 可以看到 jwt 令牌可以出现在响应的任何一个地方,客户端和服务器自行约定即可;

    当然,它也可以出现在响应的多个地方,比如为了充分利用浏览器的 cookie ,同时为了照顾其他设备,也可以让 jwt 出现在 set-cookieauthorizationbody 中,尽管这会增加额外的传输量;

令牌的组成

  1. 为了保证令牌的安全性,jwt 令牌由三个部分组成,分别是:

    • header:令牌头部,记录了整个令牌的类型和签名算法;
    • payload:令牌负荷,记录了保存的主体信息,比如你要保存的用户信息就可以放到这里;
    • signature:令牌签名,按照头部固定的签名算法对整个令牌进行签名,该签名的作用是:保证令牌不被伪造和篡改;
  2. 它们组合而成的完整格式是:header.payload.signature,比如一个完整的 jwt 令牌如下:

    eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIiLCJpYXQiOjE1ODc1NDgyMTV9.BCwUy3jnUQ_E6TqCayc7rCHkx-vxxdagUwPOWqwYCFc
    
    # 它各个部分的值分别是
    header: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
    payload: eyJmb28iOiJiYXIiLCJpYXQiOjE1ODc1NDgyMTV9
    signature: BCwUy3jnUQ_E6TqCayc7rCHkx-vxxdagUwPOWqwYCFc
    
  1. 它是令牌头部,记录了整个令牌的类型和签名算法,它的格式是一个 json 对象,如下:

    // 1. alg: 签名算法,通常可以取两个值
    //    - HS256:一种对称加密算法,使用同一个秘钥对 signature 加密解密
    //    - RS256:一种非对称加密算法,使用私钥签名,公钥验证
    // 2. typ:整个令牌的类型,固定写 JWT 即可
    {
      "alg": "HS256",
      "typ": "JWT"
    }
    
  2. 设置好了 header 之后,就可以生成 header 部分了,具体的生成方式及其简单,就是把 header 部分使用 base64 url 编码即可;

    • base64 url 不是一个加密算法,而是一种编码方式,它是在 base64 算法的基础上对 +=/ 三个字符做出特殊处理的算法;
    • base64 是使用 64 个可打印字符来表示一个二进制数据,具体的做法参考百度百科
  3. 浏览器提供了 btoa 函数,可以完成这个操作:

    window.btoa(JSON.stringify({ "alg":"HS256", "typ":"JWT" }))
    // 得到:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
    
  4. 同样的,浏览器也提供了 atob 函数,可以对其进行解码:

    window.atob("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9")
    // 得到:{"alg":"HS256","typ":"JWT"}
    

payload

  1. 这部分是 jwt 的主体信息,它仍然是一个 JSON 对象,它可以包含以下内容:

    {
      "ss":"发行者", // 发行该jwt的是谁,可以写公司名字,也可以写服务名称
      "iat":"发布时间", // 该jwt的发放时间,通常写当前时间的时间戳
      "exp":"到期时间", // 该jwt的到期时间,通常写时间戳
      "sub":"主题", // 该jwt是用于干嘛的
      "aud":"听众", // 该jwt是发放给哪个终端的,可以是终端类型,也可以是用户名称,随意一点
      "nbf":"在此之前不可用", // 一个时间点,在该时间点到达之前,这个令牌是不可用的
      "jti":"JWT ID" // jwt的唯一编号,设置此项的目的,主要是为了防止重放攻击(重放攻击是在某些场景下,用户使用之前的令牌发送到服务器,被服务器正确的识别,从而导致不可预期的行为发生)
    }
    
  2. 以上属性可以全写,也可以一个都不写,它只是一个规范,就算写了,也需要你在将来验证这个 jwt 令牌时手动处理才能发挥作用;

  3. 当用户登陆成功之后,我可能需要把用户的一些信息写入到 jwt 令牌中,比如用户id、账号等等(密码就算了😳),其实很简单,payload 这一部分只是一个 json 对象而已,你可以向对象中加入任何想要加入的信息;比如下面的 json 对象仍然是一个有效的 payload

    {
      "foo":"bar",
      "iat":1587548215
    }
    
  4. 最终 payload 部分和 header 一样,需要通过 base64 url 编码得到:

    window.btoa(JSON.stringify({ "foo":"bar", "iat":1587548215 }))
    // 得到:eyJmb28iOiJiYXIiLCJpYXQiOjE1ODc1NDgyMTV9
    

signature

  1. 这一部分是 jwt 的签名,正是它的存在,保证了整个 jwt 不被篡改;

  2. 这部分的生成,是对前面两个部分的编码结果,按照头部指定的方式进行加密,比如:

    // 1. 头部指定的加密方法是 HS256,前面两部分的编码结果是 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIiLCJpYXQiOjE1ODc1NDgyMTV9
    
    // 2. 则第三部分就是用对称加密算法 HS256 对字符串进行加密,定一个秘钥,比如 `shhhhh`
    HS256(`eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIiLCJpYXQiOjE1ODc1NDgyMTV9`, "shhhhh")
    // 得到:BCwUy3jnUQ_E6TqCayc7rCHkx-vxxdagUwPOWqwYCFc
    
    // 3. 最终将三部分组合在一起,就得到了完整的 jwt
    eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIiLCJpYXQiOjE1ODc1NDgyMTV9.BCwUy3jnUQ_E6TqCayc7rCHkx-vxxdagUwPOWqwYCFc
    
  3. 由于签名使用的秘钥保存在服务器,这样一来,客户端就无法伪造出签名,因为它拿不到秘钥;换句话说,之所以说无法伪造 jwt 就是因为第三部分的存在;

令牌的验证

  1. 首先,服务器要验证这个令牌是否被篡改过,验证方式非常简单,就是 header + payload 用同样的秘钥和加密算法进行重新加密,然后把加密的结果和传入jwtsignature 进行对比,如果完全相同,则表示前面两部分没有动过,就是自己颁发的,如果不同,肯定是被篡改过了;

  2. 当令牌验证为没有被篡改后,服务器可以进行其他验证:比如是否过期、听众是否满足要求等等,这些就视情况而定了;

  3. 示例代码

    const Koa = require('koa');
    const Router = require('@koa/router');
    const app = new Koa();
    const crypto = require('crypto');
    const bodyparser = require('koa-bodyparser'); // 正文解析中间件
    const router = new Router();
    app.use(bodyparser());
    
    let jwt = {
        secret: 'suijishengchengdemiyao',
        toBase64Url(base64) {
            // 将 + / = 敏感字符 => - _ ''
            return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
        },
        toBase64(content) {
            return this.toBase64Url(Buffer.from(JSON.stringify(content)).toString('base64'))
        },
        sign(content, secret) {
            let r = require('crypto').createHmac('sha256', secret).update(content).digest('base64');
            return this.toBase64Url(r)
        },
        encode(payload, secret) {
            // 对 head 和内容进行签名
            let header = this.toBase64({ typ: 'JWT', alg: 'HS256' });
            let content = this.toBase64(payload);
            let sign = this.sign([header, content].join('.'), secret)
            return [header, content, sign].join('.');
        },
        base64urlUnescape(str) {
            str += new Array(5 - str.length % 4).join('=');
            return str.replace(/\-/g, '+').replace(/_/g, '/');
        },
        decode(token, secret) {
            let [header, content, sign] = token.split('.');
            let newSign = this.sign([header, content].join('.'), secret);
            if (sign === newSign) { // 校验了签名
                // 将 base64 转化成 字符串
                return Buffer.from(this.base64urlUnescape(content), 'base64').toString();
            } else {
                throw new Error('被篡改')
            }
        }
    }
    
    router.post('/login', async (ctx) => {
        let { username, password } = ctx.request.body;
        if (username === 'admin' && password == 'admin') {
            let token = jwt.encode({
                username,
                expires: new Date(Date.now() + 10 * 1000).toGMTString()
            }, jwt.secret);
            ctx.cookies.set('tokenm', 'token');
            ctx.body = { err: 0, username, token };
        }
    })
    
    router.get('/validate', async (ctx) => {
        let authoraztion = ctx.headers['authorization'];
        try {
            let payload = jwt.decode(authoraztion, jwt.secret); // 解析上次传入的 payload
            if (payload.expires < Date.now()) ctx.body = '过期了';
            ctx.body = { err: 0, username: payload };
        } catch (e) {
            console.log(e);
            ctx.body = { err: 1, message: '错误' };
        }
    })
    app.use(router.routes());
    app.listen(3000);
    

总结

  1. jwt 本质上是一种令牌格式;它和终端设备无关,同样和服务器无关,甚至与如何传输无关,它只是规范了令牌的格式而已;

  2. jwt 由三部分组成:headerpayloadsignature;主体信息在 payload 中;

  3. jwt 难以被篡改和伪造,这是因为有第三部分的签名存在;

面试题

请阐述 JWT 的令牌格式

参考答案:

  1. JWT 分为三部分,分别是 headerpayloadsignature
    • header 标识签名算法和令牌类型;
    • payload 标识主体信息,包含令牌过期时间、发布时间、发行者、主体内容等;
    • signature 是使用特定的算法对前面两部分进行加密,得到的加密结果;
  2. JWT 有防篡改的特点,如果攻击者改动了前面两个部分,就会导致和第三部分对应不上,使得 token 失效,而攻击者不知道加密秘钥,因此又无法修改第三部分的值;所以,在秘钥不被泄露的前提下,一个验证通过的 token 是值得被信任的;
打赏作者
您的打赏是我前进的动力
微信
支付宝
评论

中午好👏🏻,我是 ✍🏻   疯狂 codding 中...

粽子

这有关于前端开发的技术文档和你分享。

相信你可以在这里找到对你有用的知识和教程。

了解更多

目录

  1. 1. 概述
  2. 2. 令牌的组成
    1. 2.1. header
    2. 2.2. payload
    3. 2.3. signature
  3. 3. 令牌的验证
  4. 4. 总结
  5. 5. 面试题
    1. 5.1. 请阐述 JWT 的令牌格式