深入浅出 JWT:如何保护 HTTP 端点并完善身份验证
Web 开发人员必须确保只有授权的人员或系统才能访问特定的 HTTP 端点(如 API URL)。这涉及到验证请求的发起者身份以及他们是否拥有权限。在这篇新手友好的指南中,我们将探讨保护 HTTP 端点的常用方法——包括 API 密钥、会话 Cookie 和 OAuth2 ——并用通俗易懂的语言解释它们的优缺点。然后,我们将深入探讨 JSON Web Tokens (JWT) ,解释什么是 JWT、它们如何工作以及如何保护端点。我们还将介绍访问令牌与刷新令牌的区别、它们如何融入身份验证流程、使用 JWT 的最佳实践以及需要避免的常见安全陷阱。
保护 HTTP 端点的常用方法
有几种广泛使用的方法来验证调用您的 HTTP API 的客户端或用户。每种方法都有其优点和缺点:
API 密钥
API 密钥是简单的秘密令牌(通常是一个长字符串),客户端在每个请求中包含它(例如,在查询参数或标头中)以标识自身。服务器检查此密钥以决定是否允许该请求。它就像 API 客户端的单个秘密密码。
- 优点: API 密钥实现起来很简单。没有复杂的握手过程——客户端只需在每个请求中发送密钥,服务器进行验证。这种简单性使得 API 密钥在内部 API 或易用性很重要的服务中很受欢迎。此外,可以向第三方开发人员颁发 API 密钥,以跟踪每个客户端对您 API 的使用情况。
- 缺点: 安全性可能是一个问题。API 密钥通常是长期有效的凭证,因此如果密钥泄露(例如,被推送到公共仓库或在传输中被拦截),攻击者可以在其被手动撤销之前一直使用它。在不影响客户端的情况下轮换(更改)密钥具有挑战性。API 密钥通常授予广泛的访问权限(全有或全无),这使得实施细粒度的权限控制变得困难。它们本身也不代表用户——使用相同密钥的所有请求看起来都一样,这使得审计单个用户的行为变得困难。最后,如果 API 密钥在 URL 中传递,它可能会出现在日志或浏览器历史记录中,从而增加泄露的风险。
会话 Cookie
会话 Cookie 是网站使用的传统方法。当用户使用用户名/密码登录时,服务器会创建一个会话(在内存或数据库中存储用户信息),并向客户端发送一个包含会话 ID 的 Cookie。在每个请求中,浏览器会自动发送此 Cookie,服务器查找该会话以了解用户是谁。
- 优点: 会话 Cookie 对于传统的 Web 应用程序非常方便。浏览器会处理向域发送的每个请求中的 Cookie,因此客户端(开发人员)无需手动附加令牌。会话可以在服务器端存储复杂的用户状态,并且您可以通过在服务器上清除会话来轻松撤销会话(例如注销)。可以将 Cookie 设置为 HttpOnly(JavaScript 无法访问),以减轻某些攻击(如 XSS)的风险。它们也受域限制,这可以简化在子域之间使用相同登录的过程。
- 缺点: 这种方法是有状态的——服务器必须存储会话数据并保持同步,这会使扩展变得复杂。在负载均衡的环境中,用户可能会访问到没有其会话的不同服务器,这需要共享的会话存储(数据库或缓存)。如果配置不当,由于浏览器会自动发送 Cookie,因此 Cookie 容易受到跨站请求伪造 (CSRF) 的攻击。像
SameSite
Cookie 属性这样的缓解措施有所帮助,但严格的设置可能会影响用户体验(例如,破坏跨站登录)。此外,Cookie 仅在浏览器环境中有效;它们不太适合保护纯粹的后端 API 到 API 通信。事实上,Cookie 不太适合无状态的 RESTful API——在这种情况下,使用令牌更可取。最后,会话 Cookie(与任何身份验证令牌一样)必须通过 HTTPS 发送以防止窃听。
OAuth2 令牌
OAuth2 是一个行业标准的授权协议。这是一种更复杂但更灵活的端点保护方法。在 OAuth2 中,客户端不持有简单的密码;相反,它从授权服务器获取访问令牌(有时还有刷新令牌,我们稍后会讨论)。然后,此令牌会作为授权证明(通常通过像 Authorization: Bearer <token>
这样的 HTTP 标头)呈现给 API(资源服务器)。OAuth2 通常用于第三方集成和“使用 X 登录”的场景,但也用于以稳健的方式保护第一方 API。
- 优点: OAuth2 旨在解决 API 密钥和传统会话的许多缺点。由 OAuth2 服务器颁发的访问令牌通常是短暂的,并且可以附加特定的范围(权限)。例如,一个令牌可能授予对某个 API 的只读访问权限,而不是完全控制权。这限制了令牌被盗时的损害,并允许最小权限原则。OAuth2 还支持开箱即用的令牌轮换和撤销策略。由于 OAuth2 流程涉及授权服务器,因此您可以实现集中式身份验证(例如,您的用户可以登录到单个身份验证服务,该服务为各种 API 颁发令牌)。简而言之,OAuth2 令牌在授权和安全粒度方面更胜一筹。许多大型平台和身份提供商都实现了 OAuth2,因此您可以与 Google、Facebook 等集成,而无需直接处理密码。
- 缺点: OAuth2 的主要缺点是复杂性。它是一个包含多个流程(授权码、隐式、客户端凭据等)的框架,对于初学者来说可能令人生畏。您需要部署或使用授权服务器,并了解基于重定向的登录或令牌端点调用。安全地实施 OAuth2 需要仔细遵守规范。简而言之,与更简单的方法相比,它可能“更难实现”。此外,调试令牌问题或理解多步握手可能比处理单个 API 密钥或会话 Cookie 更困难。然而,许多现代库和服务(Auth0、Okta 等)可以为您处理 OAuth 的繁重工作。
JSON Web Tokens (JWT):是什么以及为什么?
现代端点安全通常依赖 JSON Web Tokens (JWT) 作为凭证格式,尤其是在像 OAuth2 这样基于令牌的身份验证方案中。JWT 本质上是一个字符串,它断言了关于用户或客户端的一些信息(“声明”),并且经过数字签名,以便服务器可以验证它。让我们详细分解一下:
什么是 JWT? JSON Web Token (JWT) 是一个开放标准 (RFC 7519),它定义了一种紧凑且自包含的方式,用于在各方之间以 JSON 对象的形式传输信息。说它“自包含”是因为令牌本身携带了识别用户和确定其权限所需的数据(声明),并且它是经过签名的(使用密钥或秘钥),因此服务器可以验证它没有被篡改。JWT 由三部分组成:头部、载荷和签名,各部分之间用点号(.
)分隔。例如,一个 JWT 可能看起来像 xxxxx.yyyyy.zzzzz
,其中第一部分是头部,第二部分是载荷(两者都是 Base64 编码的 JSON),第三部分是签名。
- 头部 (header) 包含元数据,例如令牌类型(
JWT
)和使用的签名算法(例如 HS256 或 RS256)。 - 载荷 (payload) 包含声明——您实际关心的信息(例如,用户 ID、用户名、角色和过期时间)。有一些标准的声明字段(如
iss
签发者、exp
过期时间、sub
主题等),您也可以根据需要包含自定义声明。 - 签名 (signature) 是获取头部和载荷,并对它们进行签名的结果(通常使用 HMAC 和一个密钥,或使用 RSA/ECDSA 和一个私钥)。此签名允许服务器稍后验证令牌确实是由可信来源(持有密钥/秘钥的一方)颁发的,并且载荷在传输过程中没有被更改。
本质上,JWT 就像一个用户信息防篡改的密封信封:如果有人更改了里面的数据,签名检查将失败,令牌将被拒绝。而且由于载荷是 JSON 格式,您可以放入服务器可能需要的任何信息(例如用户角色或过期时间戳)。
JWT 如何用于保护端点? 一旦用户登录并获得 JWT,客户端将在每个受保护的请求中包含该令牌(通常在 HTTP Authorization
标头中,格式为 Bearer <token>
)。服务器在每个请求中都会解析 JWT,验证签名(使用它信任的密钥或公钥),如果有效,则使用令牌中的信息来识别用户并授权操作。这是一种无状态的身份验证方法:服务器不需要保存会话数据,因为令牌本身就携带了用户的身份和角色/权限。例如,一个 API 端点可以要求 JWT 包含一个声明 "role":"admin"
;如果令牌中没有这个声明(或者令牌丢失/无效),请求将被拒绝。
JWT 的优点: JWT 结合了 API 密钥和会话的一些优点,同时避免了它们的缺点。它们是无状态且自包含的,因此服务器可以在无需每次请求都进行数据库查找的情况下验证请求。这使得 JWT 非常适合可扩展的微服务架构和 API(其中每个调用理想情况下都应该是独立的,并且每次都发送凭证)。JWT 还可以嵌入用户角色或范围,从而无需再次查找即可实现细粒度的授权。由于 JWT 只是字符串,因此它们可以轻松地跨不同域以及在移动或物联网设备中使用(不像 Cookie 那样与浏览器绑定)。而且因为它们是经过签名的,客户端无法在不被服务器检测到的情况下更改其内容(例如,提升权限)。
JWT 的缺点: JWT 并非万能处方。一个主要问题是被盗后的安全性——JWT 是一个持有者令牌,意味着任何拥有它的人都可以使用它。如果攻击者获取了您的 JWT,他们可以在令牌过期前冒充您。与服务器端会话不同,没有简单的方法可以立即撤销已颁发的 JWT(因为服务器没有关于它的记录)。JWT 在其过期时间之前一直有效——它不能被发行者在中途撤销或更改。(一种变通方法是在服务器上维护一个已泄露令牌的黑名单,但这会重新引入状态和复杂性。)此外,由于 JWT 可以存储数据,因此存在过度信任这些数据的风险。令牌是在某个时间点颁发的,可能会变得过时——例如,如果用户的角色被更改或帐户被撤销,现有的令牌可能仍具有旧的声明,从而在不应该允许访问时允许了访问。因此,令牌应具有较短的生命周期。另一个缺点是:JWT 比简单的会话 ID 或 API 密钥更大——它们携带更多数据(通常约为 300 字节或更多),这对大多数情况来说是次要的,但在每个请求中仍然是开销。最后,虽然 JWT 经过签名以防止篡改,但其载荷默认情况下并未加密。任何拦截令牌或检查它的人(甚至有专门解码 JWT 的网站)都可以读取其内容。这意味着不应将敏感数据(如密码或个人信息)放入 JWT 声明中,除非您对 JWT 载荷进行加密。
使用 JWT:访问令牌和刷新令牌
在使用 JWT 保护 API 时(尤其是在通过 OAuth2 或类似方式时),您通常会处理访问令牌 (access tokens) 和刷新令牌 (refresh tokens) 。理解它们之间的区别以及它们如何协同工作以平衡安全性和可用性非常重要。
- 访问令牌: 这通常指用于访问受保护资源的 JWT。它是一个短期有效的令牌,证明持有者已获得授权。例如,访问令牌的有效期可能是 5 分钟或 1 小时。其理念是,即使此令牌泄露,攻击者也只能在很短的时间窗口内使用它。访问令牌旨在呈现给资源服务器(您的 API),并且通常如前所述包含在
Authorization
标头中。API 验证 JWT,如果有效,则处理请求。访问令牌应相对较快地过期(几分钟或几小时),以便限制风险,并使系统能够定期要求更新或重新验证用户权限。 - 刷新令牌: 顾名思义,刷新令牌用于“刷新”访问令牌。它是一个独立的凭证,通常具有更长的生命周期(可能有效期为几天、几周或几个月)。刷新令牌绝不会直接发送到资源 API。相反,客户端会安全地存储它,当访问令牌过期时,客户端会将刷新令牌发送到授权服务器(或身份验证服务)以获取新的访问令牌(有时还会获取新的刷新令牌)。用 OAuth2 的术语来说,客户端向令牌端点发出请求,其中
grant_type=refresh_token
,并附带刷新令牌及其自身的凭证,然后获取一个新的访问令牌。刷新令牌允许客户端获取新的访问令牌,而无需提示用户再次登录,从而为长期会话提供更流畅的用户体验。
为什么不让访问令牌长期有效并跳过刷新令牌呢?原因是安全性:如果一个访问令牌有效期为(比如说)30 天并且泄露了,攻击者将拥有 30 天的访问权限。通过使用短期有效的访问令牌和刷新令牌,您可以限制攻击窗口。如果访问令牌泄露,它很快就会过期。如果刷新令牌存储得更安全(例如,HttpOnly Cookie 或安全存储)并且仅发送到身份验证服务器,则它不太可能被攻击者拦截。即使刷新令牌被盗,某些系统也会实施保护措施(例如轮换刷新令牌并在使用时使旧令牌失效、IP/设备检查等)以减轻滥用。
使用访问令牌和刷新令牌的身份验证流程: 当用户登录(或通过 OAuth2 进行身份验证)时,服务器会向客户端颁发访问令牌和刷新令牌。客户端通常将访问令牌存储在内存或短期存储中,并将刷新令牌存储在更安全、长期的存储中(因为它有效期更长)。
Authorization
访问令牌会在短时间内过期。假设其生命周期为 15 分钟。用户继续使用应用程序,15 分钟后,下一个 API 调用失败(服务器响应“未授权”或指示令牌已过期的错误代码)。此时,客户端可以自动使用刷新令牌获取新的访问令牌,而不会打扰用户。它向身份验证服务器发出后台请求(例如,一个 POST /auth/refresh
端点),并附带刷新令牌。身份验证服务器验证刷新令牌(检查其是否有效且未过期或撤销)。如果一切正常,它会响应一个新的访问令牌(通常还有一个新的刷新令牌)。然后,客户端将此新的访问令牌用于将来的请求,循环继续。如果刷新令牌本身已过期或无效,则客户端将需要让用户再次登录。
关于刷新令牌,有几点需要注意:由于它们功能强大(任何持有有效刷新令牌的人都可以继续获取新的访问令牌,可能永远如此),因此必须努力保护它们。通常,刷新令牌存储在 HttpOnly Cookie 中或保留在服务器端的会话存储中,尤其是在基于浏览器的应用程序中,这样恶意 JavaScript 就无法窃取它们。一些框架采用刷新令牌轮换机制——每次使用刷新令牌时,服务器都会颁发一个新的并使旧的失效。这样,如果攻击者窃取了刷新令牌并尝试重用它,服务器会注意到它已被使用过并拒绝它,从而有效地将窃贼注销。此外,刷新令牌通常具有固定的生命周期或使用限制(例如,刷新令牌可能在 30 天后或一定次数的使用后过期)。所有这些措施都有助于降低滥用长期凭证的风险。
使用 JWT 的最佳实践
在实施基于 JWT 的安全性时,请牢记以下最佳实践,以保持高水平的安全性:
- 使用较短的过期时间: 始终在 JWT 访问令牌上设置过期(
exp
)声明。令牌的有效期应为您应用程序所需的最小持续时间——通常是几分钟或几小时,而不是几天。短期有效的令牌限制了攻击者滥用被盗令牌的时间窗口。如果需要更长的会话,请使用刷新令牌(它们有自己的过期时间),而不是一个长期有效的令牌。例如,访问令牌可能在 15 分钟后过期,然后您根据需要使用刷新令牌来延长会话,而不是颁发一个 24 小时有效的令牌。这也强制定期重新验证用户权限。 - 始终使用 HTTPS: 切勿通过未加密的连接发送 JWT(或任何敏感令牌)。没有 HTTPS,攻击者可以嗅探网络流量并在传输过程中窃取令牌。确保您的应用程序仅通过 TLS/SSL 传输令牌。事实上,许多 JWT 库会因此拒绝在不安全的上下文中传输令牌。同样,如果使用 Cookie 存储或发送令牌,请将其标记为
Secure
,以便它们仅通过 HTTPS 传输。 - 客户端安全存储: 如何在客户端应用程序中存储令牌至关重要。如果可以,请避免将 JWT 存储在普通的 localStorage 或其他 JavaScript 可访问的位置,因为这会使它们容易受到 XSS(跨站脚本)攻击——页面上的恶意脚本可能会读取令牌并将其泄露。对于 Web 应用程序,更好的方法是将令牌存储在 HttpOnly Cookie 中,JavaScript 无法访问它。这可以防止 XSS 直接获取您的令牌(尽管在这种情况下您必须防范 CSRF)。如果您必须存储在 localStorage 或内存中,请注意风险并通过清理输入等方式来缓解 XSS。对于移动应用程序,请使用安全的密钥库/钥匙串。关键原则是最大限度地减少令牌暴露给客户端运行时的风险。此外,切勿记录令牌或在错误消息中暴露它们——像对待密码一样对待它们。
- 保持 JWT 载荷小且不敏感: 仅在令牌中包含授权所需的信息。请记住,虽然 JWT 的签名可以防止篡改,但令牌的载荷很容易解码和读取。不要将敏感的个人数据或机密信息放入令牌中。例如,存储用户 ID,而不是他们的密码。如果您需要向客户端传递更多用户信息,请在身份验证后从您的 API 获取,而不是使 JWT 变得臃肿。较小的令牌也更高效(记住它会随每个请求一起发送)。如果您有必须在令牌中传输的非常敏感的信息,请考虑使用加密的 JWT (JWE)——但这是一个更高级的话题。
- 强签名和验证: 使用强大的算法对您的 JWT进行签名。常见的选择是 HS256(使用 SHA-256 的 HMAC),它使用一个密钥;或者是 RS256(使用 SHA-256 的 RSA),它使用一个私钥/公钥对。如果使用 HS256,请确保密钥足够长且随机(像对待密码一样对待它)。如果使用 RS256,请妥善保管您的私钥。在服务器上,始终验证签名和令牌的标准声明。验证
exp
(过期时间)是否在将来,检查iss
(签发者)是否是您的服务,如果适用,检查aud
(受众)以确保令牌是为您的 API 准备的。使用经过充分测试的 JWT 库将为您处理大部分这些检查,但您必须使用正确的密钥/秘钥和预期值对其进行配置。对算法要谨慎——避免使用较弱的算法或根本不使用算法。(曾经有一个已知的 JWT 攻击,如果服务器错误地允许alg: "none"
,攻击者可以完全绕过验证。) - 实施撤销(如果需要): 从设计上讲,无状态 JWT 不易撤销。但是,请考虑如果您需要立即撤销令牌(例如,用户报告设备被盗),您将如何处理。一种策略是保持令牌生命周期非常短,这样撤销就不那么重要了(令牌很快就会自行过期)。另一种方法是在服务器端维护一个不再有效的令牌或令牌 ID 的阻止列表。例如,您可以包含一个令牌标识符(
jti
声明)并拥有一个已撤销 ID 的缓存。API 会检查签名并确保jti
不在已撤销列表中。这会引入状态和开销,因此需要权衡。如果您使用刷新令牌,则可以通过撤销刷新令牌来有效地撤销会话(因为一旦访问令牌过期,他们就无法获得新的令牌)。一些身份系统还通过在中央存储中跟踪令牌有效性来支持“随处注销”功能(例如 OAuth 服务器可以撤销令牌)。总之,在您的系统设计中规划如何处理令牌撤销或滥用。 - 策略性地使用刷新令牌: 如果您使用刷新令牌,请比访问令牌更严格地保护它们。一个常见的最佳实践是颁发绑定到用户/设备的刷新令牌,并在每次使用时轮换它们。在服务器上监控刷新令牌的使用情况——如果一个刷新令牌被多次使用(这可能表明被复制),则撤销它以防止攻击者使用被盗的令牌。此外,为刷新令牌设置合理的绝对过期时间(例如,刷新令牌可能有效期为 30 天,之后需要重新登录,即使在此期间访问令牌不断刷新)。这限制了被盗刷新令牌可能被滥用的时间。
- 考虑二次验证: 在高安全性场景中,您可能需要在 JWT 本身之外添加额外的检查。例如,某些系统在颁发令牌时传递一次性随机数或使用客户端的指纹(IP 地址、用户代理),并将这些信息嵌入到令牌声明中。然后,服务器可以验证令牌是否从相同的客户端上下文中使用。另一种方法是将 JWT 与安全 Cookie 配对作为一种确认形式。这些是高级技术,但要点是:考虑您的威胁模型。对于大多数应用程序,使用 HTTPS 和短期过期的标准 JWT 就足够了。但是,如果您要保护非常敏感的数据,分层添加额外的验证(设备绑定、轮换密钥等)可以提供深度防御。
需要避免的常见 JWT 安全陷阱
最后,让我们强调一些在使用 JWT 保护 HTTP 端点时常见的错误或陷阱(以及如何避免它们):
- 令牌泄露: 令牌可能发生的最糟糕的事情就是被未经授权方获取。如前所述,避免可能导致令牌泄露的做法。这包括通过不安全的通道发送令牌(始终使用 HTTPS),不要将令牌以纯文本形式存储在脚本可以读取的地方(使用 HttpOnly Cookie 或安全存储),以及不要在 URL 中暴露令牌。注意浏览器开发工具和日志——不要记录 JWT,如果必须在应用程序层之间传递它们,请敏感地对待它们。泄露的 JWT 就像泄露的密码;在它过期之前都可以被使用。假设任何令牌都可能被泄露,并内置缓解措施(短生命周期、刷新令牌等)以限制损害。
- 没有撤销计划: 许多新手开发人员转向 JWT 并移除了服务器端会话跟踪,后来才意识到他们无法在用户令牌过期前强制注销用户或撤销已泄露的令牌。这是一个您应该尽早做出的设计决策。如果您完全采用无状态方式,您就接受了无法立即撤销令牌的事实——因此您必须依赖于较短的过期时间。如果这对于您的用例来说是不可接受的(例如,您需要能够立即禁用用户的访问权限),那么您需要制定一个令牌撤销策略(例如维护一个黑名单或使用可以使令牌无效的集中式身份验证服务)。不为此进行规划是一个陷阱。总之,决定在需要时如何使凭证无效。对于许多应用程序来说,短期有效的令牌 + 登录时刷新就足够了(如果被撤销,用户将在短时间后被注销)。对于其他应用程序,可以考虑混合方法(大多数调用使用无状态 JWT,但有一个可撤销的刷新令牌或可以在数据库中终止的会话记录)。
- 错误地使用 JWT(JWT 用于一切): JWT 功能强大,但并非总是适用于所有场景的最简单解决方案。有时,开发人员甚至在简单的会话 Cookie 就能满足需求的情况下也使用 JWT,从而增加了不必要的复杂性。如果您的应用程序是传统的服务器端渲染网站,维护会话可能更容易且完全安全。JWT 在 API、微服务以及当您需要无状态、可移植的令牌(或通过 OAuth 进行第三方委托)时表现出色。但是要避免以忽略其约束的方式强行使用 JWT。例如,不要使用生命周期极长的 JWT 来替代会话——这结合了两种方式的最坏缺点(不灵活且不安全)。另外,请注意不要混淆身份验证与授权:JWT 对令牌持有者进行身份验证,但您仍必须强制执行授权(例如,在允许操作之前检查 JWT 中的用户角色)。一个常见的错误是假设仅仅存在有效的 JWT 就意味着用户可以执行任何操作——您仍应根据声明验证其权限。
- 忽略签名/验证基础知识: 另一个陷阱是错误配置 JWT 库——例如,实际上没有验证令牌签名或信任使用错误算法签名的令牌。始终确保您的服务器根据预期的密钥或公钥检查签名。不要接受使用未知算法签名的令牌。此外,如果您有多个签发者(例如,来自 Google 和您自己系统的令牌),请验证签发者字段,以免接受其他人发给不同受众的令牌。使用经过充分测试的库并遵循其文档以确保安全使用。切勿为了开发中的“方便”而禁用验证,然后在生产中忘记重新启用它。
- Cookie 引发的 CSRF: 如果您选择将 JWT 存储在 Cookie 中(这是一种常见且相对安全的方法,尤其适用于 Web 应用程序),请记住 Cookie 会受到 CSRF 攻击,因为浏览器会自动发送它们。您的 API(如果它接受 Cookie 身份验证)应实施 CSRF 保护(例如,要求对状态更改请求使用 CSRF 令牌/标头,或在 Cookie 上设置
SameSite
属性)。或者,某些架构仅将 Cookie 用于刷新令牌,并要求客户端在标头中显式发送访问令牌,从而分散风险:访问令牌 (JWT) 不会由浏览器自动发送,从而避免了受保护端点上的 CSRF,而刷新 Cookie 是 HttpOnly 的,并且仅在专用的刷新端点上使用(该端点可以进行 CSRF 保护或设置为 SameSite)。简而言之,要注意 JWT 与 Web 安全机制之间的相互作用。
借助 JWT 和可靠的身份验证设计,我们的 API 可以既安全又用户友好,使授权用户能够与您的服务进行交互,同时将不良行为者拒之门外。