JWT与OAuth令牌接口安全评估 - 第3节
3.1 为什么令牌格式如此重要?
记得我最早做接口安全评估那会儿,看到一个Access 就直接放行了。后来被前辈狠批了一顿——他说:"你知道这串字符里藏着什么吗?"嗯,从那天起我就明白了,令牌格式本身就是最直接的安全防线。
不同令牌有不同的结构、生命周期和验证方式。搞不清楚这些,安全评估就是空中楼阁。这一章咱们就从最常见的三种令牌类型说起。
3.2 JWT结构:三段的秘密
说白了,JWT就是一个用点号分隔的三段字符串。你拿到一个JWT,第一眼注意到的就是那两个点。
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.
dGhpcyBpcyBhIGZha2Ugc2lnbmF0dXJlCg
嗯,你可能觉得这只是一堆乱码。但每个部分都经过Base64URL编码,解码后就清晰了。
3.2.1 Header(头部)
Header就干两件事:声明令牌类型和签名算法。我见过不少新人忽略这个部分,觉得没什么好看的。但安全漏洞往往就藏在这里。
{
"alg": "RS256",
"typ": "JWT"
}
我曾经在渗透测试中遇到过某个系统,Header里的alg直接设成"none"(注意不是算法名称,而是直接取消签名验证)。整个JWT的签名部分被完全绕过了。你想想,任意伪造身份,这得多可怕。
常见算法与安全性对比如下:
| 算法 | 对称/非对称 | 安全等级 | 评价 |
|---|---|---|---|
| HS256 | 对称 | 中 | 密钥需共享,私钥泄露风险高 |
| RS256 | 非对称 | 高 | 我推荐首选,公私钥分离 |
| none | - | 无 | 禁止在生产环境使用 |
| ES256 | 非对称 | 高 | 椭圆曲线,性能更好 |
如果你在做安全评估,第一件事就是用工具(比如jwt.io)解码Header。看一眼alg字段,如果看到"none"、"None"、"NoSignature"之类的东西——直接亮红灯。
3.2.2 Payload(载荷)
Payload就是令牌的核心数据。它包含一组"声明"(Claims)。说白了就是你告诉对方你是谁、有什么权限、什么时候过期。
{
"sub": "1234567890",
"name": "John Doe",
"admin": true,
"iat": 1516239022,
"exp": 1516242622,
"iss": "https://auth.example.com",
"aud": "https://api.example.com"
}
这里要注意,JWT的Payload是Base64URL编码的,不是加密的。什么意思?任何拿到令牌的人都能解码看内容。所以千万别往里面放密码、信用卡号这种敏感数据。
- iss (Issuer): 签发者 — 验证令牌来源是否可信
- sub (Subject): 主体 — 用户的唯一标识
- aud (Audience): 受众 — 令牌目标服务(防止令牌被重用)
- exp (Expiration): 过期时间 — 务必验证当前时间是否在exp之前
- nbf (Not Before): 生效时间 — 令牌在nbf之前无效
- iat (Issued At): 签发时间 — 辅助审计
- jti (JWT ID): 唯一标识 — 防止重放攻击
3.2.3 Signature(签名)
签名是整个JWT的安全底线。它的生成过程是这样的:
signature = HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
或者用非对称算法时:
signature = RSASHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
privateKey
)
客户端发来JWT,服务端用同样的密钥(对称)或公钥(非对称)重新计算签名。如果计算结果不一致,直接拒绝。你想想看,如果有人篡改了Payload里的"admin": false改成"admin": true,签名肯定对不上,攻击就失败了。
我曾经见过一个案例,服务端用RS256验证,但客户端把Header的alg改成HS256,然后拿服务端的公钥当对称密钥来伪造签名。听起来绕?其实就是利用了服务端验证逻辑不够严谨。正确的做法是:必须在校验签名时固定允许的算法列表。
3.3 OAuth Access 与 Refresh
OAuth 2.0协议定义了两种核心令牌:Access 和Refresh 。很多人搞混它们的职责。
3.3.1 Access (访问令牌)
Access 是客户端用来访问受保护资源的凭证。它有效期很短——常见的15分钟到1小时。为什么?因为短生命周期可以最大限度降低泄露风险。
Access 的格式可以是任何一种:JWT、SAML、甚至是一个随机字符串(当服务端需要回查时)。在安全评估时,我第一步就是看Access 的过期时间。
| 属性 | Access | Refresh |
|---|---|---|
| 生命周期 | 短(15分钟~1小时) | 长(数小时~数月) |
| 存储位置 | 内存或安全Cookie | 后端或安全存储(如Keychain) |
| 传输频率 | 每次API请求都携带 | 仅在令牌刷新时发送 |
| 泄露影响 | 短期可控 | 高危,需立即撤销 |
3.3.2 Refresh (刷新令牌)
Refresh 的作用就一个:在Access 过期后,无痛地换取一个新的Access 。它不直接访问资源,但它能"造"出新的Access ,所以它的保护级别要比Access 高得多。
我个人习惯在安全评估中重点检查以下几点:
- Refresh 是否绑定——有没有和特定的客户端IP或设备ID做绑定?
- 是否支持撤销——用户主动登出时,Refresh 是否被立即失效?
- 轮换策略——我建议每次使用Refresh 后都返回一个新,旧立即作废。这样可以防止泄露后被盗用。
我参与的一个项目中,我们实现了Refresh 的轮换。每次客户端用Refresh 换取新Access 时,服务端都返回一个新的Refresh ,旧的那个直接失效。这样就算某个Refresh 被截获,它也只能用一次。
3.4 SAML断言:XML格式的令牌
SAML断言和JWT不一样——它用XML格式,通常用在企业级单点登录(SSO)场景。你可能会觉得XML有点过时了,但很多政府、金融机构的系统还在用。
<?xml version="1.0" encoding="UTF-8"?>
<saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
ID="_123456789"
IssueInstant="2023-11-01T10:00:00Z"
Version="2.0">
<saml:Issuer>https://idp.example.com</saml:Issuer>
<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:SignedInfo>
<ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
<ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
</ds:SignedInfo>
<ds:SignatureValue>...</ds:SignatureValue>
</ds:Signature>
<saml:Subject>
<saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">
john.doe@example.com
</saml:NameID>
<saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
<saml:SubjectConfirmationData NotOnOrAfter="2023-11-01T10:10:00Z"
Recipient="https://sp.example.com/acs"/>
</saml:SubjectConfirmation>
</saml:Subject>
<saml:Conditions NotBefore="2023-11-01T09:55:00Z"
NotOnOrAfter="2023-11-01T10:10:00Z">
<saml:AudienceRestriction>
<saml:Audience>https://sp.example.com</saml:Audience>
</saml:AudienceRestriction>
</saml:Conditions>
<saml:AuthnStatement AuthnInstant="2023-11-01T09:59:00Z">
<saml:AuthnContext>
<saml:AuthnContextClassRef>
urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport
</saml:AuthnContextClassRef>
</saml:AuthnContext>
</saml:AuthnStatement>
</saml:Assertion>
做SAML安全评估时,我最关注三个要素:
- 签名验证:XML签名有没有被验证?我曾经遇到的案例里,服务端只验证了签名是否存在,却没验证签名是否有效。攻击者就伪造带任意签名的断言通过了。
- 时间窗口:
NotBefore和NotOnOrAfter决定了断言的有效期。永远要检查当前时间是否在这个窗口内。 - 受众限制(Audience Restriction):断言指定了它要发给哪个服务提供方(SP)。如果服务端不检查这个字段,一个发给A系统的断言就能直接用在B系统上。
3.5 三种令牌格式对比总结
好,咱们把三种令牌放一起做个对比,方便你心里有个全景图。
| 特性 | JWT | OAuth Access | SAML断言 |
|---|---|---|---|
| 格式 | JSON(Base64URL编码) | 不固定(常用JWT或opaque) | XML |
| 自包含性 | ✅ 是,Payload包含所有信息 | 取决于实现 | ✅ 是,断言包含完整内容 |
| 加密方式 | 签名(JWS)或加密(JWE) | 通常不加密,靠HTTPS | 支持签名和加密 |
| 典型应用 | API认证、分布式系统 | OAuth 2.0 + OIDC | 企业SSO、联邦身份 |
| 安全评估重点 | 算法、签名、过期时间 | 作用域、生命周期、撤销 | 签名验证、时间窗口、受众 |
嗯,说到这里,我想强调一个容易被忽略的点:不管用什么格式的令牌,**解析和验证永远要放在服务端逻辑的最前面**。别让客户端来决定令牌的格式、算法甚至内容。我在审计中见过太多"先信任再验证"的案例——结果就是各种漏洞。