Skip to content

认证:从“登录成功”到每次请求都认对人 ​

认证要解决两件事:第一次确认来者是谁,以及后续请求怎样可靠地延续这次确认。密码校验只是其中一环。一个系统即使密码没被破解,也可能因为会话固定、令牌验签不完整、刷新令牌失窃或账号恢复太宽松而认错人。

本文沿着浏览器登录、API 验证、联合登录和账号恢复这条线,解释 Session、JWT、OAuth 2.0、OIDC、SSO、MFA 与 Passkey 各自负责什么。实验只在本地验证令牌和刷新状态机,不连接真实身份提供方,也不把演示代码当作完整登录系统。

先把凭据、认证、会话和授权拆开 ​

名词回答的问题订单系统中的例子不能由它直接推出的结论
凭据来者能证明自己掌握什么?密码、认证器私钥知道一个用户 ID 不是证明
认证这份证明对应哪个主体?issuer + subject 对应用户 7用户 7 能看所有订单
会话后续请求如何延续认证结果?随机 Session ID、访问令牌会话永久有效
授权这个主体现在能否做这件事?用户 7 能否取消订单 42登录过就自动允许

主体不一定是人,也可能是后台任务或工作负载。把这几个问题混成一个 loggedIn=true,后续就很难表达“用户已登录,但转账需要再次认证”或“身份有效,但租户已被停用”。认证和授权的区别见 OWASP 认证说明;对象级授权要在资源访问时另行判断。

Session 和 Token 不是两种互斥的登录方式 ​

Token 是凭据的泛称,随机 Session ID 本身也是一种 token。工程里常说的“Session vs Token”,通常是在比较“随机标识指向服务端状态”和“令牌携带可独立验证的声明”。Cookie 则只是浏览器保存和发送数据的机制,既可以装 Session ID,也可以装 JWT。

比较维度不透明 Session ID自包含签名访问令牌,例如 JWT
请求时怎样确认身份查会话存储,得到主体与状态验签,再检查声明和本地策略
服务端主要成本会话存储、查询、跨实例一致性密钥分发、验签、失效策略
立即退出或封禁删除或撤销会话;处理副本与缓存延迟单凭签名无法立即撤销,需短有效期、拒绝表或在线检查
客户端能看到什么通常只有随机字符串JWS 中的声明可被解码读取
更适合的起点同一业务域里的浏览器应用多个资源服务需要验证受众受限的访问凭据
主要取舍状态集中,撤销直观;存储需可靠可减少在线查询;撤销和权限变更更复杂

这张表是在比较实现方式,不是在规定项目必须选某一种。浏览器应用完全可以让后端保存 OAuth 令牌,只给浏览器发 Session Cookie。这样减少令牌暴露给页面脚本的机会,代价是增加后端会话状态。

一个合理的浏览器会话生命周期如下。登录成功后换新会话 ID,是为了让登录前被固定或泄露的 ID 不能继续代表已登录用户。会话管理指南

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
stateDiagram-v2
    [*] --> Anonymous
    Anonymous --> Authenticated: 验证凭据并更换会话 ID
    Authenticated --> Authenticated: 每次检查有效期与状态
    Authenticated --> Elevated: 敏感操作重新认证
    Elevated --> Authenticated: 提权窗口到期
    Authenticated --> Revoked: 退出或账号停用
    Elevated --> Revoked: 凭据失窃或退出
    Revoked --> Anonymous: 清理客户端 Cookie
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
stateDiagram-v2
    [*] --> Anonymous
    Anonymous --> Authenticated: 验证凭据并更换会话 ID
    Authenticated --> Authenticated: 每次检查有效期与状态
    Authenticated --> Elevated: 敏感操作重新认证
    Elevated --> Authenticated: 提权窗口到期
    Authenticated --> Revoked: 退出或账号停用
    Elevated --> Revoked: 凭据失窃或退出
    Revoked --> Anonymous: 清理客户端 Cookie

