Challenge 机制详解:从身份验证到一次性操作授权

Challenge 机制详解:从身份验证到一次性操作授权 敏感操作真正需要的,不只是“验证码正确”,而是确认:哪个用户,正在对哪个资源,执行哪一种操作,并且这次确认只能使用一次。 Challenge 就是承载这段验证上下文和状态变化的一次性任务。 开发中的痛点 假设系统准备实现“注销账号”。最直接的

Challenge 机制详解:从身份验证到一次性操作授权

敏感操作真正需要的,不只是“验证码正确”,而是确认:哪个用户,正在对哪个资源,执行哪一种操作,并且这次确认只能使用一次。

Challenge 就是承载这段验证上下文和状态变化的一次性任务。


开发中的痛点

假设系统准备实现“注销账号”。最直接的方案,是向用户手机号发送验证码,然后在注销接口中同时校验手机号和验证码:

public void deleteAccount(Long accountId, String phone, String code) {
    if (!otpService.verify(phone, code)) {
        throw new IllegalArgumentException("验证码错误");
    }
    accountService.delete(accountId);
}

这段代码可以运行,却没有完整表达安全语义:

  • phone 来自客户端,服务端是否能确定它就是该账号的可信验证号码?
  • 验证码只能说明当前请求者能够访问该验证通道,但它是否仅授权“注销这个账号”?
  • 同一个验证码能否被拿去修改密码、解绑银行卡或操作另一个账号?
  • 两个并发请求同时提交正确验证码时,是否会重复执行?
  • 验证通过与业务执行分属不同接口时,中间状态保存在哪里?

具体实例:

假设系统提供“修改绑定手机号”和“注销账号”两个敏感接口。为了复用验证码服务,后端只按照用户 ID 保存验证码:

sms:code:{userId} = 482913

两个接口都使用相同的校验逻辑:

otpService.verify(userId, code);

用户原本在修改手机号页面申请了验证码。此时,如果页面中存在恶意脚本、登录会话被劫持,或者验证码在前端流转过程中泄露,攻击者可以把这份验证码提交给注销账号接口。

从验证码服务的视角看,所有条件都成立:

用户正确
验证码正确
验证码未过期
验证码尚未使用

但用户申请验证码时想确认的是“修改手机号”,系统实际执行的却是“注销账号”。即使验证码只能使用一次,攻击者仍可能通过并发抢先把它消费在错误接口上。

问题的根源是验证码只绑定了 userId,没有绑定:

action = CHANGE_BOUND_PHONE
resourceId = account:42

如果使用 Challenge,服务端会在创建任务时固定这些上下文。注销接口要求的动作是 DELETE_ACCOUNT,与 Challenge 中的 CHANGE_BOUND_PHONE 不一致,因此即使 Response 正确,也不能执行注销。

Challenge 不能阻止会话劫持或验证码钓鱼,但它可以限制一份验证结果的授权用途,避免同一份证明被跨接口、跨资源复用。

验证码解决的是“响应是否正确”,没有天然解决“这次响应被允许用于什么”。

方案一:验证码直接绑定最终接口

最终业务请求携带验证码,由业务服务现场校验。

优点

  • 接口少,开发成本低。
  • 验证和业务执行距离近。

缺点

  • 每个敏感接口都要重复实现验证码校验。
  • 验证对象、失败次数和有效期容易出现不一致。
  • 很难支持旧手机号、新手机号、管理员审批等多阶段流程。
  • 验证逻辑与业务事务强耦合。

方案二:验证成功后在会话中记录一个标记

例如在 Session 或 Redis 中写入 secureVerified=true,或者颁发一份只绑定用户和有效期的通用安全凭证,后续敏感接口统一读取该状态或凭证。

优点

  • 验证流程和最终业务接口得到拆分。
  • 多个接口可以复用统一的验证入口。

缺点

  • 标记没有绑定具体动作与资源,授权范围过大。
  • 标记在有效期内可能被反复使用。
  • 无法区分“验证过身份”和“授权了某笔操作”。

真正的问题不是“验证码能不能验证成功”,而是:

如何把一次验证结果,安全地转换为对指定资源执行一次指定操作的授权?

这正是应用层 Challenge 要解决的问题。


