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

29 KiB
Raw Permalink Blame History

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../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/meGET /api/v1/stores/accessible(后者见 06-api-design.md):先确认身份还有效,再拉可选门店列表。这两个接口在 401 时都会触发客户端的串行刷新流程,所以它们的 401 必须是标准的 ApiResult JSON(见下面 ApiResultAuthenticationEntryPoint),不能是 500。

其余端点的路径与响应包装规则见 06-api-design.md

Token 配置与校验

// 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 字节,当前配置不满足"   // 启动即失败,不允许带着弱密钥跑起来
        }
    }
}
// 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()
}
// 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 结构

{
  "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 与上下文注入

// 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 换成了别的注册方式,这里会以一个不太好懂的报错炸掉,值得记一笔。

// 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:

@Service
class WorkbenchAppService(
    private val storeContextHolder: StoreContextHolder,
    private val tileRepository: WorkbenchTileRepository,
) {
    fun listTiles(): List<TileResponse> {
        val userId = storeContextHolder.currentUserId()
        val storeId = storeContextHolder.currentStoreId()
        // ...
    }
}

SecurityConfig

@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) }
    }
}
// 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 之前,这里抛出的异常 @RestControllerAdvice06-api-design.mdGlobalExceptionHandler)根本接不到。如果不显式处理,token 过期会返回一个空 body 的 401 或者 Spring 默认的错误页——而客户端 ../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 又要验签"的复杂度。

// 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()
-- 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;
// 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 的串行刷新设计正是建立在"重放即全量撤销"这条规则上的)。
  • 过期清理:加一个定时任务定期删掉 expires_at < now() 且已撤销的行。注意多副本下这个任务会在每个 Pod 各跑一次,处理方式见 12-concurrency-and-scheduling.md

为什么现阶段不引入 Redis

Redis 常被用来存 refresh token/session,图的是两点:自动过期(TTL高并发读写性能。这两点目前都不是硬约束:

  • 过期:一张表 + 一个定时清理任务就够,只是没有 Redis"写入即设 TTL、到期自动消失"那么省事。
  • 性能:refresh token 校验只发生在"access token 过期后换新"这一低频动作上,不是每次请求都查,MySQL 上一个唯一索引足够支撑。
  • 额外成本:引入 Redis 意味着在 K8s 上多起一个有状态服务(持久化、备份、连接池配置、AKS 里再开一条访问路径),这跟 09-build-deploy.md07-config-governance.md 里"能用现有基础设施就不额外引入运维负担"的取舍一致。

结论:refresh token 存现有 MySQL 就够,不引入 Redis;如果以后出现 Redis 能解决、MySQL 解决不了的场景(比如跨实例分布式限流、高频缓存),到时候再评估,届时 refresh token 也可以顺带迁过去,不存在现在这张表以后没法迁移的问题。

切换门店

@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))
}
@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 层的便利,不构成安全边界。客户端拿到的菜单里没有某一项,不代表它调不到对应接口。

  • 接口级权限@PreAuthorizeapplication 层(或 Controller)上显式声明,这才是真正的边界:

    @PreAuthorize("hasRole('STORE_MANAGER')")
    fun approveProcurement(id: Long) { ... }
    
  • 两者的数据来源应该是同一份角色/权限配置,但必须两边都做。只做菜单不做接口校验,等于没做。

密码存储与登录保护

  • 密码用 BCrypt 存储BCryptPasswordEncoder,强度 12),存 hash,绝不存明文或可逆加密。用 DelegatingPasswordEncoderSpring Security 默认)让 hash 带 {bcrypt} 前缀,将来换算法时新旧可以共存。
  • 登录失败限流user 表上加 failed_attempts / locked_until 两列,同一账号连续失败 5 次锁定 15 分钟。放在数据库而不是内存里,是因为多副本下内存计数各算各的,等于没限。
  • IP 维度的限流放在 API Gateway 做,后端不重复实现——后端看到的往往是网关的 IP,自己做也做不准。
  • 登录失败的响应不区分"用户不存在"和"密码错误",统一返回同一个错误码和文案,避免被用来枚举账号。
  • 密码、token、Authorization 头等敏感字段不允许进日志,脱敏规则见 08-observability.md

密钥轮换

JWT 签名密钥放在 K8s Secret 里(见 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),不复用登录 JWT,避免票据泄漏后长期有效。

附录一:为什么用 Spring Security 内置的 Nimbus,而不是 jjwt

jjwt 是 Java 生态里很常见的 JWT 库,但在我们这个版本基线(Spring Boot 4 / 01-project-structure.md)下它有个具体问题:jjwt 的 JSON 序列化模块 jjwt-jackson 依赖 Jackson 2com.fasterxml.jackson),而 Boot 4 默认用的是 Jackson 3tools.jackson)。两者包名不同、不二进制兼容,用 jjwt 就意味着要在同一个应用里同时带两套 Jackson——能跑,但多一份依赖、多一处升级时要照顾的地方,还容易让人误以为 ObjectMapper 只有一个。

Spring Security 本身就自带基于 Nimbus JOSE + JWT 的 JwtEncoder/JwtDecoderspring-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),ThreadLocal 绑定的线程和实际干活的线程就不是同一个了。

Spring 的 @RequestScope bean 由容器管理生命周期,请求结束自动销毁,不需要手动清理,语义上也更清楚地表达"这个对象的生命周期等于一次 HTTP 请求"。

需要注意的是 @RequestScope不会自动跨线程传播:并行聚合时子线程里拿不到它。那种场景要么把需要的值(userId/storeId)作为方法参数显式传进去(推荐,最不容易出错),要么给线程池配 TaskDecorator 显式搬运上下文——两种做法的取舍见 11 篇。

待补充

  • 权限模型细节:角色-权限-菜单三张表怎么设计,是否需要按门店维度分配不同角色。

参考链接