Files
conti-docs/backend/04-security-auth.md
T

256 lines
12 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,避免票据泄漏后长期有效。
## Access Token 的 claims 结构
在现有 `JwtTokenProvider.issueAccessToken` 基础上补充 `jti`(每次签发的唯一 ID),完整 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。
## Refresh Token:不用 Redis,直接用现有 Postgres
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_tokens")
class RefreshTokenEntity(
@Id @GeneratedValue val id: Long? = null,
val userId: Long,
val tokenHash: String, // SHA-256(原始 token) 的 hex,不存明文
val expiresAt: Instant,
var revokedAt: Instant? = null,
var replacedByTokenId: Long? = null, // 轮换链,用于检测"已撤销的旧 token 被重放"
) : BaseEntity()
```
```sql
-- domains/identity-store/src/main/resources/db/migration/identity-store/V2__refresh_tokens.sql
CREATE TABLE refresh_tokens (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
token_hash CHAR(64) NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
replaced_by_token_id BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id);
```
```kotlin
// domains/identity-store/application/RefreshTokenService.kt
@Service
class RefreshTokenService(
private val repository: RefreshTokenJpaRepository,
private val clock: Clock,
) {
fun issue(userId: Long): String {
val rawToken = generateOpaqueToken()
repository.save(
RefreshTokenEntity(
userId = userId,
tokenHash = sha256Hex(rawToken),
expiresAt = clock.instant().plus(30, ChronoUnit.DAYS),
),
)
return rawToken
}
@Transactional
fun rotate(rawToken: String): String {
val existing = repository.findByTokenHash(sha256Hex(rawToken))
?.takeIf { it.revokedAt == null && it.expiresAt.isAfter(clock.instant()) }
?: throw InvalidRefreshTokenException() // 已过期/已撤销/被重放,一律要求重新登录
val rotated = repository.save(
RefreshTokenEntity(
userId = existing.userId,
tokenHash = sha256Hex(generateOpaqueToken()),
expiresAt = clock.instant().plus(30, ChronoUnit.DAYS),
),
)
existing.revokedAt = clock.instant()
existing.replacedByTokenId = rotated.id
return rotated.tokenHash // 实际返回给客户端的是加密前的 rawToken,此处示意轮换关系
}
fun revokeAllByUser(userId: Long) = repository.revokeAllByUserId(userId, clock.instant()) // 登出/强制下线
}
```
- **轮换(rotation**:每次用 refresh token 换 access token,同时签发一个新的 refresh token,旧的立刻标记 `revokedAt`——如果旧 token 之后又被人拿来用一次,说明它可能已经泄漏被盗用,能立刻识别出这次"重放",可以顺带撤销该用户名下所有 refresh token,强制重新登录。
- **过期清理**:不依赖 Redis 的自动 TTL,加一个简单的定时任务(`@Scheduled`)定期删掉 `expires_at < now()` 的行即可——单个用户同时存在的有效 refresh token 数量本来就很小,数据量级不构成问题。
### 为什么现阶段不引入 Redis
Redis 常被用来存 refresh token/session,图的是两点:**自动过期(TTL)**和**高并发读写性能**。这两点目前都不是硬约束:
- 过期:一张表 + 一个定时清理任务就够,只是没有 Redis"写入即设 TTL、到期自动消失"那么省事。
- 性能:refresh token 校验只发生在"access token 过期后换新"这一低频动作上,不是每次请求都查,Postgres 加个唯一索引足够支撑。
- 额外成本:引入 Redis 意味着在 K8s 上多起一个有状态服务(持久化、备份、连接池配置、AKS 里再开一条访问路径),这跟 [09-build-deploy.md](./09-build-deploy.md)、[07-config-governance.md](./07-config-governance.md) 里"能用现有基础设施就不额外引入运维负担"的取舍一致。
结论:refresh token 存现有 Postgres 就够,不引入 Redis;如果以后出现 Redis 能解决、Postgres 解决不了的场景(比如跨实例分布式限流、高频缓存),到时候再评估,届时 refresh token 也可以顺带迁过去,不存在现在这张表以后没法迁移的问题。
## 附录:为什么用 `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`
## 待补充
- 权限模型细节(菜单权限 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)
- [Auth0: Refresh Token Rotation](https://auth0.com/docs/secure/tokens/refresh-tokens/refresh-token-rotation)