阅读须知

  • 适用场景:账号注销、联系方式变更、设备下线、支付确认、管理员高风险操作等。
  • 前置知识:了解基本的 HTTP 接口、Redis 数据结构和 Lua 脚本即可。
  • 示例环境:Java 17+、Spring Boot 3.x、Redis 6.x/7.x。
  • 概念范围:本文主要讨论应用层的有状态 Challenge,并在后文说明它与 WebAuthn、OAuth PKCE 中同名概念的区别。

特别说明:本文的应用层 Challenge 是一种领域建模方案,不是 W3C、IETF 或 RFC 定义的统一标准对象。文中的字段、状态和接口均属于可落地的工程设计,而不是协议强制规范。

本文优先讲清楚:为什么需要、保存什么、如何流转、怎样防重放、失败时如何处理。


本文要解决什么

本文最终希望实现:

  • 由服务端确定验证用户、业务动作、目标资源和验证方式。
  • 将验证码、WebAuthn 或审批结果放入统一的任务生命周期中。
  • 验证成功后签发短期、限用途、一次性的操作凭证。
  • 防止凭证跨用户、跨动作、跨资源使用。
  • 在并发请求下原子推进状态,避免重复验证和重复消费。
  • 限制错误次数,并让过期、锁定、取消等状态可观测。

最终调用效果可以概括为:

申请敏感操作
    ↓
服务端创建 Challenge 并固定上下文
    ↓
用户完成短信 / TOTP / WebAuthn / 审批验证
    ↓
服务端签发一次性操作凭证
    ↓
最终接口消费凭证并执行业务

整体设计

一个完整的应用层 Challenge 通常包含以下组件:

组件 主要职责
业务入口 确定用户要执行的动作和目标资源
Challenge 服务 创建任务、维护状态、限制失败次数
验证器 校验短信、TOTP、WebAuthn 或人工审批结果
状态存储 保存短生命周期上下文,常见实现为 Redis
一次性凭证 表示 Challenge 已完成,但仅授权一个动作和资源
最终执行入口 原子消费凭证,再执行业务操作

整体时序如下:

sequenceDiagram participant C as 客户端 participant B as 业务服务 participant S as Challenge 服务 participant V as 验证通道 participant R as Redis C->>B: 申请执行敏感操作 B->>B: 校验用户与资源关系 B->>S: 创建 Challenge(user, action, resource) S->>R: 保存服务端确定的上下文 S->>V: 发起验证码 / WebAuthn / 审批 S-->>C: 返回 challengeId C->>S: 提交 challengeId + response S->>R: 原子校验并推进状态 S-->>C: 返回一次性操作凭证 C->>B: 携带凭证请求最终操作 B->>R: 原子校验并消费凭证 B->>B: 再次校验 action 与 resource B-->>C: 执行业务并返回结果

这里有一个重要原则:客户端负责表达意图,服务端负责确定授权上下文。

客户端可以点击“注销账号”,但具体注销哪个账号、使用哪个验证号码、需要哪种验证方式,必须由服务端根据当前登录身份和业务数据决定。


实现前需要理解的关键机制

1. Challenge 到底是什么

Challenge-Response 的原始含义是:验证方给出一个不可预测的问题,证明方根据持有的秘密或私钥生成响应,验证方再判断响应是否与本次问题匹配。

例如 WebAuthn 中,服务端生成随机 challenge,认证器使用私钥对包含该值的数据进行签名。旧签名中包含的是旧 challenge,无法直接用于新的认证过程,因此可以抵抗重放。W3C 要求 WebAuthn 的 challenge 由可信环境随机生成、响应必须匹配原值,并建议至少包含 16 字节随机量。W3C WebAuthn Level 3

而在常见业务系统中,开发者还会把“本次验证任务”整体称为 Challenge。它不只是一个随机数,而是一个服务端状态对象:

Challenge = 随机标识 + 验证上下文 + 状态机 + 有效期 + 验证结果

本文后续提到的 Challenge,主要指这一层应用模型。

2. Challenge、验证码和 Token 的区别

概念 回答的问题 典型生命周期
Challenge 正在验证谁、为什么验证、验证哪项操作 从创建到完成或失效
验证码 / Response 用户是否完成了本次证明 通常几分钟且只能校验一次
一次性 Token 验证完成后允许执行什么 从签发到最终接口消费

验证码是 Challenge 的一种响应方式,不是 Challenge 本身。Token 则是验证成功后的授权收据。

如果三者混在一起,系统很容易把“知道一个验证码”错误地扩大成“可以执行所有敏感操作”。

3. 身份认证不等于操作授权