Cookie 应按用途配置 Secure、HttpOnly、SameSite,缩小 Domain/Path 范围;可用 __Host- 前缀约束主机 Cookie。HttpOnly 防止脚本直接读取 Cookie,但同源恶意脚本仍可能借浏览器发请求;SameSite 也不能替代完整 CSRF 设计。还要分别定义空闲超时、绝对超时和敏感操作的重认证窗口,不要用“刷新一次就永久续期”代替这些规则。Cookie 前缀与属性 是浏览器行为说明;这里的组合是工程建议。

JWT:先验它是谁签的,再看它究竟在说什么 ​

常见的签名 JWT 使用 JWS Compact 格式:base64url(header).base64url(payload).base64url(signature)。前两段是编码,不是加密。把个人信息、密码或内部密钥塞进 payload,不会因为最后一段有签名就保密。JWT 也能用 JWE 加密,但那是另一套格式和处理过程。RFC 7519 §3

签名或 MAC 证明令牌没有被未持有相应密钥的人修改,并不证明其中每一个字段符合当前应用的意图。

检查对象应当检查什么省掉后的具体问题
alg 与密钥类型服务端预先确定允许算法,并绑定合适密钥让输入选择验证规则,可能出现算法混淆
kid、密钥来源只从已配置发行者的可信密钥集中选择把输入里的 URL 当密钥地址会引入 SSRF 或替换信任源
iss精确匹配预期发行者接受另一套身份系统签的令牌
aud包含当前资源服务的受众把给别的服务的令牌拿来使用
exp、nbf当前时间在有效窗口内;业务要求的字段必须存在缺失到期字段的令牌可能没有期限
iat按令牌协议检查签发时间、最大寿命及合理时钟偏差接受未来签发或寿命过长的凭据
sub、typ主体格式、令牌类型与当前用途匹配把 ID Token 当访问令牌,或混用不同主体命名空间
jti协议若要求则校验存在,并与撤销或一次性消费状态结合一个随机 ID 本身不会阻止重放

字段语义来自 RFC 7519 §4.1,算法、受众与令牌类型隔离见 RFC 8725 §3。JWT 的通用规范并不要求每种令牌都必须带上述所有字段;哪些字段必填,必须由具体协议或你的令牌配置约定。下面的本地实验就明确要求 exp/iat/nbf/sub/jti,并把寿命限制为五分钟。

不同签名算法,改变的是密钥分工 ​

家族谁能生成有效令牌谁能验证适用条件与成本
HS256,HMAC所有持有共享秘密的一方同一批持密钥方接入简单,但每个验证服务也有伪造能力;秘密必须高熵
RS256 / PS256,RSA私钥持有方公钥持有方便于分离签发和验证,密钥与签名较大;两种填充不是同一算法
ES256,ECDSA私钥持有方公钥持有方较紧凑,必须交给正确实现的库处理编码与密码学细节
Ed25519 / Ed448(全指定 JOSE 名称)私钥持有方公钥持有方分工清晰;需核对 JOSE 标识、库版本与合规环境支持

现行 RFC 9864 §2.2 已将多态 JOSE 算法标识 EdDSA 标为 Deprecated,定义了全指定的 Ed25519 与 Ed448。这是 JOSE 标识的变化,不是宣称 Ed25519 密码算法被攻破。下面固定库版本仍使用 EdDSA,只作兼容性演示;设计新协议要确认双方对新标识的支持,不能自行把字符串改名后假定互通。

没有一个名字能替你解决密钥泄漏、错误受众或吊销问题。选择算法以后还要约定 key ID、发布新公钥、过渡期间同时接受新旧公钥,以及旧签名何时彻底停止接受。JWA、EdDSA 的 JOSE 定义

在 Go 中把验证规则写进代码 ​

