资讯详情

Kanidm OAuth2 Refresh Token 设计解析:NIB 机制、重放检测与复制一致性

📅 2026/10/12 20:08:29 | 华诺云谱 👁 阅读
Kanidm OAuth2 Refresh Token 设计解析:NIB 机制、重放检测与复制一致性
后端认证鉴权安全【免费下载链接】kanidmKanidm: A simple, secure, and fast identity management platform项目地址https://gitcode.com/gh_mirrors/ka/kanidm点击查看免费下载本文围绕 Kanidm 身份管理平台中 OAuth2 刷新令牌refresh token的官方设计文档展开深入剖析 Kanidm 如何通过「Not Issued BeforeNIB」时间戳机制实现刷新令牌的at most once语义、会话吊销的快速传播以及在多副本复制环境下的最终一致性保障。读完本文你将掌握 Kanidm OAuth2 令牌体系中访问令牌与刷新令牌的完整生命周期、重放攻击的检测与处置流程、失效令牌的清理策略并能对照仓库源码与测试用例理解每个设计决策的落地实现。背景为什么 OAuth2 令牌需要定期报到Kanidm 早期的认证会话因令牌缺乏权限分离privilege separation会话时长被限制在 1 小时。在实现权限分离后会话时长被延长到 8 小时未来还可能继续增加。但这对 OAuth2 提出了一个挑战OAuth2 访问令牌access token在过期前都被视为有效。如果直接签发有效期 8 小时甚至更长的访问令牌将带来两个严重问题令牌滥用窗口过长恶意用户rogue users可以在很长一段时间内持续使用令牌直到被强制重新认证吊销传播延迟当某个账户必须被强制终止时该用户仍可访问各个 OAuth2 应用长达 8 小时或更久。为此设计文档给出的结论是OAuth2 令牌必须周期性报到check in以重新确认会话仍然有效。这就是引入访问令牌 刷新令牌两层结构的原因访问令牌生命周期很短设计提案为 15 分钟由依赖方relying party应用直接使用刷新令牌应用必须定期用刷新令牌回 Kanidm 换取新的访问令牌而 Kanidm 在这一步可以核对底层会话的真实有效性与吊销状态。于是每个 OAuth2 应用必须周期性报到才能让访问令牌保持存活而一旦会话被吊销最多只需要等待一个访问令牌生命周期15 分钟吊销即可传播到所有应用。这一设计在仓库源码中得到了完全一致的落地server/lib/src/constants/mod.rs第 188 行定义了pub const OAUTH2_ACCESS_TOKEN_EXPIRY: u32 15 * 60;即访问令牌固定为 15 分钟同文件第 184 行定义了默认刷新令牌生命周期pub const OAUTH_REFRESH_TOKEN_EXPIRY: u32 3600 * 16;16 小时且刷新令牌可被单个应用配置覆盖见下文刷新令牌生命周期可配置一节。风险分析刷新令牌必须防住的两类滥用令牌增殖Token Proliferation刷新令牌会被提交到依赖方服务器即 Kanidm 的 OAuth2 授权端点换取访问令牌以及一个可选的新刷新令牌。正因为如此攻击者理论上可以多次提交同一个刷新令牌从而在系统之外增殖出大量额外的刷新令牌与访问令牌。设计文档强调阻止这种行为对于限制令牌使用范围、实现有效监控与吊销至关重要。这对应 OAuth 安全主题草案draft-ietf-oauth-security-topics第 2.2.2 节的要求。旧刷新令牌的至少一次at most once语义已交换过的旧刷新令牌不能再被使用。如果不强制这一点即使关联的访问令牌早已过期数小时、刷新令牌也已被使用过攻击者仍可用旧刷新令牌重新获得会话访问权。设计文档明确将其与draft-ietf-oauth-security-topics的refresh_token_protection一节对齐。客户端绑定刷新令牌只能由与其关联的客户端应用使用。Kanidm 通过既有的客户端授权检查client authorisation checks严格强制这一点对应 RFC 6749 第 10.4 节的要求。这一约束在源码中同样有明确实现server/lib/src/idm/oauth2.rs的check_oauth2_token_refresh第 1630 行起在解包刷新令牌后会核对令牌所属的 OAuth2 资源服务器o2rs与客户端授权信息再决定是否放行测试用例test_idm_oauth2_refresh_token_invalid_client_authz第 7281 行专门验证了使用错误的 client id / 授权信息进行刷新会被拒绝。核心设计以 NIB 时间戳实现 at most once单服务器场景下的 NIB 机制设计文档给出了核心设计图其要点是为每个会话关联一个 not issued beforeNIB时间戳。刷新令牌要有效其IATissued at签发时间必须大于或等于会话当前的 NIB。┌─────────────────────────────────────────┐ │Kanidm │ │ │ │ ┌─────────┐ ┌─────────┐ │ │ │ │ │ │ │ │ │ Session │ 3. Update │ Session │ │ │ │ NIB 1 │─────NIB───────▶│ NIB 2 │ │ │ └─────────┘ └─────────┘ │ │ │ │ │ └───┼───────────────────────────┼─────────┘ ┌────┘ ▲ ┌────┘ │ │ │ 1. Issued │ 4. Issued │ │ │ ▼ │ ▼ ┌───────┐ │ ┌───────┐ │Access │ │ │Access │ │ │ │ │ │ │Refresh│──2. Refresh──┘ │Refresh│ │ IAT 1 │ │ IAT 2 │ └───────┘ └───────┘流程解读首次签发授权码交换成功后Kanidm 签发第一组令牌Access RefreshIAT 1并在会话记录上记录issued_at即 NIB 1首次刷新客户端用 IAT 1 的刷新令牌换取新令牌推进 NIBKanidm 验证通过后把会话的issued_at推进到当前时间NIB 2同时签发第二组令牌IAT 2重用检测如果 IAT 1 的刷新令牌在第二次签发后被再次使用由于 NIB 已推进到 2而IAT 1 NIB 2条件IAT NIB不成立——这必然意味着该刷新令牌此前已被用于交换即检测到重用。NIB 在源码中的落地这套设计在server/lib/src/idm/oauth2.rs中有完整实现。刷新时check_oauth2_token_refresh在解密并校验令牌后会执行以下关键步骤第 1693-1732 行// Check the not issued before of the session relative to this refresh iat let oauth2_session entry .get_ava_as_oauth2session_map(Attribute::OAuth2Session) .and_then(|map| map.get(session_id)) .ok_or_else(|| { /* No OAuth2 session found */ Oauth2Error::InvalidGrant })?; // If the refresh token was issued previous to the time listed in our oauth2_session // this indicates session desync / replay. We must nuke the session at this point. if iat oauth2_session.issued_at.unix_timestamp() { security_info!(?session_id, Attempt to reuse a refresh token detected, destroying session); // Revoke it: remove the OAuth2 session from the account entry let modlist ModifyList::new_list(vec![Modify::Removed( Attribute::OAuth2Session, PartialValue::Refer(session_id), )]); self.qs_write.internal_modify(/* ... */)?; return Err(Oauth2Error::InvalidGrant); }注意这里比较的正是iat oauth2_session.issued_atissued_at即设计文档中的 NIB 值。一旦判定为重放Kanidm 会直接删除账户上的 OAuth2 会话记录并返回InvalidGrant——这正是检测到重用即销毁会话的实现。而每次刷新成功后generate_access_token_response第 2009 行起在签发新令牌的同时会把会话的issued_at更新为当前时间第 2197-2228 行即推进 NIB// Write the session to the db even with the refresh path, we need to do // this to update the not issued before time. let session Value::Oauth2Session( session_id, Oauth2Session { parent: parent_session_id, state: SessionState::ExpiresAt(odt_refresh_expiry), issued_at: odt_ct, rs_uuid: o2rs.uuid, }, ); // NOTE: Oauth2_session has special handling that allows update in place // without the remove step needing to be carried out. let modlist ModifyList::new_list(vec![Modify::Present(Attribute::OAuth2Session, session)]); self.qs_write.internal_modify(/* ... */)?;这里Oauth2Session结构体的issued_at字段承担了 NIB 的角色会话状态被设为SessionState::ExpiresAt(odt_refresh_expiry)使 OAuth2 会话的生命周期与刷新令牌生命周期而非访问令牌生命周期绑定。复制环境下的稳定性最终一致性与 NIB 合并Kanidm 支持多副本部署。设计文档特别论证了 NIB 机制在复制环境下即便漏掉一次会话更新也是稳定且正确的2. ┌───────────────────────Replicate────────────────┐ │ │ ┌───────┼─────────────────────────────────┐ ┌──────┼──────────────────────────────────┐ │Kanidm │ │ │Kanidm│ │ │ ┌─────────┐ ┌─────────┐ │ │ ┌─────────┐ ┌─────────┐ │ │ │ Session │ 4. Update │ Session │ │ │ │ Session │ 7. Update │ Session │ │ │ │ NIB 1 │─────NIB───────▶│ NIB 2 │ │ │ │ NIB 1 │ ─────NIB───────▶│ NIB 3 │ │ │ └─────────┘ └─────────┘ │ │ └─────────┘ └─────────┘ │ │ │ │ │ │ ▲ │ │ └───┼───────────────────────────┼─────────┘ └──────┼────────────────────────┼─────────┘ ┌────┘ ▲ ┌────┘ │ ┌────┘ │ │ │ │ │ 1. Issued │ 5. Issued │ 8. Issued │ │ │ │ │ ▼ │ ▼ │ ▼ ┌───────┐ │ ┌───────┐ │ ┌───────┐ │Access │ │ │Access │ │ │Access │ │ │ │ │ │ │ │ │ │Refresh│──3. Refresh──┘ │Refresh│ │ │Refresh│ │ IAT 1 │ │ IAT 2 │─────6. Refresh──────────┘ │ IAT 3 │ └───────┘ └───────┘ └───────┘示例场景第一台 Kanidm 服务器上的会话 NIB 1 被复制到第二台服务器但 NIB 2 的更新尚未复制过去此时若把 IAT 2 的刷新令牌提交给第二台服务器由于IAT 2 NIB 1它仍然有效可以正常刷新这次刷新会促使会话继续推进到 NIB 3当复制恢复时NIB 3 的会话将优先于此前的 NIB 2 会话NIB 更大的更新胜出。结论复制滞后只会在非常短的时间窗口内允许旧的访问令牌在第二台副本上使用。由于该基础设施位于负载均衡器之后、且不受攻击者操控利用这一窗口攻击的难度极高、收益极低属于可接受的风险。这正是 NIB 单调递增特性带来的容错优势——即使部分副本漏掉了中间状态最终一致状态依然安全收敛。攻击检测重用即全局吊销会话设计文档引用draft-ietf-oauth-security-topics第 4.14.2 节当检测到刷新令牌重用reuse时应取消该会话的所有令牌迫使用户重新发起授权码流程。Kanidm 的实现在这一点上比仅拒绝更进一步——前面已经看到check_oauth2_token_refresh在检测到iat issued_at时会删除整个 OAuth2 会话记录并返回InvalidGrant。这意味着不仅本次刷新被拒绝与该会话绑定的所有令牌当前访问令牌与任何衍生刷新令牌也随之失效用户必须走完整的授权码流程重新建立会话。对应的测试test_idm_oauth2_refresh_token_reuse_invalidates_session第 7359 行完整验证了这一行为// Now use it again. - this will cause an error and the session to be terminated. let ct Duration::from_secs(TEST_CURRENT_TIME 2); // ... 使用第一次签发时得到的 refresh_token 再次刷新 ... let access_token_response_3 idms_prox_write .check_oauth2_token_exchange(client_authz, token_req, ct) .unwrap_err(); assert_eq!(access_token_response_3, Oauth2Error::InvalidGrant); // The session should be invalid at this point. let valid entry .get_ava_as_oauth2session_map(Attribute::OAuth2Session) .and_then(|sessions| sessions.first_key_value()) .map(|(_, session)| !matches!(session.state, SessionState::RevokedAt(_))) .unwrap(); assert!(!valid);此外test_idm_oauth2_refresh_token_divergence第 7437 行专门覆盖了会话分叉场景先不提交事务地使用 refresh 1 生成 refresh 2再使用 refresh 2 刷新验证最终会话状态与 NIB 推进的一致性——这正是复制/异步写路径下可能出现的真实情况的回归测试。失效刷新令牌与not after策略令牌自身的过期字段OAuth security topics第 4.14.2 节同时指出不活跃inactive的令牌应在一定时间后被作废。在刷新令牌这一层Kanidm 通过在加密刷新令牌内部携带一个exp字段来实现Oauth2TokenType::Refresh { scopes, parent_session_id, session_id, exp, uuid, iat, nbf, nonce, auth_time }见server/lib/src/idm/oauth2.rs第 2158-2168 行的构造以及proto/src/oauth2.rs中的类型定义。刷新时第一步就是校验exp ct则返回InvalidGrant第 1674-1677 行。刷新令牌是一个 JWE 加密的紧凑串在域版本domain level≥ 1.12 时使用jwe_a256gcm_encryptAES-256-GCM否则回退到jwe_a128gcm_encryptAES-128-GCM——见第 2177-2195 行。访问令牌则是 RFC 9068 规范的atjwtJWS 签名ES256 或 RS256其中携带scope、nonce、session_id、parent_session_id等扩展声明见OAuth2RFC9068Token与OAuth2RFC9068TokenExtensionsproto/src/oauth2.rs第 218-257 行。服务端的 not after 参数与会话清理设计文档提出服务端需要一个随令牌活动而更新的not after 参数以追踪最后一次活动时间同时需要不活跃会话清理并建议将其扩展进已存在的会话一致性插件session consistency plugin。仓库中这一插件确实存在并承担了该职责server/lib/src/plugins/session.rs定义了SessionConsistency插件注册名为plugin_session_consistency并通过server/lib/src/plugins/mod.rs的pre_modify/pre_batch_modify钩子挂入写入事务。其modify_inner按顺序执行三类清理第 43-184 行凭据被移除则吊销会话若账户上的主凭据密码、passkey、attested passkey、OAuth2 账户凭据 UUID已不存在则移除对应的用户认证会话过期 UAT 清理SessionState::ExpiresAt(exp)且exp curtime的用户认证会话被移除过期/孤儿 OAuth2 会话清理已过期的 OAuth2 会话被移除若父会话不存在且已超过宽限期grace window则移除孤儿 OAuth2 会话。其中宽限期由proto/src/constants.rs第 55 行的pub const AUTH_TOKEN_GRACE_WINDOW: Duration Duration::from_secs(5 * 60);5 分钟定义用于容忍异步/复制造成的父会话可见性延迟。插件内对应测试test_session_consistency_oauth2_expiry_cleanup、test_session_consistency_oauth2_removed_by_parent、test_session_consistency_oauth2_grace_window_past分别覆盖了这三类场景。由于刷新令牌的每一次使用本身就是活动信号会同步推进会话的issued_at与过期时间设计文档明确指出不需要额外的信令机制来上报活跃度。会话有效性复核链路刷新令牌换发访问令牌时Kanidm 会复核底层会话的真实状态。check_oauth2_token_refresh的核心调用链如下解析并解密 JWE确认是Oauth2TokenType::Refresh而非访问令牌用访问令牌冒充刷新令牌会被拒绝第 1659-1662 行校验exp是否已过期调用check_oauth2_account_uuid_valid(uuid, session_id, parent_session_id, iat, ct)第 1682 行——该调用同时校验**父会话用户认证会话**与OAuth2 会话的有效性账户是否有效、父会话是否被吊销、OAuth2 会话是否仍在宽限期内等server/lib/src/idm/server.rs中相关实现按 NIB 规则检测重放校验请求的 scope 是原 scope 的子集否则返回InvalidScope第 1734-1749 行调用generate_access_token_response签发新令牌并推进 NIB。值得注意的实现细节源码第 2021-2030 行的注释generate_access_token_response中令牌的过期时间刻意不引用会话的过期时间——因为无论是授权码交换路径还是刷新路径进入该函数前会话的有效性已被验证过这里签发的exp纯粹描述令牌自身的寿命。同时注释明确指出刷新令牌生命周期可配置而访问令牌生命周期不可配置因为 OAuth2 没有内建的吊销机制必须通过频繁换发访问令牌来反复验证会话状态。刷新令牌生命周期可配置每个 OAuth2 资源服务器应用可以设置自己的刷新令牌生命周期Oauth2RS结构体中的refresh_token_expiry: u32字段第 552 行从账户/应用条目的Attribute::OAuth2RefreshTokenExpiry读取第 974-975、1041 行签发刷新令牌时refresh_expiry iat o2rs.refresh_token_expiry第 2033 行。未配置时使用默认值OAUTH_REFRESH_TOKEN_EXPIRY 3600 * 1616 小时。测试test_idm_oauth2_refresh_token_basic第 7027 行中的一段第 7133-7167 行专门验证了自定义刷新时间后新换发的令牌采用更新后的生命周期。会话过期后刷新令牌失效刷新令牌虽然有自己的exp但设计上会话时长仍然兜底约束测试test_idm_oauth2_refresh_token_oauth2_session_expired第 7230 行验证了当底层 OAuth2 会话父会话过期或缺失时即使刷新令牌本身未到期刷新也会被拒绝——因为check_oauth2_account_uuid_valid找不到有效会话。这与设计文档中刷新令牌必须以底层会话有效性为前提的定位一致。异步写路径的取舍原设计的遗留问题设计文档在 Questions 一节提出了一个值得关注的工程权衡当前授权码授权authorisation code grant与会话的记录是**异步async**进行的。文档作者认为刷新路径也应保持一致——即刷新令牌已签发、但会话记录写入存在轻微延迟。鉴于未来的复制机制本就依赖异步/最终一致性这不会造成额外影响最多带来客户端在更新过程中的轻微延迟。但也不希望异步/延迟动作队列delayed action queues过满或过载。文档认为队列监控/积压问题可作为一个独立问题另行处理。从源码看这一异步性体现在会话写入通过self.qs_write.internal_modify(...)在事务中完成而刷新令牌的验证依赖事务提交后可见的issued_attest_idm_oauth2_refresh_token_divergence中特意不提交事务drop(idms_prox_write)来模拟未提交/异步分叉场景正是对这类一致性问题的事先防御。总结设计要点速览设计目标Kanidm 实现源码位置访问令牌短生命周期固定 15 分钟OAUTH2_ACCESS_TOKEN_EXPIRY 15 * 60server/lib/src/constants/mod.rs刷新令牌默认生命周期16 小时可被单应用OAuth2RefreshTokenExpiry覆盖server/lib/src/constants/mod.rs、server/lib/src/idm/oauth2.rsat most once会话issued_atNIB单调推进iat issued_at判为重放server/lib/src/idm/oauth2.rs重用检测处置删除 OAuth2 会话并返回InvalidGrant强制重新授权server/lib/src/idm/oauth2.rs客户端绑定刷新时必须携带合法客户端授权否则拒绝server/lib/src/idm/oauth2.rs失效令牌清理plugin_session_consistency清理过期/孤儿 OAuth2 会话宽限期 5 分钟server/lib/src/plugins/session.rs、proto/src/constants.rs令牌格式访问令牌为 RFC 9068atjwtJWS刷新令牌为 JWE 加密proto/src/oauth2.rs、server/lib/src/idm/oauth2.rs会话复核刷新前校验父会话与 OAuth2 会话双重有效性server/lib/src/idm/oauth2.rs总体而言Kanidm 的刷新令牌设计用一个单调推进的 NIB 时间戳同时解决了三个问题让访问令牌保持短生命周期以快速传播吊销、让刷新令牌具备 at most once 语义以阻止增殖、让复制环境下的最终一致性自然收敛。这套机制既符合 OAuth2 安全最佳实践RFC 6749 与 OAuth security topics也在源码与测试中得到了严谨的实现与验证可作为自建 OAuth2/OIDC 服务时刷新令牌安全设计的参考范本。赞分享后端认证鉴权安全【免费下载链接】kanidmKanidm: A simple, secure, and fast identity management platform项目地址https://gitcode.com/gh_mirrors/ka/kanidm点击查看免费下载相关推荐BiliBiliToolPro 使用教程B 站自动签到与多账号管理的快速上手指南BiliBiliToolPro 使用教程B 站自动签到与多账号管理的快速上手指南 BiliBiliToolPro 是一个基于 .NET 开发的 B 站bil后端任务调度工作流自动化Kanidm 跨域信任Trust机制设计解析基于分数复制的身份联邦方案Kanidm 跨域信任Trust机制设计解析基于分数复制的身份联邦方案 Kanidm 的跨域信任Trust是一项将独立 Kanidm 实例中的用户与群后端认证鉴权安全Kanidm 架构设计解析Schema 引用类型Reference Types与引用完整性机制Kanidm 架构设计解析Schema 引用类型Reference Types与引用完整性机制 Kanidm 的身份数据模型中对象之间需要相互引用——最后端认证鉴权安全创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