Files
conti-docs/backend/04-security-auth.md
T
Guangfei.Zhao 1e0cbb86a2 feat: Add comprehensive documentation for integration layer, API design, config governance, observability, build/deploy, and testing strategies
- Introduced integration layer design with Resilience4j for external vendor calls.
- Established API design standards with unified response structures and global exception handling.
- Defined configuration and service governance using Kubernetes native solutions.
- Implemented observability practices including trace ID propagation and structured logging.
- Outlined build and multi-environment deployment strategies using Gradle and GitLab CI/CD.
- Specified testing strategies across different layers, utilizing JUnit, MockK, Testcontainers, and WireMock.
2026-08-12 18:23:11 +08:00

153 lines
6.8 KiB
Markdown
Raw 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` 模块统一签发和校验,对应架构图里 `Auth and Token Center`;门店/角色上下文通过统一 filter 解析后注入 `RequestScope`,供各 domain 读取,对应架构图 `Store Context` 的职责。
## 结构约定
```
platform-security/
JwtTokenProvider # 签发/解析/刷新 token
JwtAuthenticationFilter # 统一 filter:解析 JWT,写入 SecurityContext + StoreContextHolder
StoreContextHolder # RequestScope bean,持有当前 用户+门店+角色
SecurityConfigSupport # 各 domain 复用的 Spring Security 通用配置片段
domains/identity-store/
负责登录、token 签发/刷新/失效、门店列表、菜单权限
```
## `JwtTokenProvider` 示例
```kotlin
// platform-security/.../JwtTokenProvider.kt
@Component
class JwtTokenProvider(
@Value("\${security.jwt.secret}") secret: String,
@Value("\${security.jwt.access-token-ttl-minutes:30}") private val accessTokenTtlMinutes: Long,
) {
private val key = Keys.hmacShaKeyFor(secret.toByteArray())
fun issueAccessToken(userId: Long, storeId: Long, roles: List<String>): String =
Jwts.builder()
.subject(userId.toString())
.claim("storeId", storeId)
.claim("roles", roles)
.issuedAt(Date())
.expiration(Date.from(Instant.now().plus(accessTokenTtlMinutes, ChronoUnit.MINUTES)))
.signWith(key)
.compact()
fun parse(token: String): Jws<Claims> =
Jwts.parser().verifyWith(key).build().parseSignedClaims(token)
}
```
## `JwtAuthenticationFilter` + `StoreContextHolder` 示例
```kotlin
// platform-security/.../StoreContextHolder.kt
@Component
@RequestScope
class StoreContextHolder {
var userId: Long? = null
var storeId: Long? = null
var roles: List<String> = emptyList()
fun currentUserId(): Long = userId ?: throw IllegalStateException("未认证请求不应到达这里")
}
// platform-security/.../JwtAuthenticationFilter.kt
class JwtAuthenticationFilter(
private val jwtTokenProvider: JwtTokenProvider,
private val storeContextHolder: StoreContextHolder,
) : OncePerRequestFilter() {
override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, chain: FilterChain) {
val token = request.getHeader("Authorization")?.removePrefix("Bearer ")
if (token != null) {
val claims = jwtTokenProvider.parse(token).payload
storeContextHolder.userId = claims.subject.toLong()
storeContextHolder.storeId = (claims["storeId"] as Number).toLong()
@Suppress("UNCHECKED_CAST")
storeContextHolder.roles = claims["roles"] as List<String>
val authorities = storeContextHolder.roles.map { SimpleGrantedAuthority("ROLE_$it") }
SecurityContextHolder.getContext().authentication =
UsernamePasswordAuthenticationToken(storeContextHolder.userId, null, authorities)
}
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()
// ...
}
}
```
## `SecurityConfigSupport` 示例(各 domain/bootstrap 复用)
```kotlin
@Configuration
@EnableWebSecurity
class SecurityConfig(
private val jwtTokenProvider: JwtTokenProvider,
private val storeContextHolder: StoreContextHolder,
) {
@Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain {
http
.csrf { it.disable() } // 无状态 API,不需要 CSRF token
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
.authorizeHttpRequests {
it.requestMatchers("/actuator/health", "/api/v1/auth/login").permitAll()
it.anyRequest().authenticated()
}
.addFilterBefore(
JwtAuthenticationFilter(jwtTokenProvider, storeContextHolder),
UsernamePasswordAuthenticationFilter::class.java,
)
return http.build()
}
}
```
## 关键规则
- `API Gateway` 已做 TLS/路由,后端服务只需要校验 JWT 签名和 claims,不重复做接入层的事。
- 门店/角色上下文在统一 filter 里解析 JWT 后写入 `StoreContextHolder`,各 domain 通过它读取当前上下文,不各自解析 token——避免"每个 domain 有一份自己的 token 解析逻辑"这种重复和不一致。
- 切换门店会使当前上下文失效,`webview-ticket` 相关会话需要联动失效(对应架构图 Flow 2 的规则):`identity-store` 切换门店成功后,需要通知 `webview-ticket` 使当前 ticket 状态置为 `INVALIDATED`(走 `bff-orchestration` 编排或事件通知,不是 `identity-store` 直接改 `webview-ticket` 的表)。
- `webview-ticket` 用的票据是独立的短时票据机制(见 [05-integration-layer.md](./05-integration-layer.md)),不复用登录 JWT,避免票据泄漏后长期有效。
## 附录:为什么用 `RequestScope` bean 而不是 `ThreadLocal`
传统做法常见用 `ThreadLocal` 存当前用户上下文,但 `ThreadLocal` 有两个常见坑:
1. **忘记清理**:请求处理完不手动 `remove()`,线程池复用线程时,下一个请求可能读到上一个请求残留的上下文——在 Tomcat 这种线程池容器里是真实发生过的安全事故类型。
2. **响应式/协程场景失效**:一旦引入 `WebClient` 的异步回调或 Kotlin 协程切换线程,`ThreadLocal` 绑定的线程和实际处理请求的线程可能不是同一个。
Spring 的 `@RequestScope` bean 由容器管理生命周期,请求结束自动销毁,不需要手动清理,语义上也更清楚地表达"这个对象的生命周期等于一次 HTTP 请求"。当前阶段 domain 内部都是同步 Servlet 栈(Spring MVC),`RequestScope` 完全够用;如果未来某个模块换成 WebFlux(响应式),需要改用 Reactor Context 传递上下文,不能直接照搬 `RequestScope`
## 待补充
- JWT 具体 claims 结构、refresh token 的存储方式(Redis?)。
- 权限模型细节(菜单权限 vs 接口级权限,是否需要单独的权限表)。
- 与 K8s ConfigMap/Secret 配合的密钥轮换方式,见 [07-config-governance.md](./07-config-governance.md)。
## 参考链接
- [Spring Security 官方文档](https://docs.spring.io/spring-security/reference/index.html)
- [jjwtJWT 库)](https://github.com/jwtk/jjwt)
- [OWASP JWT 安全实践](https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html)