29 KiB
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、../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):先确认身份还有效,再拉可选门店列表。这两个接口在 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 之前,这里抛出的异常 @RestControllerAdvice(06-api-design.md 的 GlobalExceptionHandler)根本接不到。如果不显式处理,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.md、07-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。
越权与数据隔离规则
这是这套系统里最容易出、也最贵的一类漏洞(一个门店看到另一个门店的数据),单独立规则:
- 所有涉及门店数据的查询,必须以
storeContextHolder.currentStoreId()作为过滤条件,而不是用请求参数里传来的storeId。请求参数可以被任意篡改,token 里的不行。 - 确实需要按路径/参数指定
storeId的接口(如切店),必须先校验该用户对目标门店的可访问性,校验不过返回STORE_NOT_ACCESSIBLE。上面的findAccessibleStore就是这个模式:把权限校验合进查询条件,而不是"先查出来再判断"——后者很容易漏判,前者查不到就是没权限。 - 按 ID 查单条记录时,
where id = ? and store_id = ?,不要只按主键查完再比对——只按主键查会让"不存在"和"没权限"走不同的代码路径,容易漏掉后者,而且响应差异本身就是一种信息泄漏。 - 这三条在 code review 里是必看项,新增查询方法时优先检查。
接口级权限 vs 菜单权限
-
菜单权限(切店时返回的
menu)只决定 APP 上显示什么,是 UI 层的便利,不构成安全边界。客户端拿到的菜单里没有某一项,不代表它调不到对应接口。 -
接口级权限用
@PreAuthorize在application层(或 Controller)上显式声明,这才是真正的边界:@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。
密钥轮换
JWT 签名密钥放在 K8s Secret 里(见 07-config-governance.md),轮换按 kid 分三步走,全程不需要让用户重新登录:
- 加新密钥:在
security.jwt.keys里加一个新kid,但activeKeyId保持旧的。这时新旧密钥都能验签,签发仍用旧的。滚动更新完成后所有实例都认识新密钥。 - 切签发:把
activeKeyId改成新kid。此时新签发的 token 用新密钥,还在有效期内的旧 token 仍能验过。 - 删旧密钥:等超过一个 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),不复用登录 JWT,避免票据泄漏后长期有效。
附录一:为什么用 Spring Security 内置的 Nimbus,而不是 jjwt
jjwt 是 Java 生态里很常见的 JWT 库,但在我们这个版本基线(Spring Boot 4 / 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 有两个常见坑:
- 忘记清理:请求处理完不手动
remove(),线程池复用线程时,下一个请求可能读到上一个请求残留的上下文——在 Tomcat 这种线程池容器里是真实发生过的安全事故类型。 - 线程切换场景失效:一旦把工作交给另一个线程池(比如
workbench首页的并行聚合,见 11-cross-domain-collaboration.md),ThreadLocal绑定的线程和实际干活的线程就不是同一个了。
Spring 的 @RequestScope bean 由容器管理生命周期,请求结束自动销毁,不需要手动清理,语义上也更清楚地表达"这个对象的生命周期等于一次 HTTP 请求"。
需要注意的是 @RequestScope 也不会自动跨线程传播:并行聚合时子线程里拿不到它。那种场景要么把需要的值(userId/storeId)作为方法参数显式传进去(推荐,最不容易出错),要么给线程池配 TaskDecorator 显式搬运上下文——两种做法的取舍见 11 篇。
待补充
- 权限模型细节:角色-权限-菜单三张表怎么设计,是否需要按门店维度分配不同角色。