# 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 / NimbusJwtDecoder(HS256) AccessTokenIssuer # 签发 access token(claims 结构见下) 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`。撤销该 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 = 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 { 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 = 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, ) : 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("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 { val userId = storeContextHolder.currentUserId() val storeId = storeContextHolder.currentStoreId() // ... } } ``` ## `SecurityConfig` ```kotlin @Configuration @EnableWebSecurity @EnableMethodSecurity // 开启 @PreAuthorize class SecurityConfig( private val storeContextHolder: ObjectFactory, 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 { 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 = 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 TTL(30 分钟)后,从 `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)