使用 JSON Web Tokens 进行无状态身份验证
在yos.io上查找更多有趣的文章
无论你编写的是公共 API 还是内部微服务,正确的身份验证都可能决定你的 API 的成败。让我们来看看一个基于 JSON Web Token 的身份验证系统。
我们将从基本的身份验证和 JWT 概念开始,然后通过大量代码示例详细讲解如何设计身份验证服务。
在我们开始之前,先来定义一些定义:
- 凭证:描述身份的事实
- 身份验证:验证凭证以识别实体
- 授权:验证实体是否被允许访问资源或执行操作
什么是 JSON Web 令牌?
JSON Web Tokens(JWT - 发音为“jot”)是一种紧凑且自包含的方式,用于安全地传输信息并以 JSON 对象的形式表示各方之间的声明。
这是一个编码的 JSON Web Token:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiI1MWQ4NGFjMS1kYjMxLTRjM2ItOTQwOS1lNjMwZWJiYjgzZGYiLCJ1c2VybmFtZSI6Imh1bnRlcjIiLCJzY29wZXMiOlsicmVwbzpyZWFkIiwiZ2lzdDp3cml0ZSJdLCJpc3MiOiIxNDUyMzQzMzcyIiwiZXhwIjoiMTQ1MjM0OTM3MiJ9.cS5KkPxtEJ9eonvsGvJBZFIamDnJA7gSz3HZBWv6S1Q
如图所示的 JSON Web Tokens 是一个由三个部分组成的字符串,每个部分由一个(句点)字符分隔.
。
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.
eyJqdGkiOiI1MWQ4NGFjMS1kYjMxLTRjM2ItOTQwOS1lNjMwZWJiYjgzZGYiLCJ1c2VybmFtZSI6Imh1bnRlcjIiLCJzY29wZXMiOlsicmVwbzpyZWFkIiwiZ2lzdDp3cml0ZSJdLCJpc3MiOiIxNDUyMzQzMzcyIiwiZXhwIjoiMTQ1MjM0OTM3MiJ9
.
cS5KkPxtEJ9eonvsGvJBZFIamDnJA7gSz3HZBWv6S1Q
Base64Url
解码 JSON Web Token 后,我们可以得到以下内容:
{
"alg": "HS256",
"typ": "JWT"
}
.
{
"jti": "51d84ac1-db31-4c3b-9409-e630ebbb83df",
"username": "hunter2",
"scopes": ["repo:read", "gist:write"],
"iss": "1452343372",
"exp": "1452349372"
}
.
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret
)
JSON Web Tokens 由以下三个部分组成:Header、Payload和Signature。Token 的构造如下:
-
您生成一个任意 JSON 数据的声明(即Payload),在我们的例子中,它包含了身份验证所需的所有用户信息。Header通常定义签名算法和
alg
token 的类型typ
。 -
您可以使用一些元数据来修饰它,例如声明何时到期、受众是谁等等。这些被称为声明,在JWT IETF 草案中定义。
-
然后,数据(包括Header和Payload)会使用基于哈希的消息认证码 (HMAC) 进行加密签名。此签名用于验证 JWT 的发送者身份,并确保消息未被篡改。
-
然后对Header、Payload和Signature进行
Base64
编码并用句点连接在一起以分隔字段,从而得到我们在第一个示例中看到的令牌。
JWT 还可以使用秘密(使用 HMAC 算法)或使用 RSA 的公钥/私钥对进行签名。
为了实现身份验证,JWT 充当凭证/身份对象,客户端必须向网守出示该凭证/身份对象,以验证您是否有权访问您想要访问的受保护资源。它可以由受信任的一方签名,并由网守进行验证。
JSON Web Token 可以跨多种编程语言使用。您应该能够找到一些客户端,用于签名和验证为您的技术栈编写的 Token。
身份验证流程
使用 JWT 的主要用例之一是对请求进行身份验证。用户登录后,每个后续请求都可以包含 JWT,以访问之前无法访问的受保护资源和服务。
为了说明,让我们想象一组包含用户受保护资源的微服务的身份验证层。
我们的身份验证流程发生在以下各方之间:
- 资源所有者(用户):拥有要共享资源的一方。我们假设用户为Tom。
- 资源服务器:持有受保护资源的服务。我们的WalletService持有 Wallet 资源,也就是用户的数字钱包。
- 授权服务器:验证用户身份的服务。我们称之为AuthService。
- 客户端:代表资源所有者向资源服务器发出请求的应用程序(Web/移动/其他)。例如,我们来创建一个WalletApp Android 应用。
如果您熟悉 OAuth2,我们的流程与资源所有者密码凭据授予流程类似。根据您的用例,其他流程可能更适合您的应用程序。
我们的整个流程如下:
- 资源所有者汤姆希望通过客户端查看他的数字钱包的内容。
- 客户端与 WalletService 对话,请求 Tom 的钱包资源。
- 遗憾的是,钱包属于受保护资源。客户端需要传递访问令牌才能继续操作。
- 客户端与 AuthService 通信,请求访问令牌。AuthService 响应并询问用户的凭证。
- 客户端将资源所有者 Tom 重定向到 AuthService,这使得 Tom 可以选择拒绝或接受客户端的访问请求。
- AuthService 验证 Tom 的凭证,将她重定向回客户端,并向客户端授予授权码。
- 客户端向 AuthService 提供授权码,如果成功则向客户端返回访问令牌(JWT)。
- WalletApp 将访问令牌提交给 WalletService,请求 Tom 的 Wallet 资源。每当客户端想要访问受保护的路由或资源时,都应该发送 JWT,通常在
Authorization
标头中使用Bearer
以下模式:Authorization: Bearer <token>
- WalletService 验证令牌、解码 JWT 并解析其内容。
- (可选,请参阅撤销令牌)WalletService 要求 AuthService 验证令牌。
- 如果访问令牌对于请求的操作和资源有效,WalletService 会将 Tom 的钱包返回给 WalletApp 客户端。
- WalletApp 向汤姆展示了他的钱包。
请注意,资源所有者不会直接与客户端共享其凭证。相反,用户会通知授权方,客户端可以访问其请求的任何内容,然后客户端会使用授权码单独进行身份验证。更多详细信息,请参阅OpenID Connect规范。
在本文中,我们主要关注步骤 8 至 12。
最小可行身份验证服务
让我们使用普通的 Node + Express 为上述流程构建一个身份验证服务。当然,您可以自由地使用任何您喜欢的框架来构建您自己的身份验证服务。
我们至少需要一个端点:
HTTP 动词 | URI | 描述 |
---|---|---|
邮政 | /会话 | 登录 |
// Authentication Service API Login endpoint
var _ = require('underscore');
var Promise = require('bluebird');
var express = require('express');
var router = express.Router();
var models = require('../models');
var User = models.User;
var JWT = require('../utils/jwt');
// Login
router.post('/sessions', function(req, res, next) {
var params = _.pick(req.body, 'username', 'password', 'deviceId');
if (!params.username || !params.password || !params.deviceId) {
return res.status(400).send({error: 'username, password, and deviceId ' +
'are required parameters'});
}
var user = User.findOne({where: {username: params.username}});
var passwordMatch = user.then(function(userResult) {
if (_.isNull(userResult)) {
return res.status(404).send({error: 'User does not exist'});
}
return userResult.comparePassword(params.password);
});
Promise.join(user, passwordMatch, function(userResult, passwordMatchResult) {
if (!passwordMatchResult) {
return res.status(403).send({
error: 'Incorrect password'
});
}
var userKey = uuid.v4();
var issuedAt = new Date().getTime();
var expiresAt = issuedAt + (EXPIRATION_TIME * 1000);
var token = JWT.generate(user.username, params.deviceId, userKey, issuedAt, expiresAt);
return res.status(200).send({
accessToken: token;
});
})
.catch(function(error) {
console.log(error);
next(error);
});
});
//lib/utils/jwt.js
var _ = require('underscore');
var config = require('nconf');
var jsrsasign = require('jsrsasign');
var sessionKey = require('../utils/sessionKey');
var JWT_ENCODING_ALGORITHM = config.get('jwt:algorithm');
var JWT_SECRET_SEPARATOR = config.get('jwt:secret_separator');
function JWT() {
this.secretKey = config.get('jwt:secret');
}
// Generate a new JWT
JWT.prototype.generate = function(user, deviceId, userKey, issuedAt,
expiresAt) {
if (!user.id || !user.username) {
throw new Error('user.id and user.username are required parameters');
}
var header = {
alg: JWT_ENCODING_ALGORITHM, typ: 'JWT'
};
var payload = {
username: user.username,
deviceId: deviceId,
jti: sessionKey(user.id, deviceId, issuedAt),
iat: issuedAt,
exp: expiresAt
};
var secret = this.secret(userKey);
var token = jsrsasign.jws.JWS.sign(JWT_ENCODING_ALGORITHM,
JSON.stringify(header),
JSON.stringify(payload),
secret);
return token;
};
// Token Secret generation
JWT.prototype.secret = function(userKey) {
return this.secretKey + JWT_SECRET_SEPARATOR + userKey;
};
module.exports = new JWT();
太棒了!现在我们可以在登录成功后返回访问令牌了。在接下来的部分中,我们将介绍如何为我们的身份验证系统添加更多功能,以及如何编写一个可以轻松用于保护未来微服务路由的身份验证中间件。
但首先,让我们进一步了解为什么我们使用 JWT 而不是常规纯文本令牌的原因。
使用 JWT 进行身份验证的好处
与不透明的 OAuth2 令牌相比,使用 JSON Web 令牌作为身份对象具有许多优势Bearer
:
1. 细粒度访问控制:您可以在令牌本身中指定详细的访问控制信息,作为其有效负载的一部分。就像您可以创建具有非常具体权限的 AWS 安全策略一样,您可以将令牌限制为仅授予对单个资源的读/写访问权限。相比之下,API 密钥通常具有粗略的“全有或全无”访问权限。
您可以使用包含一组动态范围的 JWT私有声明来填充您的令牌。例如:
{
"jti": "51d84ac1-db31-4c3b-9409-e630ebbb83df",
"username": "hunter2",
"scopes": ["repo:read", "gist:write"],
"iss": "1452343372",
"exp": "1452349372"
}
您的身份验证中间件可以解析此 JWT 元数据并执行验证,而无需向授权服务器发出请求。API 端点只需检查是否存在正确的 scope 属性,如下所示。
我们在上一节中已经讨论过这个问题,并提供了代码示例。
2. 可自省:JSON Web 令牌带有类似标头的元数据,可以轻松检查以进行客户端验证,而不像纯文本Bearer
OAuth 令牌那样,如果不调用数据库就无法解码和检查。
3. Expirable:JSON Web Token 可以通过该属性内置过期机制exp
。exp
(过期时间) 声明指定了过期时间,超过该时间,JWT 不得被接受处理。
4. 无状态:完成特定请求所需的所有信息都会随其一起发送,其中包括一个Authorization
包含 JWT 的 HTTP 标头,该 JWT 充当“身份对象”。由于有效负载包含了我们验证用户身份所需的所有信息,因此我们可以避免重复调用数据库。
5. 加密:虽然 JWT 的签名可以防止恶意方篡改,但令牌的标头仅经过Base64
编码。在处理令牌中的机密标识符时,应使用 加密令牌AES
。
此时你可能会想:
“哇,太棒了!我可以完全无状态地实现身份验证,而无需存储任何会话信息!”
上述情况确实如此,因为您可以对到期时间声明执行客户端验证,exp
以使过期的令牌无效。
然而,我们注意到我们目前的设计还没有解决一些问题:
当你想将用户从应用程序中注销时会发生什么?假设你更新了令牌的架构。你该如何处理尚未过期的旧令牌?当你将此更新部署到应用程序时,如何使当前会话失效?当你存储会话时,该如何操作?
此时,我们的授权服务器还没有办法使尚未过期的会话无效。
撤销令牌
纯无状态方法的一个问题是,我们无法在已颁发的令牌过期之前撤销/使其失效。换句话说,我们无法手动注销用户。如果恶意方设法获取了令牌,并且我们知道该令牌已被恶意方获取,那么我们就会成为攻击目标。我们无法剥夺已颁发令牌的访问权限。
我们可以设置客户端逻辑,在验证过程中清除所有过期的会话令牌。然而,客户端安全性不足。为了防止令牌滥用,我们需要能够撤销已颁发的令牌。
恶意方有可能看到您服务的请求标头。您应该使用TLS进行客户端-服务器和服务内通信,以确保无人嗅探您的网络请求。话虽如此,我们希望即使攻击者能够拦截客户端和服务器之间的网络通信,我们的身份验证系统也能保持安全。
根据您的用例,我们可以采用两种方法来支持两种不同的令牌失效功能。这两种方法都需要使用额外的存储空间(例如 Redis)来存储某种形式的令牌标识符。
Redis 是一个优秀的键值存储系统,非常适合保存诸如令牌之类的临时数据。它拥有诸如自动删除或设置令牌过期等功能,能够处理大量写入操作,并且支持水平扩展。
这两种方法都需要我们的验证中间件向授权服务器发出请求以进行令牌验证。让我们看看如何实现它们:
1. 为了能够撤销属于单个用户的所有令牌,我们只需使用该用户自己的私有密钥对属于该用户的 JWT 进行签名即可。您可以动态生成这些密钥,也可以使用其密码的哈希值。
然后,在我们的令牌验证过程中,我们可以从数据库/服务(在我们的例子中是从KeyService
)检索这个私人秘密来验证令牌的签名。
可以通过更改或删除该用户的秘密来撤销令牌,从而使属于该用户的所有已颁发的令牌无效。
2. 为了能够撤销单个令牌(用户可以在不同的设备上拥有多个令牌),我们需要jti
为每个 JWT 生成一个唯一标识符,我们可以将其用作标识符,KeyService
以检索为签署和验证单个令牌而创建的动态生成的、特定于会话的秘密。
// Verify JWT
KeyService.get(payload.jti)
.then(function(userKey) {
var authenticated = JWT.verify(token, userKey);
if (authenticated) {
return next();
}
return next(new Error('403 Invalid Access Token'));
});
标识符值的分配方式必须确保将相同值意外分配给不同数据对象的可能性降至最低;如果应用程序使用多个发行器,则必须防止不同发行器生成的值之间发生冲突。一种有助于最大程度减少冲突的方法是使用uuids
而不是integers
作为标识符。
对于这两种方法,我们还需要通过在JWT 有效负载中包含唯一标识符和 (issued at) 声明中的时间戳来防止重放攻击。这确保了生成的每个令牌都是唯一的。
jti
iat
我们需要添加额外的端点:
HTTP 动词 | URI | 描述 |
---|---|---|
邮政 | /会话 | 登录 |
得到 | /会话/:id | 检索特定于用户/会话的私人秘密 |
删除 | /会话/:id | 登出 |
该GET
端点主要由我们的身份验证中间件使用,以检索用于签署 JWT 的秘密并验证签名是否有效。
端点DELETE
将更改或删除特定设备上用于用户会话的机密,以便 JWT 签名验证失败并403 Forbidden
触发响应。
我们还创建了一个服务包装器,用于存储用于签署 JWT 的用户/会话特定的秘密,方法有get
、set
和delete
:
// KeyService.js, a key storage backed by Redis
// KeyService stores and manages user-specific keys used to sign JWTs
var redis = require('redis');
var Promise = require('bluebird');
var config = require('nconf');
var uuid = require('node-uuid');
var JWT = require('../utils/jwt');
var EXPIRATION_TIME = config.get('key_service:expires_seconds');
var sessionKey = require('../utils/sessionKey');
Promise.promisifyAll(redis.RedisClient.prototype);
function KeyService() {
this.client = redis.createClient(config.get('key_service:port'),
config.get('key_service:host'));
this.client.on('connect', function() {
console.log('Redis connected.');
});
console.log('Connecting to Redis...');
}
// Retrieve a JWT user key
KeyService.prototype.get = function(sessionKey) {
return this.client.getAsync(sessionKey);
};
// Generate and store a new JWT user key
KeyService.prototype.set = function(user, deviceId) {
var userKey = uuid.v4();
var issuedAt = new Date().getTime();
var expiresAt = issuedAt + (EXPIRATION_TIME * 1000);
var token = JWT.generate(user, deviceId, userKey, issuedAt, expiresAt);
var key = sessionKey(user.id, deviceId, issuedAt);
var setKey = this.client.setAsync(key, userKey);
var setExpiration = setKey.then(this.client.expireAsync(key,
EXPIRATION_TIME));
var getToken = setExpiration.then(function() {
return token;
});
return getToken;
};
// Manually remove a JWT user key
KeyService.prototype.delete = function(sessionKey) {
return this.client.delAsync(sessionKey);
};
module.exports = new KeyService();
请注意,内置了过期机制,该机制利用 Redis 的EXPIRE
功能自动删除已过期的会话,从而使使用该密钥签名的任何已颁发的令牌无效。
这是我们的主路由器,已更新以处理附加端点并与以下对象通信KeyService
:
// Authentication Service API endpoints
var _ = require('underscore');
var Promise = require('bluebird');
var express = require('express');
var router = express.Router();
var models = require('../models');
var User = models.User;
var KeyService = require('../services/KeyService');
var ErrorMessage = require('../utils/error');
// Register
router.post('/users', function(req, res, next) {
var params = _.pick(req.body, 'username', 'password');
if (!params.username || !params.password) {
return res.status(400).send({error: 'username and password ' +
'are required parameters'});
}
User.findOrCreate({
where: {username: params.username},
defaults: {password: params.password}
})
.spread(function(user, created) {
if (!created) {
return res.status(409).send({error: 'User with that username ' +
'already exists.'});
}
res.status(201).send(user);
})
.catch(function(error) {
res.status(400).send(ErrorMessage(error));
});
});
// Login
router.post('/sessions', function(req, res, next) {
var params = _.pick(req.body, 'username', 'password', 'deviceId');
if (!params.username || !params.password || !params.deviceId) {
return res.status(400).send({error: 'username, password, and deviceId ' +
'are required parameters'});
}
var user = User.findOne({where: {username: params.username}});
var passwordMatch = user.then(function(userResult) {
if (_.isNull(userResult)) {
return res.status(404).send({error: 'User does not exist'});
}
return userResult.comparePassword(params.password);
});
Promise.join(user, passwordMatch, function(userResult, passwordMatchResult) {
if (!passwordMatchResult) {
return res.status(403).send({
error: 'Incorrect password'
});
}
return KeyService.set(userResult, params.deviceId)
.then(function(token) {
res.status(200).send({
accessToken: token
});
});
})
.catch(function(error) {
console.log(error);
next(error);
});
});
// Get Session
router.get('/sessions/:sessionKey', function(req, res, next) {
var sessionKey = req.params.sessionKey;
if (!sessionKey) {
return res.status(400).send({error: 'sessionKey is a required parameters'});
}
KeyService.get(sessionKey)
.then(function(result) {
if (_.isNull(result)) {
return res.status(404).send({error: 'Session does not exist or has ' +
'expired. Please sign in to continue.'});
}
res.status(200).send({userKey: result});
})
.catch(function(error) {
console.log(error);
next(error);
});
});
// Logout
router.delete('/sessions/:sessionKey', function(req, res, next) {
var sessionKey = req.params.sessionKey;
if (!sessionKey) {
return res.status(400).send({error: 'sessionKey is a required parameter'});
}
KeyService.delete(sessionKey)
.then(function(result) {
if (!result) {
return res.status(404).send();
}
res.status(204).send();
})
.catch(function(error) {
console.log(error);
next(error);
});
});
module.exports = router;
更新的身份验证流程
以下是我们更新后的流程,支持撤销已颁发的令牌:
我们在令牌验证过程中引入了一些额外的步骤(这发生在我们的中间件中),这些步骤与外部私有秘密存储进行通信KeyService
,以检索解码和验证 JWT 签名所需的秘密。
正如我们所讨论的,这使我们能够以一定的复杂性为代价引入过期和手动撤销已颁发令牌的能力。
最小可行的身份验证中间件
除了 AuthService 之外,我们还可以编写一个配套的 Node.js 模块,以便其他开发者可以轻松地将身份验证功能添加到他们的微服务中。例如:
var auth = require('auth');
router.post('/protected', auth.isAuthenticated, function(req, res, next) {
res.status(200).send();
});
仅供参考,isAuthenticated 中间件会检查该路由传入的每个请求中的 Authorization: Bearer 标头是否有效。源代码如下所示。
您还可以像这样保护所有路线:
var auth = require('auth');
app.use(auth.isAuthenticated);
中间件isAuthenticated
可以这样写:
// index.js
var base64url = require('base64url');
var JWT = require('./lib/utils/jwt');
var KeyService = require('./lib/services/KeyService');
function isAuthenticated(req, res, next) {
// Guard clauses
var authorization = req.headers.authorization;
if (!authorization || !(authorization.search('Bearer ') === 0)) {
return next(new Error('401 Missing Authorization Header'));
}
var token = authorization.split(' ')[1];
if (!token) {
return next(new Error('401 Missing Bearer Token'));
}
// Unpack JWT
var components = token.split('.');
var header = JSON.parse(base64url.decode(components[0]));
var payload = JSON.parse(base64url.decode(components[1]));
var signature = components[2];
// Verify JWT
KeyService.get(payload.jti)
.then(function(userKey) {
var authenticated = JWT.verify(token, userKey);
if (authenticated) {
return next();
}
return next(new Error('403 Invalid Access Token'));
});
}
module.exports = {
isAuthenticated: isAuthenticated
};
KeyService
是对 Redis 存储的包装,用于存储会话特定的用户密钥,并通过uuid
令牌jti
声明中包含的标识符进行索引。我们之前见过这种情况,只是我们中间件定义的操作是严格只读的。
// KeyService stores and manages user-specific keys used to sign JWTs
var redis = require('redis');
var Promise = require('bluebird');
var config = require('nconf');
Promise.promisifyAll(redis.RedisClient.prototype);
function KeyService() {
this.client = redis.createClient(config.get('key_service:port'),
config.get('key_service:host'));
this.client.on('connect', function() {
console.log('Redis connected.');
});
console.log('Connecting to Redis...');
}
// Retrieve a JWT user key
KeyService.prototype.get = function(sessionKey) {
return this.client.getAsync(sessionKey);
};
module.exports = new KeyService();
JWT
是加密库的轻量级包装器jsrsasign
。我们使用jsrsassign
加密库来验证我们的 JWT:
在选择用于签名和验证 JWT 的加密库时,请务必谨慎。请务必检查jwt.io是否存在任何安全漏洞。
// lib/utils/jwt.js
var _ = require('underscore');
var config = require('nconf');
var jsrsasign = require('jsrsasign');
var base64url = require('base64url');
var JWT_ENCODING_ALGORITHM = config.get('jwt:algorithm');
var JWT_SECRET_SEPARATOR = config.get('jwt:secret_separator');
function JWT() {
this.secretKey = config.get('jwt:secret');
}
JWT.prototype.verify = function(token, userKey) {
var secret = this.secret(userKey);
var isValid = jsrsasign.jws.JWS.verifyJWT(token,
secret,
{
alg: [JWT_ENCODING_ALGORITHM],
verifyAt: new Date().getTime()});
return isValid;
};
JWT.prototype.secret = function(userKey) {
return this.secretKey + JWT_SECRET_SEPARATOR + userKey;
};
module.exports = new JWT();
问:为什么我们在计算时同时使用全局密钥和用户特定密钥
secret
?答:拥有全局密钥后,我们只需更改此单个密钥值即可轻松使所有用户的所有令牌无效,从而使所有已颁发令牌的 JWT 签名无效。
以这种方式编写针对身份验证等跨切关注点的模块,可以节省未来微服务的开发时间和精力。随着可复用模块的增多,您可以快速启动具有日益丰富功能的新服务。共享模块还有助于在所有不同服务之间保持一致的行为。
其他 JWT 用例
JSON Web Tokens 可以在各方之间安全地传输信息,因为它的签名可以确保发送者是我们预期的。JWT 的其他用例包括用作重置密码链接中的令牌。我们可以使用 JWT 创建签名的超链接,而无需将密码重置令牌存储在数据库中。
结束语
我介绍了一种使用 JSON Web 令牌构建身份验证层的方法。我们还讨论了一些设计决策,以帮助预防一些安全漏洞。
虽然 JWT 似乎是一种相当合理的身份验证方法,但重要的是我们不能忽视从多年的实战经验中吸取的旧身份验证方案的教训。
通过这个过程,我希望我已经与您分享了使用 JWT 的客户端身份验证方案如何具有其自身的风险和局限性,需要在实施之前彻底调查。
请在下面的评论中告诉我您的想法!
补充阅读
- JWT IETF 草案
- jwt.io
- JWT 闪电演讲
- 关于 token 和 cookies 你应该知道的十件事
- 微服务的无状态身份验证
- JWT 的无状态令牌
- 使用 JSON Web Tokens 作为 API 密钥
鏂囩珷鏉ユ簮锛�https://dev.to/yos/stateless-authentication-with-json-web-tokens--km3在yos.io上查找更多有趣的文章