JWT 集成
CoSec 使用 Auth0 java-jwt 库来创建和验证 JSON Web Token。该集成封装在 cosec-jwt 模块中,提供 JwtTokenConverter(签发令牌)和 JwtTokenVerifier(验证并提取主体)。Spring Boot 自动配置将所有组件组装在一起。
令牌生命周期
令牌有效期默认值
| 令牌类型 | 默认有效期 | 可通过以下方式配置 |
|---|---|---|
| 访问令牌 | 10 分钟 | cosec.jwt.token-validity.access |
| 刷新令牌 | 7 天 | cosec.jwt.token-validity.refresh |
这些默认值定义在 JwtProperties 中:
data class TokenValidity(
var access: Duration = Duration.ofMinutes(10),
var refresh: Duration = Duration.ofDays(7)
)支持的算法
自动配置支持三种 HMAC 算法,通过 cosec.jwt.algorithm 选择:
| 值 | 算法 | Javadoc |
|---|---|---|
HMAC256(默认) | HS256 | Algorithm.HMAC256(secret) |
HMAC384 | HS384 | Algorithm.HMAC384(secret) |
HMAC512 | HS512 | Algorithm.HMAC512(secret) |
JWT 声明结构
JwtTokenConverter 构建具有以下声明结构的 JWT 访问令牌:
{
"jti": "<generated-unique-id>",
"sub": "<principal.id>",
"iat": 1684000000,
"exp": 1684000600,
"policies": ["policy-id-1", "policy-id-2"],
"roles": ["admin", "user"],
"attributes": {"key": "value"},
"tenantId": "tenant-123"
}关键映射:
sub(主题):设置为principal.id-- 唯一用户标识符jti(JWT ID):由IdGenerator生成(默认:UUID)。用于令牌吊销(见下文《注销登录(Token 吊销)》小节)和刷新令牌绑定policies:PolicyCapable.POLICY_KEY声明 -- 分配给主体的策略 ID 列表(仅在非空时写入)roles:RoleCapable.ROLE_KEY声明 -- 角色 ID 列表(仅在非空时写入)attributes:CoSecPrincipal::attributes.name声明 -- 任意键值元数据(仅在非空时写入)tenantId:Tenant.TENANT_ID_KEY声明 -- 仅当主体实现了TenantCapable时存在
刷新令牌的结构更简单:
{
"jti": "<refresh-token-id>",
"sub": "<access-token-id>",
"iat": 1684000000,
"exp": 1685209600
}刷新令牌的 sub 声明被设置为访问令牌的 jti,在两个令牌之间建立绑定关系。
关键类
JwtTokenConverter
JwtTokenConverter 实现了 TokenConverter,将 CoSecPrincipal 转换为 CompositeToken:
class JwtTokenConverter(
private val idGenerator: IdGenerator,
private val algorithm: Algorithm,
private val accessTokenValidity: Duration = Duration.ofMinutes(10),
private val refreshTokenValidity: Duration = Duration.ofDays(7)
) : TokenConverterJwtTokenVerifier
JwtTokenVerifier 实现了 TokenVerifier,提供:
verify(AccessToken):验证签名,检查过期时间,提取TokenPrincipalrefresh(CompositeToken):验证刷新令牌,验证访问令牌的签名(其有效期可能已过),确保其sub与访问令牌的jti匹配,然后从(可能已过期的)访问令牌中提取主体
Jwts 工具类
Jwts 提供辅助函数:
decode(token):去除Bearer前缀并解码 JWT(不验证)toPrincipal(decodedJWT):提取所有声明并构造TokenPrincipal(当存在tenantId时构造TokenTenantPrincipal)removeBearerPrefix():去除"Bearer "前缀的字符串扩展函数(如果存在)
架构图
令牌创建流程
sequenceDiagram
autonumber
participant Auth as TokenCompositeAuthentication
participant CA as CompositeAuthentication
participant AP as AuthenticationProvider
participant Impl as Authentication Impl
participant Conv as JwtTokenConverter
participant JWT as JWT.create()
Auth->>CA: authenticate(credentials)
CA->>AP: getRequired(credentialsType)
AP-->>CA: Authentication instance
CA->>Impl: authenticate(credentials)
Impl-->>Auth: CoSecPrincipal
Auth->>Conv: toToken(principal)
Conv->>JWT: create access token (sub=policies=roles=tenantId)
JWT-->>Conv: signed access token string
Conv->>JWT: create refresh token (sub=accessTokenId)
JWT-->>Conv: signed refresh token string
Conv-->>Auth: CompositeToken(accessToken, refreshToken)令牌验证流程
flowchart TD
A["Incoming AccessToken"] --> B["removeBearerPrefix()"]
B --> C["jwtVerifier.verify(token)"]
C --> D{"Verification result"}
D -->|"TokenExpiredException"| E["throw TokenExpiredException"]
D -->|"Other Exception"| F["throw TokenVerificationException"]
D -->|"Valid DecodedJWT"| N{"revoked (jti in TokenStore)?"}
N -->|"yes"| R["throw TokenRevokedException (401)"]
N -->|"no"| G["Jwts.toPrincipal(decodedJWT)"]
G --> H["Extract sub, policies, roles, attributes"]
H --> I{"tenantId claim present?"}
I -->|"yes"| J["return TokenTenantPrincipal"]
I -->|"no"| K["return TokenPrincipal"]
style A fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
style B fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
style C fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
style D fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
style E fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
style F fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
style G fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
style H fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
style I fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
style J fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
style K fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
style N fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
style R fill:#2d333b,stroke:#6d5dfc,color:#e6edf3刷新令牌流程
sequenceDiagram
autonumber
participant Client
participant Verifier as JwtTokenVerifier
participant JWT as JWT.require()
participant Jwts as Jwts
Client->>Verifier: refresh(CompositeToken)
Verifier->>JWT: verify(refreshToken)
JWT-->>Verifier: DecodedJWT (refresh)
Verifier->>Jwts: decode(accessToken) -- signature verified next, expiry not re-checked
Jwts-->>Verifier: DecodedJWT (access, possibly expired)
Verifier->>Verifier: verifyAccessTokenSignature(accessJWT)
Verifier->>Verifier: require(refresh.sub == access.jti)
Verifier->>Jwts: toPrincipal(accessJWT)
Jwts-->>Verifier: TokenPrincipal
Verifier-->>Client: TokenPrincipal注销登录(Token 吊销)
JWT 默认是无状态的——仅在客户端删除令牌并不会让服务端令牌失效。CoSec 通过以令牌的 jti 声明为键的可选吊销机制弥补了这一缺口。
功能说明
TokenRevoker.revoke(accessToken)验证令牌并将其jti记录到吊销存储中。请在自定义的登出端点中调用它。- 被吊销的访问令牌会立即失效并返回
401——每次验证都会经过RevocableTokenVerifier,它在接受令牌前会先检查吊销存储。 - 刷新令牌绑定到访问令牌的
jti(即其sub声明),因此被吊销令牌的刷新请求同样会被拒绝。 - 吊销条目的存活时间为刷新令牌的有效期(
cosec.jwt.token-validity.refresh),因此它绝不会早于所绑定的刷新令牌过期。
令牌已失效时登出保持幂等——捕获并忽略验证失败即可:
@PostMapping("/logout")
fun logout(@RequestHeader(HttpHeaders.AUTHORIZATION) authorization: String): ResponseEntity<Void> {
try {
tokenRevoker.revoke(SimpleAccessToken(authorization)) // Bearer 前缀由验证器内部剥离
} catch (ignored: TokenVerificationException) {
// 令牌已无效或已过期——已无可吊销
}
return ResponseEntity.noContent().build()
}注意:请在访问令牌过期前执行注销。已过期的访问令牌会验证失败,此时其仍有效的绑定刷新令牌将无法再通过 TokenRevoker 吊销。
如何启用
cosec:
jwt:
token-revocation:
enabled: true这将接入基于 Redis 的 CoCacheTokenStore,它需要 cosec-cocache 依赖(启动器的 cacheSupport Gradle 特性)以及 Redis 连接:
dependencies {
implementation("me.ahoo.cosec:cosec-spring-boot-starter") {
capabilities {
requireCapability("me.ahoo.cosec:cosec-spring-boot-starter-cache-support")
}
}
}TokenStore SPI
吊销存储通过 TokenStore SPI 实现可插拔:
- 默认
NoOp—— 无状态空实现。升级 CoSec 后行为零变化,保持无状态默认行为。 CoCacheTokenStore—— 开箱即用的 Redis 实现(CoCache 两级缓存:本地 + Redis),在启用该属性且类路径上存在 cosec-cocache 时自动装配。- 自定义 —— 提供自己的
TokenStoreBean,可将吊销信息存储到其他任何地方。
运维注意事项
- 注入模式:通过网关请求头注入安全上下文的下游服务不验证 JWT 签名,因此它们自身无法检查吊销状态。吊销的执行发生在验证边缘(网关)。
- 传播:CoCache 通过 Redis pub/sub 驱逐本地条目,因此在集群健康时吊销几乎实时地在所有实例上生效。最坏情况下,传播时间受本地缓存 TTL 限制(默认 30 秒,可通过
cosec.authorization.cache.token.*配置)。 - Redis 故障时失效(fail-open):在 CoCache 默认的
strictFailure=false下,Redis 不可达会使isRevoked回退为false(被吊销的令牌可能重新通过认证),吊销写入也会被丢弃。偏好 fail-closed 行为的部署可以设置cocache.redis.strict-failure=true。
Spring Boot 自动配置
CoSecJwtAutoConfiguration 在以下条件满足时激活:
cosec.enabled=true(默认)cosec.jwt.enabled=true(默认)JwtTokenConverter在类路径上
它注册五个 Bean:
| Bean | 类型 | 用途 |
|---|---|---|
cosecTokenAlgorithm | Algorithm | 来自配置的 HMAC 算法 |
cosecTokenConverter | TokenConverter | 创建 JWT 令牌 |
cosecTokenStore | TokenStore | 吊销存储(默认 NoOp,除非接入缓存实现,见《注销登录(Token 吊销)》小节) |
cosecJwtTokenVerifier | TokenVerifier | 验证 JWT 令牌并拒绝已吊销的令牌 |
cosecTokenRevoker | TokenRevoker | 注销时吊销令牌 |
当认证也被启用时,它还会注册 TokenCompositeAuthentication,将基于凭据的认证与令牌签发链接在一起。
配置示例
cosec:
jwt:
enabled: true
algorithm: HMAC256
secret: your-secret-key-must-be-long-enough
token-validity:
access: 10m
refresh: 7d
token-revocation:
enabled: false # 可选启用注销;设为 true 时接入基于 Redis 的 CoCacheTokenStore吊销的本地缓存通过 cosec.authorization.cache.token.* 调优(默认:写入后 30 秒过期、10 万条目——这也是跨实例登出生效的最坏传播窗口)。
参考文献
- JwtTokenConverter.kt:42 - 包含声明的 JWT 令牌创建
- JwtTokenVerifier.kt:37 - JWT 验证和主体提取
- Jwts.kt:44 - JWT 工具函数(decode、toPrincipal、removeBearerPrefix)
- CoSecJwtAutoConfiguration.kt:52 - Spring Boot 自动配置
- JwtProperties.kt:28 - 配置属性
- TokenStore.kt:33 - 令牌吊销存储 SPI
- CoCacheTokenStore.kt:29 - 基于 Redis 的吊销存储