身份验证相关内容:
浏览器 cookie 的原理(详)
session 原理
在浏览器 cookie 的原理(详)这篇文章中介绍了身份验证的步骤:
简单来说,如果身份验证通过 session 完成,那【出入证】就是一个 cookie 信息,内容是 sessionId。但还有其他的问题需要解决:
随着前后端分离的发展,一个产品的终端可能不止是浏览器,还有桌面应用、智能家居等设备。
通过上图中可以看到:这些设备都会和同一个服务器通信,一般都是 http 协议。
通常不同的产品线会有自己的服务器,产品内部数据一般和自己的服务器交互。但中心服务器仍有存在的必要,因为产品之间总会有数据需要共享。
这个中心服务器至少承担着认证和授权的功能,比如登录:各种设备发送消息到中心服务器,中心服务器响应一个【出入证】(令牌信息)。
问题来了:其他的设备还能使用 cookie 传递令牌信息吗?
虽然 cookie 简单来说就是一个消息头,但浏览器有完善的管理机制:比如自动保存和自动发送,还有相应的安全机制等。但其他设备上的 cookie 机制就需要手动处理了。
jwt 的出现就是为了解决这个问题。
全称为 json web token
,目的:为不同的终端设备提供统一的、安全的令牌格式。
令牌信息在传输时就是一个字符串而已,而 jwt 是令牌格式。这个字符串可以简单理解为:对一些特殊信息做了编码和加密,来达到身份验证的目的。
所以对这个字符串来说,
比如登录成功后,服务器可以给客户端响应一个 jwt令牌:
POST /api/login HTTP/1.1 200 OK
...
set-cookie: token=jwt令牌
authorization: bearer jwt令牌
...
{..., token:jwt令牌}
它可以出现在响应的任何位置,或是同时出现在多个位置。
以上面的响应为例,就是为了充分利用浏览器的 cookie 机制,同时为了照顾其他设备,所以也出现在了响应头 authorization 和响应体中。
虽然没有明确的要求应该如何附带到请求中,但通常都会如下的格式(OAuth2附带 token 的一种规范格式):
GET /api/resources HTTP/1.1
...
authorization: bearer jwt令牌
...
整体交互流程:
为了保证令牌的安全性,jwt 令牌由3个部分组成:
完整格式为 header.payload.signature
。举例:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJteW5hbWUiLCJpYXQiOiIxNzAzNzc2NjM3In0._UuiFQ-rF8DZdheGE79LA46nfACrn2IiFPckUay7lQI
格式为 json 对象:
{
"alg":"HS256",
"typ":"JWT"
}
HS256
(对称加密算法);也可以使用 RS256
(非对称加密算法)。JWT
。接着将 json 对象使用 base64 url
编码。
base64 url
不是加密算法,而是一种编码格式,它是在base64
编码的基础上对=
,+
,/
这3个字符做特殊处理(=
被省略,+
替换为-
,/
替换为_
),因为 jwt 可能也会在 url 中传输。
而base64
是使用64个可打印字符来表示一个二进制数据。
nodejs 需要借助第三方库实现,比如 base64url:
const base64url = require("base64url");
const a = base64url.encode(
JSON.stringify({
alg: "HS256",
typ: "JWT",
})
);
console.log(a); // eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
const b = base64url.decode("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9");
console.log(b);
jwt 的主体信息,也是一个 json 对象,包含以下内容:
{
"ss":"发行者", // 可以是公司名字,也可以是服务名称
"iat":"发布时间",
"exp":"到期时间",
"sub":"主题", // 该 jwt 的作用
"aud":"受众", // 发放给哪个终端的,可以是终端类型,也可以是用户名称
"nbf":"在此之前不可用", // 一个时间点,在该时间点到达之前,这个令牌是不可用的
"jti":"JWT ID" // jwt的唯一编号,主要是为了防止重放攻击(在某些场景下,用户使用之前的令牌发送到服务器,被服务器正确的识别,从而导致不可预期的行为发生)
}
以上内容只是一个规范,都是可选的。设置了也需要之后验证 jwt 令牌时手动处理才能发挥作用。
而我们可以把需要的信息加进去,比如用户 id 等等。比如:
{
"foo": "myname", // 自定义信息
"iat": "1703776637" // 规范的信息
}
同样也需要使用 base64url
编码:
// base64url 编码
const base64url = require("base64url");
const a = base64url.encode(
JSON.stringify({
foo: "myname",
iat: "1703776637",
})
);
// eyJmb28iOiJteW5hbWUiLCJpYXQiOiIxNzAzNzc2NjM3In0
注意,浏览器提供的 window.btoa
函数只是 base64
编码,并不是base64 url
编码!不会对 =
,+
,/
这3个字符做特殊处理。
window.btoa(JSON.stringify({
"foo":"myname",
"iat":"1703776637"
}))
// 'eyJmb28iOiJteW5hbWUiLCJpYXQiOiIxNzAzNzc2NjM3In0='
// 但都可被正常解码,下面2个结果相同
window.atob('eyJmb28iOiJteW5hbWUiLCJpYXQiOiIxNzAzNzc2NjM3In0=')
window.atob('eyJmb28iOiJteW5hbWUiLCJpYXQiOiIxNzAzNzc2NjM3In0')
header 和 payload 都算是明文传输的。所以不要将敏感信息放到 payload 中。
这部分保证了 jwt 不会被篡改或伪造。
生成步骤:将 header 和 payload 的编码结果,使用 header 中指定的加密算法进行加密。
// 上面 header 的编码结果 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
// 上面 payload 的编码结果 eyJmb28iOiJteW5hbWUiLCJpYXQiOiIxNzAzNzc2NjM3In0
const crypto = require("crypto");
function HS256(header, playload) {
const hmac = crypto.createHmac("sha256", "mykey"); // 创建加密对象,且指定秘钥为 mykey
hmac.update(`${header}.${playload}`); // 将数据放入加密对象
return hmac.digest("base64url"); // 编码为 base64url
}
HS256("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9", "eyJmb28iOiJteW5hbWUiLCJpYXQiOiIxNzAzNzc2NjM3In0")
// _UuiFQ-rF8DZdheGE79LA46nfACrn2IiFPckUay7lQI
最终将这3部分拼接在一起,得到完整的 jwt:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJteW5hbWUiLCJpYXQiOiIxNzAzNzc2NjM3In0._UuiFQ-rF8DZdheGE79LA46nfACrn2IiFPckUay7lQI
node-hmac.digest 参考
因为签名使用的秘钥会保存在服务器,所以客户端无法伪造签名来篡改 jwt。
服务器拿到客户端回传的 jwt 之后,除了验证相同之外(比如payload信息被篡改),还需要验证是否过期,受众是否还满足要求等。
最终整体验证流程:
以上。