登录证明“当前请求者是谁”,敏感操作验证则要确认“当前用户是否明确同意执行这项操作”。

OWASP 的事务授权指南建议:重要操作的数据应由服务端生成和保存,授权凭证应与单次操作绑定,并设置有限的使用窗口。用户还应能识别自己正在确认的关键数据,例如收款方、金额或目标账号。OWASP Transaction Authorization Cheat Sheet

因此,可靠的 Challenge 至少应该回答:

谁在确认?
确认什么动作?
动作作用于哪个资源?
使用什么方式确认?
确认结果可以使用几次?
多久后失效?

4. 一次性与原子状态转换

“先查询验证码,再更新状态”不是一个原子操作:

请求 A:读取状态 WAITING
请求 B:读取状态 WAITING
请求 A:验证成功并签发 Token A
请求 B:验证成功并签发 Token B

同样,“先读取 Token,再删除 Token”也可能让两个请求同时通过。

所以 Challenge 的两个关键阶段都需要原子化:

  1. 验证响应、累计错误次数、推进状态、签发凭证。
  2. 校验凭证、读取上下文、删除凭证。

从设计到实现

下面使用通用 Java 与 Redis 代码实现一个最小但完整的 Challenge 模型。代码只展示关键逻辑,不依赖特定项目结构。


第一步:定义 Challenge 契约

先定义允许授权的动作和任务状态:

public enum ChallengeAction {
    DELETE_ACCOUNT,
    CHANGE_BOUND_PHONE,
    REVOKE_DEVICE,
    CONFIRM_PAYMENT
}
public enum ChallengeStatus {
    WAITING,
    VERIFIED,
    CONSUMED,
    LOCKED,
    CANCELLED
}

EXPIRED 可以作为显式状态持久化,也可以由 Redis TTL 到期后通过“记录不存在”表达。若系统需要审计,建议把最终状态异步写入数据库,而不是只依赖 Redis。

核心上下文可以定义为:

public record ChallengeContext(
        String challengeId,
        Long userId,
        ChallengeAction action,
        String resourceId,
        String resourceDigest,
        String verifierType,
        String maskedTarget,
        ChallengeStatus status,
        int failedAttempts
) {
}

其中最容易遗漏的是 resourceDigest。它用于固定用户确认时的关键业务数据:

resourceDigest = SHA-256(canonicalEncode({
    action,
    resourceId,
    amount,
    receiver,
    version
}))

canonicalEncode 必须固定字段顺序、字符编码、空值表示和数字格式。不能直接拼接字符串,否则不同字段组合可能产生相同输入,跨语言实现也可能得到不同摘要。

如果金额、收款方或资源版本发生变化,摘要也会变化,原 Challenge 就不能继续使用。不过,摘要只能证明“最终数据与创建 Challenge 时的数据一致”,不能单独证明“用户确实看到了这些数据”。对于转账等交易授权,界面或可信设备还必须向用户展示收款方、金额等关键数据,让用户明确确认后再产生 Response。


第二步:创建 Challenge

创建入口不应该接受客户端任意指定的验证号码。业务服务先确定动作和资源,再从可信数据源取得验证对象:

public ChallengeCreated create(
        Long userId,
        ChallengeAction action,
        String resourceId) {

    businessPolicy.checkAllowed(userId, action, resourceId);

    String verifyTarget = accountRepository.findVerifiedPhone(userId);
    String challengeId = randomId(32);
    String code = randomSixDigits();

    ChallengeContext context = contextFactory.create(
            challengeId, userId, action, resourceId, verifyTarget);

    redisRepository.saveChallenge(context, Duration.ofMinutes(10));
    redisRepository.saveCodeDigest(
            challengeId,
            hmacSha256(serverSecret, challengeId + ":" + code),
            Duration.ofMinutes(5));

    messageSender.send(verifyTarget, code, action);
    return new ChallengeCreated(challengeId, mask(verifyTarget), 300);
}

这段代码包含几个关键约束:

  1. challengeId 使用密码学安全随机数,不能使用递增 ID。
  2. 动作和资源来自服务端业务入口。
  3. 验证目标来自服务端可信数据源。
  4. Challenge 有效期可以长于验证码,但不能无限续期。
  5. 短验证码不建议直接保存;可使用服务端秘密作为 Key,对 challengeId + 分隔符 + code 计算 HMAC。
  6. 消息发送失败时,应删除刚创建的任务和验证码摘要。