本例固定 golang-jwt/jwt/v5 v5.3.1,使用其支持的旧 JOSE EdDSA 标识,弃用边界见上节。自定义 typ="orders-at+jwt" 区分这套教学令牌;本例不宣称符合 RFC 9068 的 at+jwt profile。下面是完整程序中的关键选项;Claims.Validate 还检查业务必填字段和最大寿命。密钥回调只返回本地测试公钥,不会根据令牌内容联网。

go
jwt.WithValidMethods([]string{"EdDSA"}),
jwt.WithIssuer("https://issuer.example"),
jwt.WithAudience("orders-api"),
jwt.WithExpirationRequired(),
jwt.WithIssuedAt(),
jwt.WithTimeFunc(func() time.Time { return now }),

程序用一次性随机 Ed25519 密钥签出正常、错误受众和到期的令牌,然后把正常令牌的 jti 放进本地拒绝表。本次 Go 1.27.2、Linux/amd64 实测:

text
valid=true
wrong-audience-rejected=true
expired-rejected=true
revoked-rejected=true

错误受众的令牌也有正确签名。它被拒绝是因为“这份声明不是给 orders-api 的”,这正是只验签会遗漏的边界。WithExpirationRequired 也不能省:固定版本默认会验证已存在的 exp,但默认不要求它出现。固定版本验证选项

刷新与吊销:有效签名不会自动变成无效 ​

访问令牌短期使用;刷新令牌拿去授权服务器换取新的访问令牌,不能直接代替访问令牌调用业务 API。缩短访问令牌寿命可以缩短失窃后的暴露窗口,但会增加刷新频率,并不能做到立即失效。

常见的撤销方案有四种:会话或令牌内省每次在线查状态;记录 jti 拒绝表直到令牌原到期时间;维护用户/会话版本并检查当前版本;接受短访问令牌的剩余窗口,同时立即撤销刷新令牌。它们都在延迟、存储、可用性和撤销速度之间取舍。更换签名密钥会影响同一密钥签出的全部令牌,不适合作为日常单用户退出机制。内省 RFC 7662、撤销 RFC 7009

刷新轮换把“旧刷新令牌只能用一次”落实成服务端状态。若旧令牌再次出现,可能是副本失窃,也可能是客户端并发刷新或重试。服务端不能凭签名分辨二者,必须制定明确的重试策略,而不是忽略重复使用。RFC 9700 §4.14

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
sequenceDiagram
    participant C as 客户端
    participant A as 授权服务器
    participant D as 刷新状态存储
    C->>A: 刷新令牌 R0
    A->>D: 原子确认 R0 有效并消费,创建 R1
    D-->>A: 同一事务成功
    A-->>C: 新访问令牌与 R1
    C->>A: 再次提交已经消费的 R0
    A->>D: 命中已消费记录,撤销该令牌族
    A-->>C: 拒绝,要求重新认证
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
sequenceDiagram
    participant C as 客户端
    participant A as 授权服务器
    participant D as 刷新状态存储
    C->>A: 刷新令牌 R0
    A->>D: 原子确认 R0 有效并消费,创建 R1
    D-->>A: 同一事务成功
    A-->>C: 新访问令牌与 R1
    C->>A: 再次提交已经消费的 R0
    A->>D: 命中已消费记录,撤销该令牌族
    A-->>C: 拒绝,要求重新认证

本地状态机的三次调用紧邻结果如下:

go
f.Rotate("public-r0", "public-r1")
f.Rotate("public-r0", "public-r2")
f.Rotate("public-r1", "public-r3")
text
rotated
reuse-revoked-family
family-revoked

实验选择严格复用检测,两次并发刷新会导致一次成功、一次触发整族撤销。这是刻意展示的取舍,不是推荐所有系统照搬。生产实现需要随机高熵令牌、仅保存令牌摘要、持久化原子更新、过期清理、设备与客户端绑定、限流,以及响应丢失时不会无限重放的客户端策略。RFC 9700 要求公共客户端使用发送方约束的刷新令牌或轮换;发送方约束还需要相应密钥和证明验证,不能只增加一个字段。

OAuth 2.0、OIDC 与 SSO 各管哪一段 ​

