Bitwarden 服务器 Send 访问令牌请求校验机制解析:基于 Duende IdentityServer 的自定义 Grant 实现
【免费下载链接】serverBitwarden infrastructure/backend (API, database, Docker, etc).项目地址: https://gitcode.com/GitHub_Trending/ser/server
Send 是 Bitwarden 中用于临时分享文本或文件的安全特性。当用户(或未登录的访客)访问一个受保护的 Send 时,客户端必须向 Identity 服务发起一次send_access扩展授权请求,只有通过 src/Identity/IdentityServer/RequestValidators/SendAccess/readme.md 所描述的请求校验流程,才能换取携带send_id等自定义 Claim 的访问令牌。本文以该文档为骨架,结合 Bitwarden 服务器仓库(GitHub_Trending/ser/server)中的源码与测试,完整讲解 Send 访问请求的校验模型、认证方式、请求参数与错误响应,并深入剖析每个校验器的底层实现。
读完本文,你将掌握:send_access扩展授权的完整校验链路、四种 Send 认证方式(SendInaccessible/NotAuthenticated/ResourcePassword/EmailOtp)的判定逻辑与区别、令牌请求的必需参数约定,以及统一错误响应结构中send_access_error_type自定义字段的含义。
Send Access 特性与文档背景
Send 访问请求校验(Send Access Request Validation)解决的问题是:工具(Tools/客户端)在访问 Send 数据时,必须满足请求校验器中定义的要求。也就是说,一个 Send 是否可以被访问、以何种认证方式被访问,完全由 Identity 服务侧的请求校验器决定。
文档强调了一个极其重要的约束:SendAccessConstants中的字符串常量与 SDK 中的 Auth 模块(bitwarden-authcrate)协同使用,任何对这些字符串值的修改都必须是有意的,并且必须在 SDK 中做对应的同步修改。同时,仓库中存在快照测试(snapshot testing),一旦字符串发生变化测试就会失败,从而帮助检测对字符串常量的非预期改动。这一机制的源码依据位于 SendAccessConstants.cs,其 XML 注释明确写道:
Most of these need to be synced with the
bitwarden-authcrate in the SDK. There is snapshot testing to help ensure this.
对应的快照测试位于 SendConstantsSnapshotTests.cs,它逐一断言了错误类型、Token 请求参数、OTP Token 常量以及邮件主题字符串的值。
架构总览:校验器与依赖注入
从源码结构看,Send Access 校验功能分布在两层:
- Grant 校验入口:SendAccessGrantValidator.cs 实现
IExtensionGrantValidator,声明GrantType为send_access(见 CustomGrantTypes.cs)。 - 认证方法校验器:SendPasswordRequestValidator.cs 与 SendEmailOtpRequestValidator.cs,二者都实现泛型接口 ISendAuthenticationMethodValidator<T>,其中
T分别是ResourcePassword与EmailOtp。
这些组件在 ServiceCollectionExtensions.cs 中完成注册:第 32–33 行将两个认证方法校验器注册为AddTransient,第 66 行通过.AddExtensionGrantValidator<SendAccessGrantValidator>()将 Grant 校验器挂载到 IdentityServer 管线。
对应地,测试目录 test/Identity.Test/IdentityServer/SendAccess/ 中包含SendAccessGrantValidatorTests.cs、SendPasswordRequestValidatorTests.cs、SendEmailOtpRequestValidatorTests.cs、SendConstantsSnapshotTests.cs与SendAccessTestUtilities.cs五个测试文件,为整个功能提供了完整的行为契约。
自定义 Claims:访问令牌中的 Send 专属声明
文档指出,Send 访问令牌中包含针对send_access授权类型专属的自定义 Claims。这些 Claim 的实际定义与签发位置如下:
| Claim | 取值 | 签发条件 |
|---|---|---|
send_id | 被访问 Send 的GUID(字符串形式) | 总是包含在签发的访问令牌中 |
send_email | 授权邮箱地址 | 仅当 Send 要求EmailOtp认证类型时设置 |
type | 固定为Send | 总是包含 |
Claim 名称的源码定义位于 Claims.cs:
public static class SendAccessClaims { public const string SendId = "send_id"; public const string Email = "send_email"; }从实现看,三种成功路径(NotAuthenticated、密码匹配、Email OTP 验证通过)都会签发send_id与type两个 Claim,其中type取值为IdentityClientType.Send(即字符串Send),而send_email只在 Email OTP 路径中追加(见 SendEmailOtpRequestValidator.cs)。
成功结果统一构造为GrantValidationResult:以sendId.ToString()作为subject,以CustomGrantTypes.SendAccess(send_access)作为authenticationMethod。
此外,ApiResources.cs 中将send_accessscope 关联的 Claim 类型定义为sub(JwtClaimTypes.Subject)与send_id,而 ProfileService.cs 对 Send 客户端做了特殊处理:当context.Client.ClientId == BitwardenClient.Send时,直接保留SendAccessGrantValidator添加的既有 Claims,不再叠加任何用户身份 Claims。
认证方式(Authentication Methods)
SendAuthenticationQuery(SendAuthenticationQuery.cs)负责根据send_id从仓库读取 Send 记录,并返回一个"认证方法"——这是一个判别联合(discriminated union),其类型定义在 SendAuthenticationTypes.cs:
public abstract record SendAuthenticationMethod; public record NotAuthenticated : SendAuthenticationMethod; public record ResourcePassword(string Hash) : SendAuthenticationMethod; public record EmailOtp(string[] emails) : SendAuthenticationMethod; public record SendInaccessible : SendAuthenticationMethod;查询逻辑(SendAuthenticationQuery.cs)按以下顺序判定:
SendAuthenticationMethod method = send switch { null => SEND_INACCESSIBLE, var s when s.Disabled => SEND_INACCESSIBLE, var s when s.AccessCount >= s.MaxAccessCount.GetValueOrDefault(int.MaxValue) => SEND_INACCESSIBLE, var s when s.ExpirationDate.GetValueOrDefault(DateTime.MaxValue) < DateTime.UtcNow => SEND_INACCESSIBLE, var s when s.DeletionDate <= DateTime.UtcNow => SEND_INACCESSIBLE, var s when s.AuthType == AuthType.Email && s.Emails is not null => EmailOtp(s.Emails), var s when s.AuthType == AuthType.Password && s.Password is not null => new ResourcePassword(s.Password), _ => NOT_AUTHENTICATED };EmailOtp构造时会把以逗号分隔的邮箱列表拆分为数组(SendAuthenticationQuery.cs)。
SendInaccessible—— Send 不可访问
这是兜底场景:Send 存在但被禁用、已过期、已过删除日期、访问次数达到上限,或者send_id找不到对应的 Send 记录。上述所有情况统一返回invalid_grant,错误码为send_id_invalid(见 SendAccessGrantValidator.cs)。
从源码实现上看,SendInaccessible与"send_id 格式非法"最终返回相同的错误码(均为send_id_invalid、invalid_grant),这是有意为之——目的是避免向调用方泄露"该 Send 是否存在"这类枚举信息。
NotAuthenticated—— 无需认证
当 Send 未启用任何额外认证/授权保护时,直接向请求方签发访问令牌。成功结果中包含send_id与type=Send两个 Claim(见 SendAccessGrantValidator.cs)。
ResourcePassword—— 密码保护
Send 受密码保护,用户必须提交正确的密码哈希才能获得访问令牌。其校验逻辑位于 SendPasswordRequestValidator.cs:
- 从请求中读取
password_hash_b64; - 若该字段缺失,视为请求形状错误,返回
invalid_request+password_hash_b64_required; - 否则调用
ISendPasswordHasher.PasswordHashMatches(resourcePassword.Hash, clientHashedPassword)比对哈希; - 不匹配则返回
invalid_grant+password_hash_b64_invalid;匹配则签发令牌。
底层哈希比对由 SendPasswordHasher.cs 实现:内部委托给 ASP.NET Core 的IPasswordHasher<SendPasswordHasherMarker>,对空字符串同样会返回 false;且因为客户端提交的是高熵预哈希机密(high-entropy, pre-hashed secret),实现不关心是否触发重哈希(SuccessRehashNeeded也视为匹配),注释还指出 Send 最长存活 30 天。
EmailOtp—— 邮箱 + 一次性密码
Send 仅对特定邮箱的所有者开放。用户必须先提交正确的邮箱;确认邮箱属于授权列表后,再通过 OTP 证明邮箱所有权。OTP 会发送到该邮箱,用户需要连同邮箱一起提交 OTP 才能换取访问令牌。核心逻辑位于 SendEmailOtpRequestValidator.cs,流程如下:
- 读取
email,缺失则返回invalid_request+email_required; - 将邮箱
Trim()并ToLowerInvariant()归一化,随后对授权邮箱列表做大小写不敏感(StringComparer.OrdinalIgnoreCase)的包含判断——因为历史数据中可能混有大小写混合的邮箱(无数据迁移); - 读取
otp;若缺失,则调用IOtpTokenProvider<DefaultOtpTokenProviderOptions>生成 OTP 并通过IMailService.SendSendEmailOtpEmailAsync发送到该邮箱(邮件主题见下方常量),随后返回错误响应; - 若提供了
otp,则调用ValidateTokenAsync校验;校验失败同样返回错误响应;成功则签发包含send_id、send_email与type三个 Claim 的令牌。
邮件发送的底层实现在 HandlebarsMailService.cs:模板为Auth.TwoFactorEmail,邮件正文中硬编码提示验证码 5 分钟内有效。
需要特别说明的错误语义:该类错误响应在 OAuth 标准意义上并不完全符合invalid_requestvsinvalid_grant的区分——所有与邮箱/OTP 相关的错误一律返回invalid_request,即使某些场景用invalid_grant更合适。这是有意设计,用于更好地防止枚举攻击(防止攻击者探测邮箱是否在授权列表中)。该意图在 SendEmailOtpRequestValidator.cs 的注释中有明确说明。
相关常量(SendAccessConstants.cs):
public static class OtpToken { public const string TokenProviderName = "send_access"; public const string Purpose = "email_otp"; public const string TokenUniqueIdentifier = "{0}_{1}"; // {0}=send_id, {1}=email } public static class OtpEmail { public const string Subject = "Your Bitwarden Send verification code is {0}"; }OTP 的缓存查找键格式为{TokenProviderName}_{Purpose}_{TokenUniqueIdentifier},即send_access_email_otp_{send_id}_{email},由IOtpTokenProvider<TOptions>机制驱动(接口定义见 IOtpTokenProvider.cs,实现见 OtpTokenProvider.cs,该机制的使用说明见 OtpTokenProvider/readme.md)。
Send Access 请求校验(Send Access Request Validation)
入口:send_id 解析与 Grant 分发
SendAccessGrantValidator.ValidateAsync首先调用GetRequestSendId解析请求中的send_id(SendAccessGrantValidator.cs):
send_id缺失 →send_id_required(对应invalid_request,描述为 "send_id is required.");send_id存在但无法通过 Base64URL 解码为有效 GUID(或解码后为Guid.Empty)→send_id_invalid(对应invalid_grant,描述为 "send_id is invalid.")。
解析成功后,GetAuthenticationMethod(sendId)查询出认证方法并分派:
switch (method) { case SendInaccessible: // invalid_grant + send_id_invalid case NotAuthenticated: // 直接签发 case ResourcePassword rp: // 委托密码校验器 case EmailOtp eo: // 委托 Email OTP 校验器 default: throw new InvalidOperationException($"Unknown auth method: {method.GetType()}"); }Required Parameters(必需参数)
文档规定的参数约定如下,所有字段均位于令牌请求(Token Request)的原始参数中:
| 场景 | 参数 | 说明 |
|---|---|---|
| 所有请求 | send_id | 被访问 Send 的Base64 URL 编码的 GUID |
| 密码保护的 Send | password_hash_b64 | 客户端哈希后的 Base64 编码密码 |
| Email OTP 保护的 Send | email | 与该 Send 关联的邮箱地址 |
| Email OTP 保护的 Send | otp | 一次性密码(可选——若缺失,则生成并发送 OTP) |
其中send_id的 Base64 URL 编码/解码由CoreHelpers.Base64UrlDecode完成(见 SendAccessGrantValidator.cs),测试工具 SendAccessTestUtilities.cs 展示了客户端构造请求的完整形态:除了上述参数外,还会带上grant_type=send_access、client_id(BitwardenClient.Send)、scope(ApiScopes.ApiSendAccess)与device_type。
完整请求示例
结合源码与测试工具,一个完整的 Email OTP 校验请求形如:
POST /connect/token Content-Type: application/x-www-form-urlencoded grant_type=send_access &client_id=bitwarden-send &scope=api.send_access &device_type=1 &send_id=<Base64Url(GUID)> &email=alice@example.com &otp=123456 // 可选:若省略,服务端生成并发送 OTP 邮件密码保护的请求则将email/otp替换为:
&password_hash_b64=<客户端哈希并 Base64 编码的密码>客户端配置:SendClientBuilder
静态客户端 SendClientBuilder.cs 定义了send_access授权可用的 Client 配置:
AllowedGrantTypes = [CustomGrantTypes.SendAccess]:仅允许该扩展授权;AccessTokenLifetime = 60 * globalSettings.SendAccessTokenLifetimeInMinutes:令牌生命周期默认 5 分钟(见 GlobalSettings.cs);AllowOfflineAccess = false:禁止签发刷新令牌;RequireClientSecret = false:Send 是公共匿名客户端,无需(也无法安全使用)客户端密钥;AllowedCorsOrigins = [Vault]:允许 Web Vault 使用该客户端;AllowedScopes = [ApiScopes.ApiSendAccess]:允许请求api.send_accessscope。
Error Responses(错误响应)
所有错误响应都会额外包含一个自定义字段send_access_error_type,其响应结构如下:
{ "error": "invalid_request|invalid_grant", "error_description": "Human readable description", "send_access_error_type": "specific_error_code" }该字段的常量名定义于 SendAccessConstants.cs:public const string SendAccessError = "send_access_error_type";,其用法(置于GrantValidationResult.CustomResponse中)在该常量的注释中有明确说明。
完整的错误码清单(均被 SendConstantsSnapshotTests.cs 快照锁定):
send_access_error_type取值 | 对应error | 触发场景 |
|---|---|---|
send_id_required | invalid_request | 请求中缺少send_id(请求形状错误) |
send_id_invalid | invalid_grant | send_id不是合法 GUID、解码为空 GUID,或 Send 不存在/不可访问 |
password_hash_b64_required | invalid_request | 密码保护的 Send 缺少password_hash_b64字段 |
password_hash_b64_invalid | invalid_grant | 密码哈希不匹配(请求形状正确但数据错误) |
email_required | invalid_request | Email OTP 保护的 Send 缺少email字段 |
email_and_otp_required | invalid_request | 邮箱不在授权列表中,或邮箱正确但 OTP 缺失/无效(默认错误响应) |
错误码常量分组定义于 SendAccessConstants.cs:
SendIdGuidValidatorResults:valid_send_guid/send_id_required/send_id_invalid(其中valid_send_guid仅用于内部流转,不会出现在响应中);PasswordValidatorResults:password_hash_b64_invalid/password_hash_b64_required;EmailOtpValidatorResults:email_required/email_and_otp_required。
测试契约:行为如何被验证
SendAccessGrantValidatorTests(SendAccessGrantValidatorTests.cs)覆盖了入口校验的每条分支:
- 缺少
send_id→invalid_request且描述为 "send_id is required."; - 非法格式 / 空 GUID 的
send_id→invalid_grant且描述为 "send_id is invalid."; SendInaccessible→invalid_grant+ 自定义响应send_access_error_type=send_id_invalid;NotAuthenticated→ 成功,subject 为 sendId、认证方式为send_access,Claims 含send_id与type=Send;ResourcePassword/EmailOtp→ 分别恰好调用一次对应类型的ISendAuthenticationMethodValidator<T>.ValidateRequestAsync;- 未知认证方法 → 抛出
InvalidOperationException(消息以 "Unknown auth method:" 开头)。
SendEmailOtpRequestValidatorTests(SendEmailOtpRequestValidatorTests.cs)则验证:缺少邮箱时返回invalid_request且不会触发 OTP 生成与邮件发送;邮箱不在授权列表时返回 "email and otp are required." 且同样不触发邮件;正确邮箱且缺失 OTP 时生成并发送邮件;错误 OTP 会被ValidateTokenAsync拒绝。这些测试通过 NSubstitute 对IOtpTokenProvider<DefaultOtpTokenProviderOptions>与IMailService进行打桩,完整刻画了 Email OTP 两阶段流程的边界行为。
小结
从 readme 文档到源码实现,Send Access 校验在 Bitwarden 服务器中的完整链路为:客户端以send_access扩展授权类型发起令牌请求 →SendAccessGrantValidator解析并校验send_id(Base64URL 编码的 GUID)→SendAuthenticationQuery依据 Send 状态返回四种认证方法之一 → 分别走"直接签发 / 密码哈希比对 / 邮箱 OTP 两阶段验证"路径 → 成功后签发携带send_id、send_email(仅 EmailOtp)、type=Send自定义 Claim 的短期访问令牌(默认 5 分钟、无刷新令牌),失败则统一返回带send_access_error_type自定义字段的错误响应。这套机制既保证了公开匿名访问 Send 的安全性(密码哈希、邮箱所有权证明),又通过有意的错误语义设计降低了枚举攻击风险,其字符串常量与 SDK 通过快照测试保持严格同步。
【免费下载链接】serverBitwarden infrastructure/backend (API, database, Docker, etc).项目地址: https://gitcode.com/GitHub_Trending/ser/server
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考