W3C 对密码学 challenge 建议至少使用 16 字节随机量。应用层 ID 也可以采用这一安全下限;使用 32 字节随机量则更充裕。


第三步:建立状态机

Challenge 不是一个布尔值,而是一组受约束的状态迁移:

stateDiagram-v2 [*] --> WAITING: 创建任务 WAITING --> VERIFIED: 响应正确 WAITING --> LOCKED: 失败次数超限 WAITING --> CANCELLED: 用户或系统取消 VERIFIED --> CONSUMED: 最终操作消费凭证 WAITING --> [*]: TTL 到期 VERIFIED --> [*]: 凭证到期 LOCKED --> [*] CANCELLED --> [*] CONSUMED --> [*]

服务端只允许预先定义的迁移:

  • WAITING → VERIFIED
  • WAITING → LOCKED
  • WAITING → CANCELLED
  • VERIFIED → CONSUMED

客户端不能提交 status=VERIFIED,也不能请求把 LOCKED 恢复为 WAITING。需要重试时,应创建新的 Challenge


第四步:使用 Lua 原子核销响应

验证时需要同时完成:检查任务、校验归属、比较响应、累计失败次数、推进状态和签发凭证。Redis Lua 可以把这些操作放在一次原子执行中。

一次性 Token 采用以下格式:

<challengeId>.<32 字节随机 secret>

challengeId 是定位 Redis Key 的 selector,随机 secret 才是主要授权秘密。两部分都使用 Base64URL 编码,并限定字符集和长度;解析时只允许一个分隔点。由于客户端在创建阶段已经获得 challengeId,把它放入 Token 不会额外泄露授权能力。

服务端可直接从 Token 解析 challengeId,定位固定的 grantKey

String token = challengeId + "." + randomBase64Url(32);
String grantKey = "security:challenge:{" + challengeId + "}:grant";

下面的示例将验证码转换为 HMAC 摘要后传入脚本。actionresourceIdresourceDigestcontextJson 必须由服务端根据已保存的 Challenge 构造,不能接收客户端提交的同名字段:

local challengeKey = KEYS[1]
local responseKey = KEYS[2]
local grantKey = KEYS[3]

local userId = ARGV[1]
local submittedDigest = ARGV[2]
local newToken = ARGV[3]
local contextJson = ARGV[4]
local action = ARGV[5]
local resourceId = ARGV[6]
local resourceDigest = ARGV[7]
local grantTtl = tonumber(ARGV[8])
local challengePostVerifyTtl = tonumber(ARGV[9])
local maxAttempts = tonumber(ARGV[10])

if redis.call('exists', challengeKey) == 0 then
    return 'CHALLENGE_NOT_FOUND'
end

if redis.call('hget', challengeKey, 'userId') ~= userId then
    return 'OWNER_MISMATCH'
end

local status = redis.call('hget', challengeKey, 'status')
if status == 'VERIFIED' then
    if redis.call('exists', grantKey) == 0 then
        return 'GRANT_EXPIRED'
    end
    return 'VERIFIED:' .. (redis.call('hget', challengeKey, 'token') or '')
end
if status ~= 'WAITING' then
    return 'STATE_INVALID'
end

local expectedDigest = redis.call('get', responseKey)
if not expectedDigest then
    return 'RESPONSE_EXPIRED'
end

if expectedDigest ~= submittedDigest then
    local attempts = redis.call('hincrby', challengeKey, 'failedAttempts', 1)
    if attempts >= maxAttempts then
        redis.call('hset', challengeKey, 'status', 'LOCKED')
        redis.call('del', responseKey)
        return 'ATTEMPTS_EXCEEDED'
    end
    return 'RESPONSE_INVALID'
end

redis.call('del', responseKey)
redis.call('hset', challengeKey, 'status', 'VERIFIED', 'token', newToken)
redis.call('expire', challengeKey, challengePostVerifyTtl)
redis.call('hset', grantKey,
        'token', newToken,
        'userId', userId,
        'action', action,
        'resourceId', resourceId,
        'resourceDigest', resourceDigest,
        'context', contextJson)
redis.call('expire', grantKey, grantTtl)
return 'VERIFIED:' .. newToken

为了兼容 Redis Cluster,脚本涉及的多个 Key 必须位于同一个 Hash Slot。可以使用相同的 Hash Tag:

security:challenge:{challengeId}:meta
security:challenge:{challengeId}:response
security:challenge:{challengeId}:grant