OAuth 2.0 的核心是委托访问:用户同意让一个客户端以受限权限访问资源。OIDC 在 OAuth 之上定义身份层,用 ID Token 等信息告诉客户端此次认证的结果。SSO 是“一次在身份提供方登录,多个应用可以复用这次登录”的体验与架构,不是 JWT 的另一个名字。OAuth 2.0、OIDC Core §1–2

返回物主要接收方用来做什么不应该怎么用
授权码 code客户端的回调与 token endpoint一次性兑换令牌当作长期会话凭据
Access Token指定资源服务器访问授权范围内的资源默认拿来证明客户端登录身份
ID TokenOIDC 客户端验证用户在发行者处的认证结果交给任意业务 API 当访问令牌
Refresh Token授权服务器续期授权放 URL、业务日志或随意交给第三方

新设计可从 Authorization Code + PKCE 开始。公共客户端必须用 PKCE;机密客户端也推荐用。不要新建密码授予模式,RFC 9700 已明确禁止它;隐式授予也不应当作为新应用的默认方案。RFC 9700 §2

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
sequenceDiagram
    participant B as 浏览器
    participant C as 应用后端
    participant I as 身份提供方
    B->>C: 开始登录
    C->>C: 生成并保存一次性 state、nonce、verifier
    C-->>B: 跳转授权端点,含 S256 challenge
    B->>I: 登录与同意授权
    I-->>B: 回调地址加 code 与 state
    B->>C: 回调
    C->>C: 检查本次会话绑定的 state
    C->>I: code 加 verifier,在 token endpoint 兑换
    I-->>C: ID Token 和 Access Token
    C->>C: 验签并检查 issuer、audience、时间、nonce
    C-->>B: 创建本应用会话
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
sequenceDiagram
    participant B as 浏览器
    participant C as 应用后端
    participant I as 身份提供方
    B->>C: 开始登录
    C->>C: 生成并保存一次性 state、nonce、verifier
    C-->>B: 跳转授权端点,含 S256 challenge
    B->>I: 登录与同意授权
    I-->>B: 回调地址加 code 与 state
    B->>C: 回调
    C->>C: 检查本次会话绑定的 state
    C->>I: code 加 verifier,在 token endpoint 兑换
    I-->>C: ID Token 和 Access Token
    C->>C: 验签并检查 issuer、audience、时间、nonce
    C-->>B: 创建本应用会话
参数绑定或证明什么在哪检查
state当前浏览器会话发起的这笔授权事务客户端回调,和服务端保存值比较并消费
PKCE verifier/challenge兑换授权码者掌握这笔事务的随机秘密授权服务器 token endpoint;优先 S256
OIDC nonce返回的 ID Token 对应此次认证请求客户端验完 ID Token 后匹配并消费

PKCE 的计算关系是 BASE64URL(SHA256(verifier)),不是把 verifier 直接放进授权请求。RFC 7636 §4 授权服务器还必须把回调 redirect_uri 与预注册值精确匹配,原生应用 localhost 端口例外按协议处理。客户端若支持多个发行者,应把发起事务的发行者、端点与回调绑定,并按 RFC 9700 的 mix-up 防护验证授权响应的发行者等信息,不能仅按回调中未经验证的参数切换 token endpoint。

三者目的有重叠,但检查的位置和对象不同。RFC 9700 允许在明确满足其条件时依赖 PKCE 或 OIDC nonce 提供回调 CSRF 防护;上图同时保留 state 是便于检查事务绑定的工程选择,不是声称规范在所有流程都要求三者同时存在。

OIDC 还要按流程检查 azp 等适用声明;不能拿邮件地址当跨发行者稳定主键,通常应以已验证的 (iss, sub) 绑定本地账号。Discovery 与 JWKS 只能从预配置的可信发行者读取,不能跟随任意输入 URL。SSO 中各应用仍有独立会话,退出一个应用不等于身份提供方和其他应用都已退出;全局退出需要另外实现相应注销协议和会话传播。OIDC 验证规则、RP-Initiated Logout

