Files
conti-backend/docs/04-security-auth.md
2026-08-17 15:31:27 +08:00

528 lines
29 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 04. 安全与认证方案
## 决策
Spring Security + JWT,由 `identity-store` 模块统一签发,由 `platform-security` 统一校验,对应架构图里 `Auth and Token Center`;门店/角色上下文在认证之后注入 `@RequestScope` bean,供各 domain 读取,对应架构图 `Store Context` 的职责。
JWT 的签发与校验**用 Spring Security 内置的 `NimbusJwtEncoder`/`NimbusJwtDecoder`,不引入 jjwt**,理由见文末附录。
## 结构约定
```
platform-security/
JwtProperties # security.jwt.* 配置绑定 + 启动期校验
JwtEncoderConfig # NimbusJwtEncoder / NimbusJwtDecoderHS256
AccessTokenIssuer # 签发 access tokenclaims 结构见下)
StoreContextFilter # 认证之后执行:把 Jwt claims 写进 StoreContextHolder
StoreContextHolder # @RequestScope bean,持有当前 用户+门店+角色
ApiResultAuthenticationEntryPoint # 401 → ApiResult JSON
ApiResultAccessDeniedHandler # 403 → ApiResult JSON
SecurityConfig # 统一 SecurityFilterChain
domains/identity-store/
负责登录、refresh token 签发/轮换/撤销、门店列表与切换、菜单权限
```
## 端点契约(与客户端已实现的行为对齐)
以下五个端点的路径、请求体、响应体**必须**与客户端文档 [../05-networking.md](../../conti-retail-app/docs/05-networking.md)、[../11-store-context-and-session.md](../../conti-retail-app/docs/11-store-context-and-session.md) 一致,客户端的自动刷新、切店、登出、冷启动恢复流程已经按这个契约实现:
| 端点 | 认证 | 说明 |
| --- | --- | --- |
| `POST /api/v1/auth/login` | 免认证 | `{ username, password }``{ accessToken, refreshToken, user }` |
| `POST /api/v1/auth/refresh` | 免认证 | `{ refreshToken }``{ accessToken, refreshToken }`。**必须免认证**:调用它的时候 access token 恰好已经过期 |
| `POST /api/v1/auth/logout` | 需认证 | `{ refreshToken }``ApiResult<Unit>`。撤销该 refresh token;客户端 3 秒超时、失败不阻断本地登出 |
| `GET /api/v1/auth/me` | 需认证 | → `{ user, currentStoreId }`。客户端冷启动时用本地 token 调它恢复会话(客户端 `11-store-context-and-session.md` 的启动流程)。**必须是轻量查询**,不做任何写操作——它在每次冷启动都会被调用 |
| `POST /api/v1/stores/{id}/switch` | 需认证 | → `StoreContext`(含新的 `accessToken` + 菜单),见下面"切换门店" |
客户端冷启动的完整顺序是 `GET /auth/me``GET /api/v1/stores/accessible`(后者见 [06-api-design.md](./06-api-design.md)):先确认身份还有效,再拉可选门店列表。这两个接口在 401 时都会触发客户端的串行刷新流程,所以它们的 401 必须是标准的 `ApiResult` JSON(见下面 `ApiResultAuthenticationEntryPoint`),不能是 500。
其余端点的路径与响应包装规则见 [06-api-design.md](./06-api-design.md)。
## Token 配置与校验
```kotlin
// platform-security/.../JwtProperties.kt
@ConfigurationProperties(prefix = "security.jwt")
@Validated
data class JwtProperties(
/** 当前签发用的密钥 ID,写进 JWT header 的 kid,用于密钥轮换,见"密钥轮换"一节。 */
@field:NotBlank val activeKeyId: String,
/** kid -> base64 密钥,允许同时持有多个用于校验,只有 activeKeyId 用于签发。 */
val keys: Map<String, String> = emptyMap(),
@field:Min(5) val accessTokenTtlMinutes: Long = 30,
@field:Min(1) val refreshTokenTtlDays: Long = 30,
) {
@PostConstruct
fun validate() {
val key = keys[activeKeyId] ?: error("security.jwt.keys 里没有 activeKeyId=$activeKeyId 对应的密钥")
require(Base64.getDecoder().decode(key).size >= 32) {
"HS256 密钥长度必须 ≥ 32 字节,当前配置不满足" // 启动即失败,不允许带着弱密钥跑起来
}
}
}
```
```kotlin
// platform-security/.../JwtEncoderConfig.kt
@Configuration
@EnableConfigurationProperties(JwtProperties::class)
class JwtEncoderConfig(private val props: JwtProperties) {
private fun secretKey(keyId: String): SecretKey =
SecretKeySpec(Base64.getDecoder().decode(props.keys.getValue(keyId)), "HmacSHA256")
@Bean
fun jwtEncoder(): JwtEncoder {
val jwkSet = JWKSet(
props.keys.keys.map { kid ->
OctetSequenceKey.Builder(secretKey(kid)).keyID(kid).algorithm(JWSAlgorithm.HS256).build()
},
)
return NimbusJwtEncoder(ImmutableJWKSet(jwkSet))
}
@Bean
fun jwtDecoder(): JwtDecoder =
// 校验时按 header 里的 kid 选密钥,所以轮换期间新旧 token 都能验过
NimbusJwtDecoder.withSecretKey(secretKey(props.activeKeyId))
.macAlgorithm(MacAlgorithm.HS256)
.build()
}
```
```kotlin
// platform-security/.../AccessTokenIssuer.kt
@Component
class AccessTokenIssuer(
private val jwtEncoder: JwtEncoder,
private val props: JwtProperties,
private val clock: Clock,
) {
fun issue(userId: Long, storeId: Long, roles: List<String>): String {
val now = clock.instant()
val claims = JwtClaimsSet.builder()
.subject(userId.toString())
.claim("storeId", storeId)
.claim("roles", roles)
.id(UUID.randomUUID().toString()) // jti
.issuedAt(now)
.expiresAt(now.plus(props.accessTokenTtlMinutes, ChronoUnit.MINUTES))
.build()
val header = JwsHeader.with(MacAlgorithm.HS256).keyId(props.activeKeyId).build()
return jwtEncoder.encode(JwtEncoderParameters.from(header, claims)).tokenValue
}
}
```
### Access Token 的 claims 结构
```json
{
"sub": "1024",
"storeId": 7,
"roles": ["STORE_MANAGER"],
"jti": "9f2b6e2e-2f3a-4b7a-9b0a-2c8e6f5a1d3c",
"iat": 1735600000,
"exp": 1735601800
}
```
`jti` 只用于**审计关联**(跟 `traceId` 一起打进日志,方便事后查"这个用户当时用的是哪个 access token"),不用于撤销判断——access token 本身依然是无状态的,服务端不会为了撤销去反查 `jti`,那样就失去了 JWT 免查库校验的意义。真正需要撤销能力的是下面的 refresh token。
## `StoreContextHolder` 与上下文注入
```kotlin
// platform-security/.../StoreContextHolder.kt
@Component
@RequestScope // 默认 proxyMode = TARGET_CLASS,可以直接注入到单例 bean 里
class StoreContextHolder {
var userId: Long? = null
var storeId: Long? = null
var roles: List<String> = emptyList()
fun currentUserId(): Long = userId ?: throw IllegalStateException("未认证请求不应到达这里")
fun currentStoreId(): Long = storeId ?: throw IllegalStateException("未绑定门店的请求不应到达这里")
}
```
Kotlin 里这个类必须是 `open` 的(CGLIB 代理要求),`kotlin-spring` 插件会因为 `@Component` 自动放开,不需要手写 `open`——但如果哪天把 `@Component` 换成了别的注册方式,这里会以一个不太好懂的报错炸掉,值得记一笔。
```kotlin
// platform-security/.../StoreContextFilter.kt
// 注意:这个 filter 只负责"把已经验过的 claims 搬进 StoreContextHolder"
// 不做任何解析或验签——验签由 Spring Security 的 BearerTokenAuthenticationFilter 做完了。
// 这一点很关键:token 无效时的 401 响应由标准的 AuthenticationEntryPoint 产出,
// 不会出现"自己写的 filter 里抛异常 → @RestControllerAdvice 接不住 → 返回 500"的情况。
class StoreContextFilter(
private val storeContextHolder: ObjectFactory<StoreContextHolder>,
) : OncePerRequestFilter() {
override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, chain: FilterChain) {
val jwt = (SecurityContextHolder.getContext().authentication as? JwtAuthenticationToken)?.token
if (jwt != null) {
val ctx = storeContextHolder.`object`
ctx.userId = jwt.subject.toLong()
ctx.storeId = jwt.getClaim<Number>("storeId")?.toLong()
ctx.roles = jwt.getClaimAsStringList("roles") ?: emptyList()
// 客户端会带 X-Store-Id(见 06-api-design.md 的统一请求头约定)。
// 服务端一律以 token 里的 storeId 为准;不一致时记一条 warn 用于排查
// (切店瞬间客户端可能还在用旧 header,直接拒绝会误伤正常请求)。
request.getHeader("X-Store-Id")?.toLongOrNull()?.let { headerStoreId ->
if (headerStoreId != ctx.storeId) {
logger.warn("X-Store-Id($headerStoreId) 与 token storeId(${ctx.storeId}) 不一致,以 token 为准")
}
}
}
chain.doFilter(request, response)
}
}
```
其他 domain 里的 `application` 层直接注入 `StoreContextHolder` 拿当前上下文,不自己解析 token:
```kotlin
@Service
class WorkbenchAppService(
private val storeContextHolder: StoreContextHolder,
private val tileRepository: WorkbenchTileRepository,
) {
fun listTiles(): List<TileResponse> {
val userId = storeContextHolder.currentUserId()
val storeId = storeContextHolder.currentStoreId()
// ...
}
}
```
## `SecurityConfig`
```kotlin
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // 开启 @PreAuthorize
class SecurityConfig(
private val storeContextHolder: ObjectFactory<StoreContextHolder>,
private val entryPoint: ApiResultAuthenticationEntryPoint,
private val accessDeniedHandler: ApiResultAccessDeniedHandler,
private val environment: Environment,
) {
@Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain {
val isProd = environment.acceptsProfiles(Profiles.of("prod"))
http
.csrf { it.disable() } // 无状态 API + Bearer token,不存在 CSRF 的前提
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
.authorizeHttpRequests { auth ->
auth.requestMatchers(
"/actuator/health/**", // 存活/就绪探针,见 08-observability.md
"/api/v1/auth/login",
"/api/v1/auth/refresh", // 调它的时候 access token 已过期,必须免认证
).permitAll()
if (!isProd) {
// 接口文档只在非生产环境开放;生产环境走内网文档站或 CI 产物
auth.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
}
auth.anyRequest().authenticated()
}
.oauth2ResourceServer { oauth2 ->
oauth2.jwt { jwt -> jwt.jwtAuthenticationConverter(rolesConverter()) }
oauth2.authenticationEntryPoint(entryPoint) // token 缺失/过期/签名错 → 统一 401 JSON
}
.exceptionHandling {
it.authenticationEntryPoint(entryPoint)
it.accessDeniedHandler(accessDeniedHandler) // 认证过但权限不足 → 统一 403 JSON
}
.addFilterAfter(
StoreContextFilter(storeContextHolder),
BearerTokenAuthenticationFilter::class.java, // 必须在认证之后
)
return http.build()
}
private fun rolesConverter(): Converter<Jwt, AbstractAuthenticationToken> {
val authorities = JwtGrantedAuthoritiesConverter().apply {
setAuthoritiesClaimName("roles")
setAuthorityPrefix("ROLE_")
}
return JwtAuthenticationConverter().apply { setJwtGrantedAuthoritiesConverter(authorities) }
}
}
```
```kotlin
// platform-security/.../ApiResultAuthenticationEntryPoint.kt
@Component
class ApiResultAuthenticationEntryPoint(
private val objectMapper: ObjectMapper,
) : AuthenticationEntryPoint {
override fun commence(req: HttpServletRequest, resp: HttpServletResponse, ex: AuthenticationException) {
resp.status = HttpStatus.UNAUTHORIZED.value() // 必须是 401:客户端只在 401 时触发刷新
resp.contentType = MediaType.APPLICATION_JSON_VALUE
resp.characterEncoding = Charsets.UTF_8.name()
objectMapper.writeValue(
resp.outputStream,
ApiResult.error(ErrorCode.UNAUTHORIZED, "登录状态已失效,请重新登录"),
)
}
}
```
**为什么这两个 handler 必须存在**Spring Security 的过滤器跑在 `DispatcherServlet` **之前**,这里抛出的异常 `@RestControllerAdvice`[06-api-design.md](./06-api-design.md) 的 `GlobalExceptionHandler`)根本接不到。如果不显式处理,token 过期会返回一个空 body 的 401 或者 Spring 默认的错误页——而客户端 [../05-networking.md](../../conti-retail-app/docs/05-networking.md) 是**严格按 HTTP 401 + `ApiResult` 结构**来判断"要不要触发 token 刷新"的。这里返回错了,整条自动刷新链路就是断的,表现为用户莫名其妙被登出。
## Refresh Token:不用 Redis,直接用现有 MySQL
Access token TTL 短(30 分钟),到期后客户端用 refresh token 换新的 access token,避免频繁重新登录。Refresh token **不是 JWT**,是一个不透明的随机字符串(`SecureRandom` 生成 32 字节,base64url 编码):因为 refresh token 需要能被服务端主动吊销(登出、检测到被盗用),JWT 本身无状态、签发后没法在不额外存储的情况下撤销——既然撤销必须要有一张表来查,索性让 refresh token 本身就是这张表的查询 key,不需要再多一层"签发 JWT 又要验签"的复杂度。
```kotlin
// domains/identity-store/infrastructure/persistence/RefreshTokenEntity.kt
@Entity
@Table(name = "refresh_token", schema = "identity_store")
class RefreshTokenEntity(
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0,
@Column(nullable = false)
val userId: Long,
@Column(name = "token_hash", nullable = false, length = 64)
val tokenHash: String, // SHA-256(原始 token) 的 hex,不存明文
@Column(nullable = false)
val expiresAt: Instant,
@Column
var revokedAt: Instant? = null,
@Column
var replacedByTokenId: Long? = null, // 轮换链,用于检测"已撤销的旧 token 被重放"
) : BaseEntity()
```
```sql
-- domains/identity-store/src/main/resources/db/migration/identity_store/V2__refresh_token.sql
create table refresh_token (
id bigint not null auto_increment,
user_id bigint not null,
token_hash char(64) not null,
expires_at datetime(6) not null,
revoked_at datetime(6),
replaced_by_token_id bigint,
-- BaseEntity 的四个审计列,缺任何一个第一次插入就会失败,见 03-persistence.md
created_at datetime(6) not null,
updated_at datetime(6) not null,
created_by varchar(64),
updated_by varchar(64),
primary key (id),
unique key uk_refresh_token_hash (token_hash),
key idx_refresh_token_user_id (user_id),
key idx_refresh_token_expires_at (expires_at),
constraint fk_refresh_token_user foreign key (user_id) references user (id)
) engine = InnoDB default charset = utf8mb4 collate = utf8mb4_0900_ai_ci;
```
```kotlin
// domains/identity-store/application/RefreshTokenService.kt
@Service
class RefreshTokenService(
private val repository: RefreshTokenJpaRepository,
private val props: JwtProperties,
private val clock: Clock,
) {
fun issue(userId: Long): String {
val rawToken = generateOpaqueToken()
repository.save(
RefreshTokenEntity(
userId = userId,
tokenHash = sha256Hex(rawToken),
expiresAt = clock.instant().plus(props.refreshTokenTtlDays, ChronoUnit.DAYS),
),
)
return rawToken // 返回明文给客户端,库里只有 hash
}
@Transactional
fun rotate(rawToken: String): RotatedTokens {
val existing = repository.findByTokenHash(sha256Hex(rawToken))
?: throw InvalidRefreshTokenException() // 从来不存在的 token
// 重放检测:这个 token 存在,但已经被轮换掉了 —— 说明它很可能已泄漏,
// 因为正常客户端拿到新 token 后不会再用旧的。撤销该用户全部 refresh token,强制重新登录。
if (existing.revokedAt != null) {
repository.revokeAllByUserId(existing.userId, clock.instant())
log.warn("检测到 refresh token 重放,已撤销用户 {} 的全部 refresh token", existing.userId)
throw InvalidRefreshTokenException()
}
if (existing.expiresAt.isBefore(clock.instant())) {
throw InvalidRefreshTokenException()
}
val newRawToken = generateOpaqueToken()
val rotated = repository.save(
RefreshTokenEntity(
userId = existing.userId,
tokenHash = sha256Hex(newRawToken), // 存 hash
expiresAt = clock.instant().plus(props.refreshTokenTtlDays, ChronoUnit.DAYS),
),
)
existing.revokedAt = clock.instant()
existing.replacedByTokenId = rotated.id
return RotatedTokens(userId = existing.userId, refreshToken = newRawToken) // 返回明文
}
@Transactional
fun revoke(rawToken: String) {
repository.findByTokenHash(sha256Hex(rawToken))
?.takeIf { it.revokedAt == null }
?.let { it.revokedAt = clock.instant() }
// 登出场景:token 不存在或已撤销都视为成功,不给调用方任何"这个 token 存不存在"的信号
}
fun revokeAllByUser(userId: Long) = repository.revokeAllByUserId(userId, clock.instant()) // 强制下线
}
data class RotatedTokens(val userId: Long, val refreshToken: String)
```
- **轮换(rotation**:每次用 refresh token 换 access token,同时签发一个新的 refresh token,旧的立刻标记 `revokedAt`。旧 token 之后又被用一次,就落进上面的重放分支——**注意"从来没见过的 token"和"已撤销的 token"必须分成两个分支处理**,合在一起写会让重放检测形同虚设(客户端 [../11-store-context-and-session.md](../../conti-retail-app/docs/11-store-context-and-session.md) 的串行刷新设计正是建立在"重放即全量撤销"这条规则上的)。
- **过期清理**:加一个定时任务定期删掉 `expires_at < now()` 且已撤销的行。**注意多副本下这个任务会在每个 Pod 各跑一次**,处理方式见 [12-concurrency-and-scheduling.md](./12-concurrency-and-scheduling.md)。
### 为什么现阶段不引入 Redis
Redis 常被用来存 refresh token/session,图的是两点:**自动过期(TTL)**和**高并发读写性能**。这两点目前都不是硬约束:
- 过期:一张表 + 一个定时清理任务就够,只是没有 Redis"写入即设 TTL、到期自动消失"那么省事。
- 性能:refresh token 校验只发生在"access token 过期后换新"这一低频动作上,不是每次请求都查,MySQL 上一个唯一索引足够支撑。
- 额外成本:引入 Redis 意味着在 K8s 上多起一个有状态服务(持久化、备份、连接池配置、AKS 里再开一条访问路径),这跟 [09-build-deploy.md](./09-build-deploy.md)、[07-config-governance.md](./07-config-governance.md) 里"能用现有基础设施就不额外引入运维负担"的取舍一致。
结论:refresh token 存现有 MySQL 就够,不引入 Redis;如果以后出现 Redis 能解决、MySQL 解决不了的场景(比如跨实例分布式限流、高频缓存),到时候再评估,届时 refresh token 也可以顺带迁过去,不存在现在这张表以后没法迁移的问题。
## 切换门店
```kotlin
@RestController
@RequestMapping("/api/v1/stores")
class StoreController(private val storeAppService: StoreAppService) {
@Operation(summary = "切换当前门店,返回新的门店上下文与重新签发的 access token")
@PostMapping("/{storeId}/switch")
fun switchStore(@PathVariable storeId: Long): ApiResult<StoreContextResponse> =
ApiResult.ok(storeAppService.switchStore(storeId))
}
```
```kotlin
@Service
class StoreAppService(
private val storeRepository: StoreRepository,
private val accessTokenIssuer: AccessTokenIssuer,
private val storeContextHolder: StoreContextHolder,
private val eventPublisher: ApplicationEventPublisher,
) {
@Transactional
fun switchStore(targetStoreId: Long): StoreContextResponse {
val userId = storeContextHolder.currentUserId()
// 1. 必须校验目标门店对当前用户可访问 —— 否则任何人改一下 URL 里的 id 就能进别人的门店
val store = storeRepository.findAccessibleStore(userId, targetStoreId)
?: throw BusinessException(ErrorCode.STORE_NOT_ACCESSIBLE, "无权访问该门店", HttpStatus.FORBIDDEN)
val roles = storeRepository.findRoles(userId, targetStoreId)
// 2. 重新签发 access token —— 新 token 里的 storeId 是目标门店。
// 这一步不能省:token 里的 storeId 决定了后续所有请求的数据范围,
// 只改客户端本地状态不换 token,等于门店没真的切过去。
val accessToken = accessTokenIssuer.issue(userId, targetStoreId, roles)
// 3. 切店会使旧门店下的 WebView 票据失效(架构图 Flow 2)。
// 走领域事件,不由 identity-store 直接改 webview-ticket 的表,见 11-cross-domain-collaboration.md
eventPublisher.publishEvent(StoreSwitchedEvent(userId, from = storeContextHolder.storeId, to = targetStoreId))
return StoreContextResponse(accessToken, store.toInfo(), roles, menuOf(roles))
}
}
```
refresh token **不随切店轮换**:它绑定的是用户身份而不是门店,切店只换 access token。
## 越权与数据隔离规则
这是这套系统里最容易出、也最贵的一类漏洞(一个门店看到另一个门店的数据),单独立规则:
1. **所有涉及门店数据的查询,必须以 `storeContextHolder.currentStoreId()` 作为过滤条件**,而不是用请求参数里传来的 `storeId`。请求参数可以被任意篡改,token 里的不行。
2. **确实需要按路径/参数指定 `storeId` 的接口(如切店),必须先校验该用户对目标门店的可访问性**,校验不过返回 `STORE_NOT_ACCESSIBLE`。上面的 `findAccessibleStore` 就是这个模式:把权限校验合进查询条件,而不是"先查出来再判断"——后者很容易漏判,前者查不到就是没权限。
3. **按 ID 查单条记录时,`where id = ? and store_id = ?`**,不要只按主键查完再比对——只按主键查会让"不存在"和"没权限"走不同的代码路径,容易漏掉后者,而且响应差异本身就是一种信息泄漏。
4. 这三条在 code review 里是必看项,新增查询方法时优先检查。
### 接口级权限 vs 菜单权限
- **菜单权限**(切店时返回的 `menu`)只决定 APP 上显示什么,是 UI 层的便利,**不构成安全边界**。客户端拿到的菜单里没有某一项,不代表它调不到对应接口。
- **接口级权限**用 `@PreAuthorize``application` 层(或 Controller)上显式声明,这才是真正的边界:
```kotlin
@PreAuthorize("hasRole('STORE_MANAGER')")
fun approveProcurement(id: Long) { ... }
```
- 两者的数据来源应该是同一份角色/权限配置,但**必须两边都做**。只做菜单不做接口校验,等于没做。
## 密码存储与登录保护
- **密码用 BCrypt 存储**`BCryptPasswordEncoder`,强度 12),存 hash,绝不存明文或可逆加密。用 `DelegatingPasswordEncoder`Spring Security 默认)让 hash 带 `{bcrypt}` 前缀,将来换算法时新旧可以共存。
- **登录失败限流**`user` 表上加 `failed_attempts` / `locked_until` 两列,同一账号连续失败 5 次锁定 15 分钟。放在数据库而不是内存里,是因为多副本下内存计数各算各的,等于没限。
- **IP 维度的限流放在 API Gateway 做**,后端不重复实现——后端看到的往往是网关的 IP,自己做也做不准。
- **登录失败的响应不区分"用户不存在"和"密码错误"**,统一返回同一个错误码和文案,避免被用来枚举账号。
- 密码、token、`Authorization` 头等敏感字段**不允许进日志**,脱敏规则见 [08-observability.md](./08-observability.md)。
## 密钥轮换
JWT 签名密钥放在 K8s Secret 里(见 [07-config-governance.md](./07-config-governance.md)),轮换按 `kid` 分三步走,全程不需要让用户重新登录:
1. **加新密钥**:在 `security.jwt.keys` 里加一个新 `kid`,但 `activeKeyId` 保持旧的。这时新旧密钥都能验签,签发仍用旧的。滚动更新完成后所有实例都认识新密钥。
2. **切签发**:把 `activeKeyId` 改成新 `kid`。此时新签发的 token 用新密钥,还在有效期内的旧 token 仍能验过。
3. **删旧密钥**:等超过一个 access token TTL30 分钟)后,从 `keys` 里移除旧 `kid`。
三步之间必须各自完成一次滚动更新,不能合并成一次改动——合并了会出现"某些实例已经用新密钥签发,另一些实例还不认识新密钥"的窗口,表现为随机 401。
## 关键规则
- `API Gateway` 已做 TLS/路由,后端服务只需要校验 JWT 签名和 claims,不重复做接入层的事。
- 门店/角色上下文在统一 filter 里从已认证的 `Jwt` 写入 `StoreContextHolder`,各 domain 通过它读取,不各自解析 token。
- 切换门店必须重新签发 access token,并联动使 `webview-ticket` 的当前票据失效(架构图 Flow 2)——走领域事件,不跨模块直接改表。
- `webview-ticket` 用的票据是独立的短时票据机制(见 [05-integration-layer.md](./05-integration-layer.md)),不复用登录 JWT,避免票据泄漏后长期有效。
## 附录一:为什么用 Spring Security 内置的 Nimbus,而不是 jjwt
jjwt 是 Java 生态里很常见的 JWT 库,但在我们这个版本基线(Spring Boot 4 / [01-project-structure.md](./01-project-structure.md))下它有个具体问题:jjwt 的 JSON 序列化模块 `jjwt-jackson` 依赖 **Jackson 2**`com.fasterxml.jackson`),而 Boot 4 默认用的是 **Jackson 3**`tools.jackson`)。两者包名不同、不二进制兼容,用 jjwt 就意味着要在同一个应用里同时带两套 Jackson——能跑,但多一份依赖、多一处升级时要照顾的地方,还容易让人误以为 `ObjectMapper` 只有一个。
Spring Security 本身就自带基于 Nimbus JOSE + JWT 的 `JwtEncoder`/`JwtDecoder``spring-boot-starter-oauth2-resource-server`),版本由 Boot BOM 管,不引入第二套 JSON 库。更重要的是,用它就能顺带用上 `oauth2ResourceServer` 这套标准链路:**token 的解析和验签由框架的 filter 完成,失败时走标准的 `AuthenticationEntryPoint` 返回 401**——而自己写 filter 解析 token 时,最常见的 bug 恰恰是异常没接住导致返回 500(详见上面 handler 那一节)。选它是同时解决依赖和正确性两个问题。
代价:Nimbus 的 API 比 jjwt 的链式 builder 略啰嗦一点(多一个 `JwtClaimsSet`/`JwsHeader` 的概念),以及如果团队里有人熟悉 jjwt 需要适应一下。如果后续确实要换回 jjwt,注意版本要选和 Jackson 3 兼容的,或者自己实现 jjwt 的 `Serializer`/`Deserializer` 接口接到 Jackson 3 上。
## 附录二:为什么用 `RequestScope` bean 而不是 `ThreadLocal`
传统做法常见用 `ThreadLocal` 存当前用户上下文,但 `ThreadLocal` 有两个常见坑:
1. **忘记清理**:请求处理完不手动 `remove()`,线程池复用线程时,下一个请求可能读到上一个请求残留的上下文——在 Tomcat 这种线程池容器里是真实发生过的安全事故类型。
2. **线程切换场景失效**:一旦把工作交给另一个线程池(比如 `workbench` 首页的并行聚合,见 [11-cross-domain-collaboration.md](./11-cross-domain-collaboration.md)),`ThreadLocal` 绑定的线程和实际干活的线程就不是同一个了。
Spring 的 `@RequestScope` bean 由容器管理生命周期,请求结束自动销毁,不需要手动清理,语义上也更清楚地表达"这个对象的生命周期等于一次 HTTP 请求"。
需要注意的是 `@RequestScope` 也**不会自动跨线程传播**:并行聚合时子线程里拿不到它。那种场景要么把需要的值(`userId`/`storeId`)作为方法参数显式传进去(推荐,最不容易出错),要么给线程池配 `TaskDecorator` 显式搬运上下文——两种做法的取舍见 11 篇。
## 待补充
- 权限模型细节:角色-权限-菜单三张表怎么设计,是否需要按门店维度分配不同角色。
## 参考链接
- [Spring Security 官方文档](https://docs.spring.io/spring-security/reference/index.html)
- [Spring Security: JWT 支持(JwtEncoder / JwtDecoder](https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/jwt.html)
- [OWASP JWT 安全实践](https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html)
- [OWASP 认证 Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html)
- [Auth0: Refresh Token Rotation](https://auth0.com/docs/secure/tokens/refresh-tokens/refresh-token-rotation)