花括号中的内容相同,Redis Cluster 才允许 Lua 同时访问这些 Key。

这里还实现了有限幂等:并发或网络重试导致重复验证时,如果任务已经是 VERIFIED,服务端返回仍然有效的原凭证,而不是再次签发一份新凭证。

Challenge 与 Grant 的 TTL 不能各自独立计算。验证成功时,脚本把 Challenge 的剩余 TTL 重置为:

challengePostVerifyTtl = grantTtl + auditGraceTtl

例如 Grant 有效 5 分钟,可以让已验证 Challenge 再保留 6 分钟。这样 Grant 一定先过期,Challenge 还会多保留一分钟供状态查询和审计。最终消费时仍要检查 Challenge 是否存在,不能只依赖这层时间关系。


第五步:原子消费一次性凭证

验证成功并不代表业务已经执行。系统还需要一个短期凭证,把验证结果交给最终业务接口。

消费时不能使用普通的 GETDEL,也不能先删除凭证,再到 Java 中检查授权范围。当前登录用户、动作、资源和资源摘要都要在删除前完成比较:

local grantKey = KEYS[1]
local challengeKey = KEYS[2]
local submittedToken = ARGV[1]
local currentUserId = ARGV[2]
local expectedAction = ARGV[3]
local expectedResourceId = ARGV[4]
local expectedResourceDigest = ARGV[5]

if redis.call('exists', grantKey) == 0 then
    return 'GRANT_INVALID'
end

if redis.call('exists', challengeKey) == 0 then
    redis.call('del', grantKey)
    return 'CHALLENGE_EXPIRED'
end

if redis.call('hget', grantKey, 'token') ~= submittedToken then
    return 'GRANT_INVALID'
end
if redis.call('hget', challengeKey, 'status') ~= 'VERIFIED' then
    return 'STATE_INVALID'
end
if redis.call('hget', challengeKey, 'token') ~= submittedToken then
    return 'STATE_INVALID'
end

if redis.call('hget', grantKey, 'userId') ~= currentUserId then
    return 'SCOPE_MISMATCH'
end
if redis.call('hget', grantKey, 'action') ~= expectedAction then
    return 'SCOPE_MISMATCH'
end
if redis.call('hget', grantKey, 'resourceId') ~= expectedResourceId then
    return 'SCOPE_MISMATCH'
end
if redis.call('hget', grantKey, 'resourceDigest') ~= expectedResourceDigest then
    return 'RESOURCE_CHANGED'
end

local context = redis.call('hget', grantKey, 'context')
redis.call('del', grantKey)
redis.call('hset', challengeKey, 'status', 'CONSUMED')
return 'CONSUMED:' .. context

Lua 会先完成所有授权范围校验,全部匹配后才读取上下文、删除凭证并把任务推进到 CONSUMED。错误用户、错误接口或错误资源不会提前烧毁一份合法 Token。两个并发请求仍然只有一个能够消费成功。

对客户端应统一返回“凭证无效或不适用于当前操作”,避免通过不同错误信息枚举用户、动作和资源;服务端日志则保留具体失败原因用于审计。

脚本在写入状态前检查 challengeKey 是否存在。如果 Challenge 已经过期,则删除残留 Grant 并返回失败,不执行 HSET,从而避免 Redis 重新创建一个没有 TTL 的永久 Challenge。grantKeychallengeKey 仍需使用相同的 {challengeId} Hash Tag。

Java 层负责解析 Token selector,并把当前登录用户和服务端计算的期望范围传入 Lua:

public ChallengeContext consume(
        String token,
        Long currentUserId,
        ChallengeAction expectedAction,
        String expectedResourceId,
        String expectedResourceDigest) {

    GrantTokenParts parts = GrantTokenParts.parseStrictly(token);
    String result = grantRepository.consumeAtomically(
            parts.challengeId(),
            token,
            currentUserId,
            expectedAction,
            expectedResourceId,
            expectedResourceDigest);

    return consumeResultParser.requireContext(result);
}

其中 currentUserId 必须来自服务端认证上下文,不能来自请求体。一次性凭证至少要绑定:

userId + action + resourceId + challengeId

如果业务数据在验证后可能变化,还要读取当前资源快照,使用相同的规范化算法重新计算 resourceDigest,再传入消费脚本比较。


第六步:接入最终业务接口

最终接口不再接收手机号、验证码或动作类型,只接收业务参数和一次性凭证:

@DeleteMapping("/accounts/{accountId}")
public void deleteAccount(
        @PathVariable Long accountId,
        @RequestHeader("X-Action-Token") String token) {

    Long currentUserId = securityContext.requiredUserId();
    AccountSnapshot snapshot = accountService.getDeletionSnapshot(accountId);

    challengeService.consume(
            token,
            currentUserId,
            ChallengeAction.DELETE_ACCOUNT,
            accountId.toString(),
            snapshot.resourceDigest());

    accountService.deleteIfVersionMatches(
            currentUserId, accountId, snapshot.version());
}

业务服务仍然应该检查当前用户与资源的关系。Challenge 是敏感操作的额外授权证明,不替代登录认证、权限控制和资源归属校验。

Redis 中的摘要比较与数据库更新之间仍存在时间窗口。最终写操作应携带资源版本执行条件更新,例如 WHERE id = ? AND version = ?;版本变化时拒绝执行并重新发起 Challenge。这样才能防止摘要校验后、数据库写入前资源再次改变。


验证实现

不要只验证“正确验证码能够通过”。一个可用的 Challenge 至少应覆盖以下场景:

场景 输入或条件 预期结果
正常验证 用户、响应、状态均正确 签发一次性凭证
跨用户验证 用户 B 提交用户 A 的 challengeId 拒绝验证
Token 被其他用户提交 用户 B 提交用户 A 的 Token 拒绝执行,Token 不被消费
跨动作使用 注销凭证用于修改手机号 拒绝执行,Token 不被消费
跨资源使用 资源 A 的凭证操作资源 B 拒绝执行,Token 不被消费
响应过期 验证码 Key 已过期 Challenge 不通过
Challenge 过期 上下文已经删除 要求重新创建
Grant 晚于 Challenge 过期 Grant 尚在但 Challenge 已删除 清理 Grant,不重建永久 Challenge
Token selector 非法 Token 不含合法 challengeId 解析阶段直接拒绝
连续错误 达到最大失败次数 状态变为 LOCKED
并发核销 两个请求同时提交正确响应 只签发一份有效凭证
重复验证 验证成功后因网络原因重试 返回尚未消费的原凭证
重复消费 两个请求使用同一凭证 只有一个请求成功
资源被修改 当前摘要与创建时不同 原 Challenge 失效

测试方法可以直接围绕安全语义命名:

@Test
void shouldRejectGrantIssuedForAnotherResource() {
    // given: 为资源 A 完成 Challenge
    // when: 使用其凭证操作资源 B
    // then: 拒绝执行,资源 A 和 B 均不变化,凭证仍可用于资源 A
}
@Test
void shouldRejectStolenGrantWithoutConsumingIt() {
    // given: 用户 A 获得一份有效凭证
    // when: 用户 B 在自己的登录会话中提交该凭证
    // then: 拒绝执行,用户 A 仍可正常消费
}
@Test
void shouldConsumeGrantOnlyOnceUnderConcurrency() {
    // given: 一份有效的一次性凭证
    // when: 两个线程同时消费
    // then: 一个成功,一个得到“已使用或过期”
}

为什么这样设计?

方案对比

方案 优点 缺点 适用场景
最终接口直接校验验证码 简单、调用链短 重复代码多,难以表达多阶段状态 低复杂度、单接口验证
会话级验证标记 接入容易 授权范围过大,容易重复使用 极低风险的短期“近期验证”
短期 JWT 无状态,跨服务传递方便 天然难以保证一次性消费 允许多次使用的短期授权
数据库工作流 审计能力强,事务模型明确 写入成本高,需要处理并发更新 长周期审批、强审计流程
Redis Challenge 状态清晰、TTL 原生、原子核销方便 依赖 Redis 可用性,审计需额外设计 短周期、一次性敏感操作

为什么不直接使用短期 JWT?

JWT 擅长携带可验证声明,但一个合法 JWT 在过期前可以被重复提交。若要实现“只能消费一次”,仍需在服务端保存 jti 或消费状态。

既然已经需要有状态存储,使用一个不透明的随机 Token 往往更直接:服务端可以原子读取上下文并删除记录,不必承担 JWT 撤销列表和状态同步的额外复杂度。

为什么要把验证和执行拆成两步?