MFA 与 Passkey:提升登录强度,也别留下更弱的恢复入口 ​

多因素认证要求不同类别的因素,例如知道的密码、持有的认证器、设备内验证的生物特征。两个密码或密码加安全问题不自动成为两个独立因素。短信和 TOTP 可以提高盗号难度,但用户仍可能把一次性码输入钓鱼网站;是否抵抗钓鱼,要看证明是否绑定正确验证方,而不是页面上出现了几步。NIST SP 800-63B-4

Passkey 通常使用 WebAuthn 公钥凭据。服务端保存公钥;认证器持有私钥,对服务端的随机 challenge 及关联数据签名。浏览器和认证器参与 RP ID/origin 约束,服务端还必须验证 challenge、origin、RP ID hash、签名、用户存在与所需用户验证标志。生物识别通常在设备本地解锁凭据,不是把指纹交给网站。WebAuthn 认证验证算法

方案被偷的是密码时对钓鱼的主要边界需要付出的管理成本
密码 + TOTP仍需一次性码实时中继钓鱼仍可能盗用认证器迁移、丢失恢复、失败限流
Passkey,要求用户验证不依赖网站密码正确验证 RP/origin 时能抵抗凭据钓鱼注册、设备与同步账号恢复、凭据撤销
硬件绑定安全密钥私钥通常不经同步复制同样依赖正确的 WebAuthn 验证备用密钥、丢失替换、硬件发放

是否满足某个认证保证级别,要看具体部署、用户验证、同步与恢复机制,不能把“支持 Passkey”直接等同于任何合规等级。同步 Passkey 与设备绑定凭据也有不同风险边界。账号恢复如果只凭容易接管的邮箱或客服问答就能更换认证器,攻击者会绕过强登录流程去走恢复流程。NIST 同步认证器要求、OWASP MFA 恢复建议

落地时应先画出注册、登录、换设备、丢设备、撤销与恢复的完整路径,再选库。对高价值操作保留重认证、异常通知和恢复冷却期;这是根据业务风险设计的建议,具体时间不能从一篇通用文章推出。

依据与验证边界 ​

  • 协议:RFC 7519、8725、6749、7636、7009、7662、9700;上文已分别链接到声明、验证、授权码、撤销和刷新规则。
  • 联合身份:OIDC Core 1.0 errata 2、RP-Initiated Logout 1.0;WebAuthn Level 3、NIST SP 800-63B-4。WebAuthn 链接是持续更新的 Level 3 文档,部署时还应核对目标浏览器与服务端库支持。
  • 库实现:jwt v5.3.1 parser_option.go、validator.go。固定版本用于复现,不代表未来版本安全结论。
  • 本次于 2026-10-13 用官方 Go 1.27.2、Linux/amd64 运行 go run .、go test -race -count=1 ./...、go vet ./...。正常令牌、错误受众、到期、撤销、错误发行者、缺字段、未来时间、错密钥、错算法、错误 typ/kid、缺失 iat/nbf、超长寿命及并发刷新都有测试。
  • 未运行真实浏览器 Cookie/CSRF 流程、OAuth/OIDC 互通、JWKS 轮换、SSO 注销、Passkey 注册与恢复。没有测试真实账号、生产存储、故障恢复和风控;这些不能从本地测试通过推出。

完整复现代码 ​

以下文件放在同一个空目录。时间固定为测试时刻,密钥每次运行重新生成,不含真实秘密;刷新令牌使用公开标签,只展示状态,不提供可部署凭据实现。

go.mod ​

go.mod
module example.com/security01

go 1.27.2

require github.com/golang-jwt/jwt/v5 v5.3.1

go.sum ​

text
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=

main.go ​

代码块 · 110 行 · 点击收起 / 展开
go
package main

import (
	"crypto/ed25519"
	"crypto/rand"
	"errors"
	"fmt"
	"sync"
	"time"

	"github.com/golang-jwt/jwt/v5"
)

