概述
-
回顾登录的流程:
-
接下来的问题是:这个出入证(令牌)里面到底存啥?一种比较简单的办法就是直接存储用户信息的 JSON 串,这会造成下面的几个问题:
- 非浏览器环境,如何在令牌中记录过期时间
- 如何防止令牌被伪造
-
JWT 就是为了解决这些问题出现的:
- JWT 全称 Json Web Token ,本质就是一个字符串;
- 它要解决的问题,就是在互联网环境中,提供 统一的、安全的 令牌格式;
- 因此 jwt 只是一个令牌格式而已,你可以把它存储到 cookie ,也可以存储到 localstorage ,没有任何限制!
- 同样的,对于传输,可以使用任何传输方式来传输 jwt ,一般来说,会使用消息头来传输它,比如,当登录成功后,服务器可以给客户端响应一个 jwt:
-
可以看到 jwt 令牌可以出现在响应的任何一个地方,客户端和服务器自行约定即可;
当然,它也可以出现在响应的多个地方,比如为了充分利用浏览器的 cookie ,同时为了照顾其他设备,也可以让 jwt 出现在 set-cookie 、authorization、body 中,尽管这会增加额外的传输量;
令牌的组成
-
为了保证令牌的安全性,jwt 令牌由三个部分组成,分别是:
- header:令牌头部,记录了整个令牌的类型和签名算法;
- payload:令牌负荷,记录了保存的主体信息,比如你要保存的用户信息就可以放到这里;
- signature:令牌签名,按照头部固定的签名算法对整个令牌进行签名,该签名的作用是:保证令牌不被伪造和篡改;
-
它们组合而成的完整格式是:
header.payload.signature
,比如一个完整的 jwt 令牌如下:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIiLCJpYXQiOjE1ODc1NDgyMTV9.BCwUy3jnUQ_E6TqCayc7rCHkx-vxxdagUwPOWqwYCFc # 它各个部分的值分别是 header: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 payload: eyJmb28iOiJiYXIiLCJpYXQiOjE1ODc1NDgyMTV9 signature: BCwUy3jnUQ_E6TqCayc7rCHkx-vxxdagUwPOWqwYCFc
header
-
它是令牌头部,记录了整个令牌的类型和签名算法,它的格式是一个 json 对象,如下:
// 1. alg: 签名算法,通常可以取两个值 // - HS256:一种对称加密算法,使用同一个秘钥对 signature 加密解密 // - RS256:一种非对称加密算法,使用私钥签名,公钥验证 // 2. typ:整个令牌的类型,固定写 JWT 即可 { "alg": "HS256", "typ": "JWT" }
-
设置好了 header 之后,就可以生成 header 部分了,具体的生成方式及其简单,就是把 header 部分使用 base64 url 编码即可;
- base64 url 不是一个加密算法,而是一种编码方式,它是在 base64 算法的基础上对
+
、=
、/
三个字符做出特殊处理的算法; - 而 base64 是使用 64 个可打印字符来表示一个二进制数据,具体的做法参考百度百科
- base64 url 不是一个加密算法,而是一种编码方式,它是在 base64 算法的基础上对
-
浏览器提供了 btoa 函数,可以完成这个操作:
window.btoa(JSON.stringify({ "alg":"HS256", "typ":"JWT" })) // 得到:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
-
同样的,浏览器也提供了 atob 函数,可以对其进行解码:
window.atob("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9") // 得到:{"alg":"HS256","typ":"JWT"}
payload
-
这部分是 jwt 的主体信息,它仍然是一个 JSON 对象,它可以包含以下内容:
{ "ss":"发行者", // 发行该jwt的是谁,可以写公司名字,也可以写服务名称 "iat":"发布时间", // 该jwt的发放时间,通常写当前时间的时间戳 "exp":"到期时间", // 该jwt的到期时间,通常写时间戳 "sub":"主题", // 该jwt是用于干嘛的 "aud":"听众", // 该jwt是发放给哪个终端的,可以是终端类型,也可以是用户名称,随意一点 "nbf":"在此之前不可用", // 一个时间点,在该时间点到达之前,这个令牌是不可用的 "jti":"JWT ID" // jwt的唯一编号,设置此项的目的,主要是为了防止重放攻击(重放攻击是在某些场景下,用户使用之前的令牌发送到服务器,被服务器正确的识别,从而导致不可预期的行为发生) }
-
以上属性可以全写,也可以一个都不写,它只是一个规范,就算写了,也需要你在将来验证这个 jwt 令牌时手动处理才能发挥作用;
-
当用户登陆成功之后,我可能需要把用户的一些信息写入到 jwt 令牌中,比如用户id、账号等等(密码就算了😳),其实很简单,payload 这一部分只是一个 json 对象而已,你可以向对象中加入任何想要加入的信息;比如下面的 json 对象仍然是一个有效的 payload;
{ "foo":"bar", "iat":1587548215 }
-
最终 payload 部分和 header 一样,需要通过 base64 url 编码得到:
window.btoa(JSON.stringify({ "foo":"bar", "iat":1587548215 })) // 得到:eyJmb28iOiJiYXIiLCJpYXQiOjE1ODc1NDgyMTV9
signature
-
这一部分是 jwt 的签名,正是它的存在,保证了整个 jwt 不被篡改;
-
这部分的生成,是对前面两个部分的编码结果,按照头部指定的方式进行加密,比如:
// 1. 头部指定的加密方法是 HS256,前面两部分的编码结果是 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIiLCJpYXQiOjE1ODc1NDgyMTV9 // 2. 则第三部分就是用对称加密算法 HS256 对字符串进行加密,定一个秘钥,比如 `shhhhh` HS256(`eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIiLCJpYXQiOjE1ODc1NDgyMTV9`, "shhhhh") // 得到:BCwUy3jnUQ_E6TqCayc7rCHkx-vxxdagUwPOWqwYCFc // 3. 最终将三部分组合在一起,就得到了完整的 jwt eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIiLCJpYXQiOjE1ODc1NDgyMTV9.BCwUy3jnUQ_E6TqCayc7rCHkx-vxxdagUwPOWqwYCFc
-
由于签名使用的秘钥保存在服务器,这样一来,客户端就无法伪造出签名,因为它拿不到秘钥;换句话说,之所以说无法伪造 jwt 就是因为第三部分的存在;
令牌的验证
-
首先,服务器要验证这个令牌是否被篡改过,验证方式非常简单,就是 header + payload 用同样的秘钥和加密算法进行重新加密,然后把加密的结果和传入jwt 的 signature 进行对比,如果完全相同,则表示前面两部分没有动过,就是自己颁发的,如果不同,肯定是被篡改过了;
-
当令牌验证为没有被篡改后,服务器可以进行其他验证:比如是否过期、听众是否满足要求等等,这些就视情况而定了;
-
示例代码
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);
总结
jwt 本质上是一种令牌格式;它和终端设备无关,同样和服务器无关,甚至与如何传输无关,它只是规范了令牌的格式而已;
jwt 由三部分组成:header、payload、signature;主体信息在 payload 中;
jwt 难以被篡改和伪造,这是因为有第三部分的签名存在;
面试题
请阐述 JWT 的令牌格式
参考答案:
- JWT 分为三部分,分别是 header、payload、signature
- header 标识签名算法和令牌类型;
- payload 标识主体信息,包含令牌过期时间、发布时间、发行者、主体内容等;
- signature 是使用特定的算法对前面两部分进行加密,得到的加密结果;
- JWT 有防篡改的特点,如果攻击者改动了前面两个部分,就会导致和第三部分对应不上,使得 token 失效,而攻击者不知道加密秘钥,因此又无法修改第三部分的值;所以,在秘钥不被泄露的前提下,一个验证通过的 token 是值得被信任的;
计算机网络🛜 加密
上一篇