因为验证通道与业务执行的生命周期不同:

  • 验证码可能需要发送、重发、限流和累计错误次数。
  • WebAuthn 需要先取得随机 challenge,再提交签名响应。
  • 人工审批可能持续几分钟甚至几小时。
  • 最终业务执行可能由另一个服务负责。

Challenge 提供稳定的边界:验证服务只负责产生“可执行一次的授权结果”,业务服务只负责消费结果并执行业务。

当前方案的核心权衡

简单验证码校验
    ↓ 增加状态与上下文
可追踪的 Challenge
    ↓ 增加动作和资源绑定
最小权限的一次性授权
    ↓ 增加 Lua 与失败处理
并发下仍然可靠的状态机

多出来的复杂度,换来的是明确的授权范围、可控的生命周期和可证明的一次性消费。


边界条件与失败场景

1. Challenge 不能让短信验证码抗钓鱼

Challenge 可以限制验证码最终能授权什么,但不能阻止用户把验证码输入到钓鱼网站,再由攻击者实时转发。

NIST 明确指出,手工输入的带外验证码和 OTP 不具备抗钓鱼能力,因为响应没有以密码学方式绑定到真实验证方或当前会话。NIST SP 800-63B

高风险场景应优先考虑 WebAuthn、硬件密钥或能够绑定验证方名称与交易数据的密码学方案。Challenge 是编排和授权模型,不会自动提升底层验证因子的安全等级。

2. 凭证消费成功,但业务执行失败

如果系统在进入业务方法前就消费凭证,随后数据库更新失败,用户需要重新完成验证。

常见处理策略有三种:

  1. 安全优先:凭证一旦进入最终接口就失效,失败后重新创建 Challenge。
  2. 业务幂等:最终请求携带业务幂等键,重试时查询第一次执行结果。
  3. 状态型执行:将 VERIFIED → EXECUTING → CONSUMED 建模为持久化工作流,再通过事务或消息实现最终一致性。

普通账号设置通常选择第一种。支付、转账等强一致性场景不能只靠 Redis Token,需要把授权状态与业务交易放进更严格的执行模型。

3. Redis 故障或数据丢失

Challenge 属于短期授权数据。Redis 数据丢失时,最安全的行为是拒绝操作并要求重新验证,而不是绕过验证。

如果业务要求完整审计,可以将以下事件异步写入审计库:

CHALLENGE_CREATED
RESPONSE_FAILED
CHALLENGE_VERIFIED
GRANT_CONSUMED
CHALLENGE_LOCKED

审计记录不应被用来恢复已经丢失的一次性 Token。

4. 验证期间资源发生变化

例如用户确认转账 100 元,但验证完成前金额被改为 1000 元。只绑定 resourceId 无法发现变化。

解决方式是绑定资源版本或关键字段摘要,并在最终执行前重新比较。摘要不一致时终止流程,要求用户基于新数据重新确认。


Challenge 不只用于短信验证码

应用层 Challenge 可以编排多种验证方式:

验证方式 Response 是什么 Challenge 负责保存什么
短信 / 邮箱验证码 用户输入的短期随机码 用户、动作、资源、目标号码、失败次数
TOTP 认证器生成的动态口令 本次业务上下文、时间窗口、尝试次数
WebAuthn 认证器生成的签名断言 随机 challenge、RP、用户、业务上下文
App 推送确认 已绑定设备的签名或确认结果 设备、会话、动作、交易摘要
人工审批 审批人的决定与审计信息 申请人、审批人、资源、审批阶段

需要注意:不同协议中的 challenge 并不完全相同。

  • WebAuthn challenge 是由服务端生成的高熵随机值,被包含在签名流程中,核心目标之一是防重放。
  • OAuth PKCE code challenge 是客户端根据 code_verifier 计算出的派生值,用来把授权请求与后续 Token 请求绑定,防止截获的授权码被直接兑换。RFC 7636
  • 应用层 Challenge 是业务系统保存的一次验证任务,可以在内部使用验证码、WebAuthn 或其他证明方式。

它们共享“先提出一个与本次流程关联的条件,再验证后续响应”的思想,但参与方、威胁模型和验证算法并不相同。


常见踩坑

坑一:让客户端提交完整 Challenge 上下文

现象

验证接口允许客户端再次提交手机号、动作和资源 ID:

{
  "phone": "13800000000",
  "action": "DELETE_ACCOUNT",
  "resourceId": "42",
  "code": "123456"
}

原因

系统没有把 challengeId 当作服务端上下文的索引,而是相信客户端重放的业务数据。