var now = time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)

type Claims struct{ jwt.RegisteredClaims }

func (c Claims) Validate() error {
	if c.Subject == "" || c.ID == "" || c.IssuedAt == nil || c.NotBefore == nil {
		return errors.New("required application claims")
	}
	if c.ExpiresAt == nil || !c.ExpiresAt.After(c.IssuedAt.Time) || c.ExpiresAt.Sub(c.IssuedAt.Time) > 5*time.Minute {
		return errors.New("invalid application lifetime")
	}
	return nil
}
func validClaims() Claims {
	return Claims{jwt.RegisteredClaims{Issuer: "https://issuer.example", Subject: "user-7", Audience: jwt.ClaimStrings{"orders-api"}, ExpiresAt: jwt.NewNumericDate(now.Add(299 * time.Second)), NotBefore: jwt.NewNumericDate(now.Add(-time.Second)), IssuedAt: jwt.NewNumericDate(now.Add(-time.Second)), ID: "public-test-jti"}}
}

// Public test fixture only: this process creates disposable keys and never persists them.
func sign(c Claims, key ed25519.PrivateKey) string {
	t := jwt.NewWithClaims(jwt.SigningMethodEdDSA, c)
	t.Header["typ"] = "orders-at+jwt"
	t.Header["kid"] = "local-test-key"
	s, err := t.SignedString(key)
	if err != nil {
		panic(err)
	}
	return s
}
func verify(raw string, pub ed25519.PublicKey, revoked map[string]bool) error {
	c := new(Claims)
	t, err := jwt.ParseWithClaims(raw, c, func(t *jwt.Token) (any, error) {
		if t.Header["typ"] != "orders-at+jwt" || t.Header["kid"] != "local-test-key" {
			return nil, errors.New("wrong token profile")
		}
		return pub, nil
	}, jwt.WithValidMethods([]string{"EdDSA"}), jwt.WithIssuer("https://issuer.example"), jwt.WithAudience("orders-api"), jwt.WithExpirationRequired(), jwt.WithIssuedAt(), jwt.WithTimeFunc(func() time.Time { return now }))
	if err != nil {
		return err
	}
	if !t.Valid {
		return errors.New("invalid token")
	}
	if revoked[c.ID] {
		return errors.New("revoked")
	}
	return nil
}

// A single in-memory refresh family. The identifiers are public labels, not bearer secrets.
// A real service needs hashed random tokens, durable atomic storage, expiry and a retry policy.
type Family struct {
	mu      sync.Mutex
	current string
	used    map[string]bool
	revoked bool
}

func (f *Family) Rotate(old, next string) string {
	f.mu.Lock()
	defer f.mu.Unlock()
	if f.revoked {
		return "family-revoked"
	}
	if f.used[old] {
		f.revoked = true
		return "reuse-revoked-family"
	}
	if old != f.current || old == "" || next == "" || next == old || f.used[next] {
		return "invalid"
	}
	f.used[old] = true
	f.current = next
	return "rotated"
}
func demo() []string {
	pub, key, err := ed25519.GenerateKey(rand.Reader)
	if err != nil {
		panic(err)
	}
	c := validClaims()
	out := []string{fmt.Sprint("valid=", verify(sign(c, key), pub, nil) == nil)}
	wrong := c
	wrong.Audience = jwt.ClaimStrings{"billing-api"}
	out = append(out, fmt.Sprint("wrong-audience-rejected=", verify(sign(wrong, key), pub, nil) != nil))
	expired := c
	expired.ExpiresAt = jwt.NewNumericDate(now)
	out = append(out, fmt.Sprint("expired-rejected=", verify(sign(expired, key), pub, nil) != nil))
	out = append(out, fmt.Sprint("revoked-rejected=", verify(sign(c, key), pub, map[string]bool{c.ID: true}) != nil))
	f := &Family{current: "public-r0", used: map[string]bool{}}
	out = append(out, f.Rotate("public-r0", "public-r1"), f.Rotate("public-r0", "public-r2"), f.Rotate("public-r1", "public-r3"))
	return out
}
func main() {
	for _, line := range demo() {
		fmt.Println(line)
	}
}

