153 lines
6.8 KiB
Markdown
153 lines
6.8 KiB
Markdown
# 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)
|
|||
|
|
- [jjwt(JWT 库)](https://github.com/jwtk/jjwt)
|
|||
|
|
- [OWASP JWT 安全实践](https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html)
|