528 lines
29 KiB
Markdown
528 lines
29 KiB
Markdown
# 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](../../conti-retail-app/docs/05-networking.md)、[../11-store-context-and-session.md](../../conti-retail-app/docs/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](./06-api-design.md)):先确认身份还有效,再拉可选门店列表。这两个接口在 401 时都会触发客户端的串行刷新流程,所以它们的 401 必须是标准的 `ApiResult` JSON(见下面 `ApiResultAuthenticationEntryPoint`),不能是 500。
|
|||
|
|
|
|||
|
|
其余端点的路径与响应包装规则见 [06-api-design.md](./06-api-design.md)。
|
|||
|
|
|
|||
|
|
## Token 配置与校验
|
|||
|
|
|
|||
|
|
```kotlin
|
|||
|
|
// 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 字节,当前配置不满足" // 启动即失败,不允许带着弱密钥跑起来
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```kotlin
|
|||
|
|
// 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()
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```kotlin
|
|||
|
|
// 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 结构
|
|||
|
|
|
|||
|
|
```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。
|
|||
|
|
|
|||
|
|
## `StoreContextHolder` 与上下文注入
|
|||
|
|
|
|||
|
|
```kotlin
|
|||
|
|
// 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` 换成了别的注册方式,这里会以一个不太好懂的报错炸掉,值得记一笔。
|
|||
|
|
|
|||
|
|
```kotlin
|
|||
|
|
// 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:
|
|||
|
|
|
|||
|
|
```kotlin
|
|||
|
|
@Service
|
|||
|
|
class WorkbenchAppService(
|
|||
|
|
private val storeContextHolder: StoreContextHolder,
|
|||
|
|
private val tileRepository: WorkbenchTileRepository,
|
|||
|
|
) {
|
|||
|
|
fun listTiles(): List<TileResponse> {
|
|||
|
|
val userId = storeContextHolder.currentUserId()
|
|||
|
|
val storeId = storeContextHolder.currentStoreId()
|
|||
|
|
// ...
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## `SecurityConfig`
|
|||
|
|
|
|||
|
|
```kotlin
|
|||
|
|
@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) }
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```kotlin
|
|||
|
|
// 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](./06-api-design.md) 的 `GlobalExceptionHandler`)根本接不到。如果不显式处理,token 过期会返回一个空 body 的 401 或者 Spring 默认的错误页——而客户端 [../05-networking.md](../../conti-retail-app/docs/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 又要验签"的复杂度。
|
|||
|
|
|
|||
|
|
```kotlin
|
|||
|
|
// 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()
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```sql
|
|||
|
|
-- 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;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```kotlin
|
|||
|
|
// 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](../../conti-retail-app/docs/11-store-context-and-session.md) 的串行刷新设计正是建立在"重放即全量撤销"这条规则上的)。
|
|||
|
|
- **过期清理**:加一个定时任务定期删掉 `expires_at < now()` 且已撤销的行。**注意多副本下这个任务会在每个 Pod 各跑一次**,处理方式见 [12-concurrency-and-scheduling.md](./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](./09-build-deploy.md)、[07-config-governance.md](./07-config-governance.md) 里"能用现有基础设施就不额外引入运维负担"的取舍一致。
|
|||
|
|
|
|||
|
|
结论:refresh token 存现有 MySQL 就够,不引入 Redis;如果以后出现 Redis 能解决、MySQL 解决不了的场景(比如跨实例分布式限流、高频缓存),到时候再评估,届时 refresh token 也可以顺带迁过去,不存在现在这张表以后没法迁移的问题。
|
|||
|
|
|
|||
|
|
## 切换门店
|
|||
|
|
|
|||
|
|
```kotlin
|
|||
|
|
@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))
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```kotlin
|
|||
|
|
@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。
|
|||
|
|
|
|||
|
|
## 越权与数据隔离规则
|
|||
|
|
|
|||
|
|
这是这套系统里最容易出、也最贵的一类漏洞(一个门店看到另一个门店的数据),单独立规则:
|
|||
|
|
|
|||
|
|
1. **所有涉及门店数据的查询,必须以 `storeContextHolder.currentStoreId()` 作为过滤条件**,而不是用请求参数里传来的 `storeId`。请求参数可以被任意篡改,token 里的不行。
|
|||
|
|
2. **确实需要按路径/参数指定 `storeId` 的接口(如切店),必须先校验该用户对目标门店的可访问性**,校验不过返回 `STORE_NOT_ACCESSIBLE`。上面的 `findAccessibleStore` 就是这个模式:把权限校验合进查询条件,而不是"先查出来再判断"——后者很容易漏判,前者查不到就是没权限。
|
|||
|
|
3. **按 ID 查单条记录时,`where id = ? and store_id = ?`**,不要只按主键查完再比对——只按主键查会让"不存在"和"没权限"走不同的代码路径,容易漏掉后者,而且响应差异本身就是一种信息泄漏。
|
|||
|
|
4. 这三条在 code review 里是必看项,新增查询方法时优先检查。
|
|||
|
|
|
|||
|
|
### 接口级权限 vs 菜单权限
|
|||
|
|
|
|||
|
|
- **菜单权限**(切店时返回的 `menu`)只决定 APP 上显示什么,是 UI 层的便利,**不构成安全边界**。客户端拿到的菜单里没有某一项,不代表它调不到对应接口。
|
|||
|
|
- **接口级权限**用 `@PreAuthorize` 在 `application` 层(或 Controller)上显式声明,这才是真正的边界:
|
|||
|
|
|
|||
|
|
```kotlin
|
|||
|
|
@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](./08-observability.md)。
|
|||
|
|
|
|||
|
|
## 密钥轮换
|
|||
|
|
|
|||
|
|
JWT 签名密钥放在 K8s Secret 里(见 [07-config-governance.md](./07-config-governance.md)),轮换按 `kid` 分三步走,全程不需要让用户重新登录:
|
|||
|
|
|
|||
|
|
1. **加新密钥**:在 `security.jwt.keys` 里加一个新 `kid`,但 `activeKeyId` 保持旧的。这时新旧密钥都能验签,签发仍用旧的。滚动更新完成后所有实例都认识新密钥。
|
|||
|
|
2. **切签发**:把 `activeKeyId` 改成新 `kid`。此时新签发的 token 用新密钥,还在有效期内的旧 token 仍能验过。
|
|||
|
|
3. **删旧密钥**:等超过一个 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](./05-integration-layer.md)),不复用登录 JWT,避免票据泄漏后长期有效。
|
|||
|
|
|
|||
|
|
## 附录一:为什么用 Spring Security 内置的 Nimbus,而不是 jjwt
|
|||
|
|
|
|||
|
|
jjwt 是 Java 生态里很常见的 JWT 库,但在我们这个版本基线(Spring Boot 4 / [01-project-structure.md](./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` 有两个常见坑:
|
|||
|
|
|
|||
|
|
1. **忘记清理**:请求处理完不手动 `remove()`,线程池复用线程时,下一个请求可能读到上一个请求残留的上下文——在 Tomcat 这种线程池容器里是真实发生过的安全事故类型。
|
|||
|
|
2. **线程切换场景失效**:一旦把工作交给另一个线程池(比如 `workbench` 首页的并行聚合,见 [11-cross-domain-collaboration.md](./11-cross-domain-collaboration.md)),`ThreadLocal` 绑定的线程和实际干活的线程就不是同一个了。
|
|||
|
|
|
|||
|
|
Spring 的 `@RequestScope` bean 由容器管理生命周期,请求结束自动销毁,不需要手动清理,语义上也更清楚地表达"这个对象的生命周期等于一次 HTTP 请求"。
|
|||
|
|
|
|||
|
|
需要注意的是 `@RequestScope` 也**不会自动跨线程传播**:并行聚合时子线程里拿不到它。那种场景要么把需要的值(`userId`/`storeId`)作为方法参数显式传进去(推荐,最不容易出错),要么给线程池配 `TaskDecorator` 显式搬运上下文——两种做法的取舍见 11 篇。
|
|||
|
|
|
|||
|
|
## 待补充
|
|||
|
|
|
|||
|
|
- 权限模型细节:角色-权限-菜单三张表怎么设计,是否需要按门店维度分配不同角色。
|
|||
|
|
|
|||
|
|
## 参考链接
|
|||
|
|
|
|||
|
|
- [Spring Security 官方文档](https://docs.spring.io/spring-security/reference/index.html)
|
|||
|
|
- [Spring Security: JWT 支持(JwtEncoder / JwtDecoder)](https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/jwt.html)
|
|||
|
|
- [OWASP JWT 安全实践](https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html)
|
|||
|
|
- [OWASP 认证 Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html)
|
|||
|
|
- [Auth0: Refresh Token Rotation](https://auth0.com/docs/secure/tokens/refresh-tokens/refresh-token-rotation)
|