身份声明
涵盖
soulsoft_identity_claims库的三层身份数据模型——Claim(单条声明)、ClaimsIdentity(一组声明 + 认证类型)、ClaimsPrincipal(多身份容器),是认证产出和授权消费的共同数据基础。
适用场景
- 需要理解认证中间件产出的
context.user是什么数据结构 - 需要在签发 JWT 时构建 claims 列表
- 需要在自定义授权策略中检查声明值
- 需要手动构建
ClaimsPrincipal进行单元测试
不适合仅使用认证中间件默认行为的场景——如果只用到 context.user.isInRole() 或 requireRole(),不需要深入了解 ClaimsIdentity 的内部细节。身份声明只提供数据模型,不参与登录流程、声明校验和授权决策。
前置条件
身份声明只依赖一个包:
| 包 | 用途 |
|---|---|
soulsoft_identity_claims | 提供 Claim、ClaimsIdentity、ClaimsPrincipal、ClaimTypes、ClaimValueTypes |
无需额外配置或 DI 注册——所有类型直接 import soulsoft_identity_claims.* 即可使用。
快速开始
下面是一个最小示例:构建一个带认证的身份、添加声明、跨身份查询。
核心两步:ClaimsIdentity(Some("Bearer")) 创建已认证身份并用 addClaim 逐条写入声明;ClaimsPrincipal 容纳多个身份,findFirstValue 跨身份聚合查询。
import soulsoft_identity_claims.*
let id = ClaimsIdentity(Some("Bearer"))
id.addClaim(ClaimTypes.Sub, "user-001")
id.addClaim(ClaimTypes.Name, "spire")
id.addClaim(ClaimTypes.Role, "admin")
let principal = ClaimsPrincipal([id])
let userId = principal.findFirstValue(ClaimTypes.Sub) ?? String.empty
let userName = principal.findFirstValue(ClaimTypes.Name) ?? String.empty
let isAdmin = principal.isInRole("admin")这段代码跑了完整链路:Claim → ClaimsIdentity(Bearer 认证)→ ClaimsPrincipal(多身份容器)→ 跨身份查询。findFirstValue 跨所有 identity 聚合查询,isInRole 只在 ClaimTypes.Role(即 "role")中查找。
核心概念
定位与职责
soulsoft_identity_claims 只负责一件事:定义"用户是谁"的数据结构。
三层模型的关系是:
Claim -> ClaimsIdentity -> ClaimsPrincipal也就是说:
- 单条信息放在
Claim - 同一个身份下的多条信息放在
ClaimsIdentity - 一个请求或用户可以同时拥有多个身份,最终放在
ClaimsPrincipal
认证中间件产出 ClaimsPrincipal 写入 context.user,授权中间件再从中读取 claims 做权限判定。这个库是两者之间唯一的数据约定。
Claim
Claim 只有三个字段:type、value、valueType。
let c1 = Claim("role", "admin")
let c2 = Claim("age", "18", ClaimValueTypes.Integer)如果不传 valueType,默认值就是:
ClaimValueTypes.String它的字符串形式也很直接:
println(c1.toString())
// role = admin [http://www.w3.org/2001/XMLSchema#string]ClaimsIdentity
ClaimsIdentity 表示一个身份。它内部持有一个 ArrayList<Claim> 和一个可选的 authenticationType。
最容易忽略的实现细节是:
isAuthenticated == authenticationType.isSome()也就是说,是否"已认证"只看 authenticationType 有没有值,不看声明内容。
例如:
let anonymous = ClaimsIdentity()
println(anonymous.isAuthenticated) // false
let bearer = ClaimsIdentity(Some("Bearer"))
println(bearer.isAuthenticated) // true添加声明有三种重载:
let identity = ClaimsIdentity(Some("AuthA"))
identity.addClaim(ClaimTypes.Name, "spire")
identity.addClaim(ClaimTypes.Role, "admin")
identity.addClaims([
Claim(ClaimTypes.Email, "spire@example.com"),
Claim("tenant", "default")
])查询接口分成三类:findAll(全部匹配)、findFirst(首个匹配)、findFirstValue(首个匹配的值),以及两个存在性判断:hasClaim(predicate) 和 hasClaim(type, value)。
匹配语义是精确匹配,不做大小写归一化:
identity.findAll("role")
identity.hasClaim("role", "admin")所以 "role" 和 "Role" 不是一回事,"admin" 和 "Admin" 也不是一回事。
ClaimsPrincipal
ClaimsPrincipal 是多个 ClaimsIdentity 的容器。
let bearer = ClaimsIdentity(Some("AuthA"))
bearer.addClaim(ClaimTypes.Sub, "user-001")
let second = ClaimsIdentity(Some("AuthB"))
second.addClaim(ClaimTypes.Role, "admin")
let principal = ClaimsPrincipal([bearer, second])它的查询会跨所有 identity 聚合:
let sub = principal.findFirstValue(ClaimTypes.Sub)
let role = principal.findFirstValue(ClaimTypes.Role)
let allRoles = principal.findAll(ClaimTypes.Role)主身份: principal.identity 默认返回第一个 identity,由全局静态选择器决定:
ClaimsPrincipal.setPrimaryIdentitySelector { identities =>
identities |> first
}你可以改掉它:
ClaimsPrincipal.setPrimaryIdentitySelector { identities =>
let items = identities |> collectArray
if (items.isEmpty()) {
None
} else {
Some(items[items.size - 1])
}
}setPrimaryIdentitySelector 是 ClaimsPrincipal 的全局静态状态——修改后影响进程内所有后续 principal.identity 的行为。
角色判断: isInRole(role) 遍历所有 identity,检查是否存在 ClaimTypes.Role(即 "role")且 value 等于传入参数。它不会自动识别 ClaimTypes.Roles("roles")、scope 或自定义声明类型。
ClaimTypes 与 ClaimValueTypes
ClaimTypes 是一组字符串常量,按用途分几类:
| 类别 | 常量 | 用途 |
|---|---|---|
| JWT 标准 | Sub、Exp、Iss、Aud、Iat、Jti、Nbf | JWT 令牌标准声明字段 |
| JWT 头部 | Typ、Alg、Kid、Enc、Cty | JWT header 参数 |
| OpenID Connect | Name、Email、EmailVerified、PhoneNumber、PreferredUsername、Picture、Sid、Birthdate、Gender、Address 等 | OIDC 标准声明 |
| 角色/权限 | Role、Roles、Scope、Permissions | 授权判定 |
| 组织/业务 | TenantId、ClientId、UserId、EmployeeId | 多租户和业务标识 |
ClaimValueTypes 同样是字符串常量,用于标注 Claim.valueType。常见值包括 String、Boolean、Integer、Date、DateTime、Email、DnsName、Rsa 等,来自 XML Schema、SOAP、XML Signature、XQuery、XACML 等命名空间。
这个库不会根据 valueType 自动做类型转换——它只是把元数据保存下来。
常见用法
多认证方案共存时的身份查询
当 Bearer + Cookie 等多认证方案共存时,ClaimsPrincipal 可能包含多个 identity。查询会自动跨所有 identity 聚合,无需手动遍历:
let bearerId = ClaimsIdentity(Some("Bearer"))
bearerId.addClaim(ClaimTypes.Sub, "user-001")
bearerId.addClaim(ClaimTypes.Name, "spire")
let cookieId = ClaimsIdentity(Some("Cookie"))
cookieId.addClaim(ClaimTypes.Role, "admin")
cookieId.addClaim(ClaimTypes.Email, "spire@example.com")
let principal = ClaimsPrincipal([bearerId, cookieId])
// 跨所有 identity 聚合查询
let name = principal.findFirstValue(ClaimTypes.Name) // Some("spire") — 来自 Bearer
let role = principal.findFirstValue(ClaimTypes.Role) // Some("admin") — 来自 Cookie
let isAdmin = principal.isInRole("admin") // true — 跨 identity 检查序列化与深克隆
三个核心类型都实现了 Serializable 接口,支持 serialize() / deserialize(),可用于跨进程传递或持久化到缓存。
同时也支持 clone() 深拷贝——修改克隆不会影响原对象:
let copiedIdentity = identity.clone()
let copiedPrincipal = principal.clone()clone() 的行为是"复制数据",不是共享内部集合:ClaimsIdentity.clone() 逐条复制 claim,ClaimsPrincipal.clone() 逐个调用 identity 的 clone()。这在需要临时追加声明但不污染原始身份的场合(如审计日志附加临时 claims)很有用。
生产建议
isAuthenticated只看authenticationType是否Some,不看声明内容。 即使ClaimsIdentity中有多条 claim,只要authenticationType为None,isAuthenticated就是false。框架用authenticationType区分"匿名身份"和"已认证身份",而非用声明数量判断。isInRole只检查ClaimTypes.Role(即"role")。 如果把角色放在"roles"、"permissions"或自定义声明类型中,isInRole不会自动识别。需要时改用principal.findFirstValue("your-role-type")手动检查,或使用自定义授权要求。findAll、hasClaim使用==精确匹配,不做大小写归一化。"role"和"Role"被视为不同的声明类型。如果声明来源多样(如不同 OIDC 提供方),建议在写入或查询时统一使用ClaimTypes常量来避免大小写不一致。setPrimaryIdentitySelector是全局静态状态。 修改后影响整个进程,不是线程隔离的。如果只在特定场景需要不同的主身份选取规则,直接在调用处按需获取特定 identity(通过principal.identities遍历),不要随意修改全局选择器。
常见问题
isAuthenticated 为 false,但 identity 里有 claims
isAuthenticated 只看 authenticationType 是否 Some。默认构造的 ClaimsIdentity() 的 authenticationType 是 None,所以即使调用了 addClaim,isAuthenticated 仍然是 false。要用 ClaimsIdentity(Some("Bearer")) 创建已认证身份。
isInRole("admin") 返回 false,但确实有 admin 声明
先检查声明类型是否是 ClaimTypes.Role(即 "role")而不是 "roles"、"Role"(大写)或自定义类型。其次检查值的大小写——"admin" 和 "Admin" 不匹配。建议始终使用 ClaimTypes.Role 常量和统一大小写的值来避免这类问题。
findAll("Role") 查不到声明
类型匹配是精确区分大小写的。声明写入时用的是 "role"(小写),查询时用 "Role"(首字母大写)不会命中。始终使用 ClaimTypes.Role 之类的常量来保证一致性。