解决

验证接口只接收:

{
  "challengeId": "随机任务标识",
  "response": "用户响应"
}

其他数据全部从服务端状态中读取。

坑二:验证成功后签发通用 Token

现象

用户完成一次验证后,可以用同一 Token 执行多种敏感操作。

原因

Token 只绑定了 userId,没有绑定 actionresourceId

解决

把授权范围缩小到一次具体操作:

用户 10
只能执行 DELETE_ACCOUNT
只能操作账号 42
只能消费一次
五分钟后失效

坑三:使用多条 Redis 命令模拟原子操作

现象

单元测试全部通过,线上并发时却偶尔签发两个 Token 或执行两次业务。

原因

测试覆盖了业务分支,却没有覆盖两个请求交错执行的时间窗口。

解决

使用 Lua、Redis 事务或带版本号的数据库条件更新。必须验证“状态仍是预期值”与“推进到下一状态”属于同一个原子操作。

坑四:只限制验证码有效期,不限制失败次数

现象

攻击者可以在五分钟内高频猜测六位验证码。

原因

六位十进制验证码只有一百万种组合,不能只依赖过期时间。

解决

同时实施账号、Challenge、IP 和设备维度的速率限制。NIST 要求短于 64 位的认证输出配合失败尝试限制,并要求有效 OTP 只能被接受一次。NIST SP 800-63B


最佳实践

在实际项目中,建议:

  1. 服务端生成上下文:用户、动作、资源和验证目标均从可信数据源确定。
  2. 使用高熵标识:Challenge ID 至少使用 128 位密码学安全随机量。
  3. 绑定关键业务数据:必要时保存资源版本或交易摘要。
  4. 协调三段生命周期:Challenge、Response 和一次性 Token 分别设置 TTL;验证成功后保证 Challenge 比 Grant 晚过期。
  5. 限制失败尝试:达到阈值后锁定任务,不允许原地恢复。
  6. 设计可定位 Token:使用高熵 selector 定位 Grant,但不把 selector 本身当作授权秘密。
  7. 原子推进状态:验证和消费分别使用 Lua 或数据库条件更新。
  8. 消费前校验范围:在删除 Token 前原子比较当前用户、动作、资源和资源摘要。
  9. 最小化凭证权限:一次凭证只对应一个用户、动作和资源。
  10. 安全失败:状态缺失、Redis 异常或上下文不一致时一律拒绝操作。
  11. 记录安全事件:审计创建、失败、锁定、验证成功和最终消费。
  12. 明确底层因子能力:短信 Challenge 仍不抗钓鱼,高风险业务应使用密码学认证器。

同时避免:

  1. 使用递增 ID 作为 challengeId。
  2. 允许客户端选择验证码接收号码。
  3. 用一个会话级布尔值代表所有敏感操作已验证。
  4. 签发可以重复使用的通用敏感操作 Token。
  5. 验证成功后允许修改动作、资源或交易数据。

总结

本文解决的核心问题是:

如何把一次身份或设备验证,转换成对指定资源执行一次指定操作的可信授权。

整体思路可以概括为:

服务端接收业务意图
    ↓
创建并固定 Challenge 上下文
    ↓
用户完成一种验证响应
    ↓
原子推进状态并签发一次性凭证
    ↓
最终接口原子消费凭证
    ↓
再次校验动作、资源和业务数据

Challenge 的核心优势是:

  • 让验证过程具备明确状态和有效期。
  • 让验证结果绑定用户、动作和资源。
  • 让授权凭证能够防止重放和跨场景复用。
  • 让短信、WebAuthn、审批等验证方式共享统一的业务边界。

适合:

  • 高风险操作需要二次确认。
  • 验证与业务执行位于不同接口或服务。
  • 操作必须绑定具体资源并限制为一次。
  • 验证过程包含多个阶段或需要审计。

不适合:

  • 普通低风险 CRUD。
  • 不需要跨步骤保存状态的简单校验。
  • 支付等必须与核心交易强一致、却只打算依赖短期 Redis 状态的场景。

真正需要记住的是:

Challenge 不是“换个地方保存验证码”,而是一次有上下文、有状态、有边界、可被原子消费的操作授权过程。


参考资料

Jakarta Bean Validation 自定义校验,实现 "自解释" 的角色编码校验 2026-07-10
别把文件上传写成一个 `save`:可靠上传接口的设计与实现 2026-07-22

评论区