main_test.go ​

代码块 · 91 行 · 点击收起 / 展开
go
package main

import (
	"crypto/ed25519"
	"crypto/rand"
	"github.com/golang-jwt/jwt/v5"
	"reflect"
	"sync"
	"testing"
	"time"
)

func TestDemo(t *testing.T) {
	want := []string{"valid=true", "wrong-audience-rejected=true", "expired-rejected=true", "revoked-rejected=true", "rotated", "reuse-revoked-family", "family-revoked"}
	if got := demo(); !reflect.DeepEqual(got, want) {
		t.Fatal(got)
	}
}
func TestRejectClaimsAndKey(t *testing.T) {
	pub, key, _ := ed25519.GenerateKey(rand.Reader)
	tests := map[string]func(*Claims){"issuer": func(c *Claims) { c.Issuer = "https://wrong.example" }, "missing-exp": func(c *Claims) { c.ExpiresAt = nil }, "missing-id": func(c *Claims) { c.ID = "" }, "future-iat": func(c *Claims) { c.IssuedAt = jwt.NewNumericDate(now.Add(time.Minute)) }, "future-nbf": func(c *Claims) { c.NotBefore = jwt.NewNumericDate(now.Add(time.Minute)) }, "empty-sub": func(c *Claims) { c.Subject = "" }}
	for name, mutate := range tests {
		t.Run(name, func(t *testing.T) {
			c := validClaims()
			mutate(&c)
			if verify(sign(c, key), pub, nil) == nil {
				t.Fatal("accepted")
			}
		})
	}
	_, other, _ := ed25519.GenerateKey(rand.Reader)
	if verify(sign(validClaims(), other), pub, nil) == nil {
		t.Fatal("wrong key accepted")
	}
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, validClaims())
	raw, err := token.SignedString([]byte("PUBLIC TEST FIXTURE, NOT A REAL SECRET"))
	if err != nil {
		t.Fatal(err)
	}
	if verify(raw, pub, nil) == nil {
		t.Fatal("wrong alg accepted")
	}
}
func TestConcurrentRefresh(t *testing.T) {
	f := &Family{current: "r0", used: map[string]bool{}}
	var wg sync.WaitGroup
	results := make(chan string, 2)
	for range 2 {
		wg.Go(func() { results <- f.Rotate("r0", "r1") })
	}
	wg.Wait()
	close(results)
	counts := map[string]int{}
	for r := range results {
		counts[r]++
	}
	if counts["rotated"] != 1 || counts["reuse-revoked-family"] != 1 {
		t.Fatal(counts)
	}
}

func TestProfileAndRequiredTimes(t *testing.T) {
	pub, key, _ := ed25519.GenerateKey(rand.Reader)
	for _, name := range []string{"typ", "kid"} {
		token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, validClaims())
		token.Header["typ"] = "orders-at+jwt"
		token.Header["kid"] = "local-test-key"
		token.Header[name] = "wrong"
		raw, err := token.SignedString(key)
		if err != nil {
			t.Fatal(err)
		}
		if verify(raw, pub, nil) == nil {
			t.Fatal("accepted wrong", name)
		}
	}
	for _, name := range []string{"iat", "nbf", "long-life"} {
		c := validClaims()
		switch name {
		case "iat":
			c.IssuedAt = nil
		case "nbf":
			c.NotBefore = nil
		case "long-life":
			c.ExpiresAt = jwt.NewNumericDate(now.Add(time.Hour))
		}
		if verify(sign(c, key), pub, nil) == nil {
			t.Fatal("accepted", name)
		}
	}
}

运行命令 ​

sh
go mod download
go run .
go test -race -count=1 ./...
go vet ./...

保留各书来源与许可 · 示例运行条件以正文为准