主题
认证:从“登录成功”到每次请求都认对人
认证要解决两件事:第一次确认来者是谁,以及后续请求怎样可靠地延续这次确认。密码校验只是其中一环。一个系统即使密码没被破解,也可能因为会话固定、令牌验签不完整、刷新令牌失窃或账号恢复太宽松而认错人。
本文沿着浏览器登录、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
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: 拒绝,要求重新认证
本地状态机的三次调用紧邻结果如下:
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 Token | OIDC 客户端 | 验证用户在发行者处的认证结果 | 交给任意业务 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: 创建本应用会话
| 参数 | 绑定或证明什么 | 在哪检查 |
|---|---|---|
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.1go.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 ./...