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

12 KiB
Raw Blame History

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 示例

// 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 示例

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

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

SecurityConfigSupport 示例(各 domain/bootstrap 复用)

@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),不复用登录 JWT,避免票据泄漏后长期有效。

Access Token 的 claims 结构

在现有 JwtTokenProvider.issueAccessToken 基础上补充 jti(每次签发的唯一 ID),完整 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。

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 又要验签"的复杂度。

// 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()
-- 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);
// 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.md07-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

参考链接