backend scaffold
This commit is contained in:
+56
@@ -0,0 +1,56 @@
|
||||
package com.continental.retailapp.identitystore.api
|
||||
|
||||
import com.continental.retailapp.identitystore.api.request.LoginRequest
|
||||
import com.continental.retailapp.identitystore.api.request.RefreshTokenRequest
|
||||
import com.continental.retailapp.identitystore.api.response.LoginResponse
|
||||
import com.continental.retailapp.identitystore.api.response.MeResponse
|
||||
import com.continental.retailapp.identitystore.api.response.TokenResponse
|
||||
import com.continental.retailapp.identitystore.application.AuthAppService
|
||||
import com.continental.retailapp.platform.security.StoreContextHolder
|
||||
import com.continental.retailapp.platform.web.ApiResult
|
||||
import io.swagger.v3.oas.annotations.Operation
|
||||
import io.swagger.v3.oas.annotations.tags.Tag
|
||||
import jakarta.validation.Valid
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
/**
|
||||
* 认证端点。路径与响应体是照着客户端已实现的契约写的(04-security-auth.md),
|
||||
* **后端对齐客户端,不是反过来**。
|
||||
*
|
||||
* Controller 只做参数校验 + 调用 `application`,不含任何业务分支。
|
||||
*/
|
||||
@Tag(name = "认证")
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/auth")
|
||||
class AuthController(
|
||||
private val authAppService: AuthAppService,
|
||||
private val storeContextHolder: StoreContextHolder,
|
||||
) {
|
||||
@Operation(summary = "登录,返回 access token / refresh token 与用户信息")
|
||||
@PostMapping("/login")
|
||||
fun login(@Valid @RequestBody request: LoginRequest): ApiResult<LoginResponse> =
|
||||
ApiResult.ok(authAppService.login(request))
|
||||
|
||||
/** 免认证:调它的时候 access token 恰好已经过期,要求带有效 token 就成了死锁。 */
|
||||
@Operation(summary = "用 refresh token 换新的 access token(轮换)")
|
||||
@PostMapping("/refresh")
|
||||
fun refresh(@Valid @RequestBody request: RefreshTokenRequest): ApiResult<TokenResponse> =
|
||||
ApiResult.ok(authAppService.refresh(request.refreshToken))
|
||||
|
||||
@Operation(summary = "登出,撤销该 refresh token")
|
||||
@PostMapping("/logout")
|
||||
fun logout(@Valid @RequestBody request: RefreshTokenRequest): ApiResult<Unit> {
|
||||
authAppService.logout(request.refreshToken)
|
||||
return ApiResult.ok()
|
||||
}
|
||||
|
||||
/** 客户端冷启动第一个调用的接口,必须轻量且只读。 */
|
||||
@Operation(summary = "当前登录用户与所在门店")
|
||||
@GetMapping("/me")
|
||||
fun me(): ApiResult<MeResponse> =
|
||||
ApiResult.ok(authAppService.me(storeContextHolder.currentUserId()))
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package com.continental.retailapp.identitystore.api
|
||||
|
||||
import com.continental.retailapp.identitystore.api.response.StoreContextResponse
|
||||
import com.continental.retailapp.identitystore.api.response.StoreResponse
|
||||
import com.continental.retailapp.identitystore.application.StoreAppService
|
||||
import com.continental.retailapp.platform.web.ApiResult
|
||||
import io.swagger.v3.oas.annotations.Operation
|
||||
import io.swagger.v3.oas.annotations.tags.Tag
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
@Tag(name = "门店")
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/stores")
|
||||
class StoreController(
|
||||
private val storeAppService: StoreAppService,
|
||||
) {
|
||||
@Operation(summary = "查询当前用户可访问的门店列表")
|
||||
@GetMapping("/accessible")
|
||||
fun listAccessibleStores(): ApiResult<List<StoreResponse>> =
|
||||
ApiResult.ok(storeAppService.listAccessibleStores())
|
||||
|
||||
/**
|
||||
* 这是少数几个"门店 id 来自路径参数"的接口之一,
|
||||
* 所以 `switchStore` 内部**第一件事就是校验可访问性**(04-security-auth.md 越权规则第 2 条)。
|
||||
*/
|
||||
@Operation(summary = "切换当前门店,返回新的门店上下文与重新签发的 access token")
|
||||
@PostMapping("/{storeId}/switch")
|
||||
fun switchStore(@PathVariable storeId: Long): ApiResult<StoreContextResponse> =
|
||||
ApiResult.ok(storeAppService.switchStore(storeId))
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.continental.retailapp.identitystore.api.request
|
||||
|
||||
import jakarta.validation.constraints.NotBlank
|
||||
import jakarta.validation.constraints.Size
|
||||
|
||||
/**
|
||||
* 登录请求。契约见 04-security-auth.md 的端点表——客户端已按此实现,后端对齐客户端。
|
||||
*
|
||||
* `password` 只在这里出现一次,不会进日志:`logback-spring.xml` 里配了兜底脱敏,
|
||||
* 但真正的防线是"永远不打印整个请求体"(08-observability.md)。
|
||||
*/
|
||||
data class LoginRequest(
|
||||
@field:NotBlank(message = "用户名不能为空")
|
||||
@field:Size(max = 64, message = "用户名过长")
|
||||
val username: String,
|
||||
|
||||
@field:NotBlank(message = "密码不能为空")
|
||||
@field:Size(max = 128, message = "密码过长")
|
||||
val password: String,
|
||||
)
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.continental.retailapp.identitystore.api.request
|
||||
|
||||
import jakarta.validation.constraints.NotBlank
|
||||
|
||||
/** `POST /api/v1/auth/refresh` 与 `POST /api/v1/auth/logout` 共用同一个请求体。 */
|
||||
data class RefreshTokenRequest(
|
||||
@field:NotBlank(message = "refreshToken 不能为空")
|
||||
val refreshToken: String,
|
||||
)
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.continental.retailapp.identitystore.api.response
|
||||
|
||||
/** 用户对外视图。不含 `passwordHash`、`failedAttempts` 这些内部字段。 */
|
||||
data class UserResponse(
|
||||
val id: Long,
|
||||
val username: String,
|
||||
val displayName: String,
|
||||
)
|
||||
|
||||
/** `POST /api/v1/auth/login` 的响应:`{ accessToken, refreshToken, user }`。 */
|
||||
data class LoginResponse(
|
||||
val accessToken: String,
|
||||
val refreshToken: String,
|
||||
val user: UserResponse,
|
||||
)
|
||||
|
||||
/** `POST /api/v1/auth/refresh` 的响应:`{ accessToken, refreshToken }`。 */
|
||||
data class TokenResponse(
|
||||
val accessToken: String,
|
||||
val refreshToken: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* `GET /api/v1/auth/me` 的响应:`{ user, currentStoreId }`。
|
||||
* 客户端冷启动会调它恢复会话,所以它必须是只读的轻量查询。
|
||||
*/
|
||||
data class MeResponse(
|
||||
val user: UserResponse,
|
||||
val currentStoreId: Long?,
|
||||
)
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.continental.retailapp.identitystore.api.response
|
||||
|
||||
/**
|
||||
* 切店返回的完整门店上下文(04-security-auth.md)。
|
||||
*
|
||||
* 返回的是上下文而不是空 body:`accessToken` 里的 `storeId` 决定了后续所有请求的数据范围,
|
||||
* 只改客户端本地状态不换 token,等于门店没真的切过去。
|
||||
*
|
||||
* `menus` 只是 UI 便利——**它不构成安全边界**,真正的边界是各端点上的 `@PreAuthorize`。
|
||||
* 菜单权限模型 04 文档自己标了"待补充",这里先留一个按角色推导的最小实现。
|
||||
*/
|
||||
data class StoreContextResponse(
|
||||
val accessToken: String,
|
||||
val store: StoreResponse,
|
||||
val roles: List<String>,
|
||||
val menus: List<String>,
|
||||
)
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package com.continental.retailapp.identitystore.api.response
|
||||
|
||||
/** 门店对外视图,字段与客户端已实现的契约一致(06-api-design.md)。 */
|
||||
data class StoreResponse(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val code: String,
|
||||
)
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
package com.continental.retailapp.identitystore.application
|
||||
|
||||
import com.continental.retailapp.identitystore.api.request.LoginRequest
|
||||
import com.continental.retailapp.identitystore.api.response.LoginResponse
|
||||
import com.continental.retailapp.identitystore.api.response.MeResponse
|
||||
import com.continental.retailapp.identitystore.api.response.TokenResponse
|
||||
import com.continental.retailapp.identitystore.api.response.UserResponse
|
||||
import com.continental.retailapp.identitystore.infrastructure.persistence.StoreRepository
|
||||
import com.continental.retailapp.identitystore.infrastructure.persistence.UserEntity
|
||||
import com.continental.retailapp.identitystore.infrastructure.persistence.UserJpaRepository
|
||||
import com.continental.retailapp.identitystore.infrastructure.persistence.UserStatus
|
||||
import com.continental.retailapp.platform.observability.audit.Audited
|
||||
import com.continental.retailapp.platform.security.AccessTokenIssuer
|
||||
import com.continental.retailapp.platform.web.BusinessException
|
||||
import com.continental.retailapp.platform.web.ErrorCode
|
||||
import io.micrometer.core.instrument.MeterRegistry
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.security.crypto.password.PasswordEncoder
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.time.Clock
|
||||
import java.time.Duration
|
||||
|
||||
/**
|
||||
* 登录 / 刷新 / 登出 / me,见 04-security-auth.md。
|
||||
*
|
||||
* 两条贯穿本类的规则:
|
||||
*
|
||||
* 1. **失败响应必须无差别**。"用户不存在""密码错误""账号被锁"对外都是同一句
|
||||
* "用户名或密码错误"、同一个错误码。区分开来等于送给攻击方一个用户名枚举接口。
|
||||
* 2. **密码和令牌不进日志**。日志里只出现 `userId`,不出现 `username`(PII)、更不出现密码。
|
||||
*/
|
||||
@Service
|
||||
class AuthAppService(
|
||||
private val userJpaRepository: UserJpaRepository,
|
||||
private val storeRepository: StoreRepository,
|
||||
private val passwordEncoder: PasswordEncoder,
|
||||
private val accessTokenIssuer: AccessTokenIssuer,
|
||||
private val refreshTokenService: RefreshTokenService,
|
||||
private val meterRegistry: MeterRegistry,
|
||||
private val clock: Clock,
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(javaClass)
|
||||
|
||||
@Audited(action = "LOGIN")
|
||||
@Transactional
|
||||
fun login(request: LoginRequest): LoginResponse {
|
||||
val now = clock.instant()
|
||||
val user = userJpaRepository.findByUsername(request.username)
|
||||
|
||||
// 用户不存在也要走一次 BCrypt:否则"不存在"的响应会明显更快,
|
||||
// 时间差本身就是一个可用的用户名枚举信号。
|
||||
if (user == null) {
|
||||
passwordEncoder.matches(request.password, DUMMY_HASH)
|
||||
return failLogin("USER_NOT_FOUND")
|
||||
}
|
||||
if (user.status != UserStatus.ACTIVE) {
|
||||
return failLogin("DISABLED")
|
||||
}
|
||||
if (user.isLocked(now)) {
|
||||
return failLogin("LOCKED")
|
||||
}
|
||||
if (!passwordEncoder.matches(request.password, user.passwordHash)) {
|
||||
registerFailure(user)
|
||||
return failLogin("BAD_PASSWORD")
|
||||
}
|
||||
|
||||
user.failedAttempts = 0
|
||||
user.lockedUntil = null
|
||||
|
||||
val storeId = resolveCurrentStore(user)
|
||||
val roles = roleNamesOf(user.id!!, storeId)
|
||||
user.currentStoreId = storeId
|
||||
|
||||
countLogin("success")
|
||||
return LoginResponse(
|
||||
accessToken = accessTokenIssuer.issue(user.id!!, storeId, roles),
|
||||
refreshToken = refreshTokenService.issue(user.id!!),
|
||||
user = user.toResponse(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新。轮换只换 refresh token 和 access token,**不换门店**——
|
||||
* 门店取自 `user.current_store_id`,否则每次刷新都会把用户弹回默认门店。
|
||||
*/
|
||||
@Transactional
|
||||
fun refresh(rawRefreshToken: String): TokenResponse {
|
||||
val rotated = refreshTokenService.rotate(rawRefreshToken)
|
||||
val user = userJpaRepository.findById(rotated.userId).orElseThrow { InvalidRefreshTokenException() }
|
||||
if (user.status != UserStatus.ACTIVE) {
|
||||
// 账号在这期间被停用:连带把该用户所有令牌作废,不给它继续刷的机会
|
||||
refreshTokenService.revokeAllByUser(user.id!!)
|
||||
throw InvalidRefreshTokenException()
|
||||
}
|
||||
|
||||
val storeId = resolveCurrentStore(user)
|
||||
user.currentStoreId = storeId
|
||||
return TokenResponse(
|
||||
accessToken = accessTokenIssuer.issue(user.id!!, storeId, roleNamesOf(user.id!!, storeId)),
|
||||
refreshToken = rotated.refreshToken,
|
||||
)
|
||||
}
|
||||
|
||||
@Audited(action = "LOGOUT")
|
||||
@Transactional
|
||||
fun logout(rawRefreshToken: String) = refreshTokenService.revoke(rawRefreshToken)
|
||||
|
||||
/** 客户端每次冷启动都会调,必须是只读的轻量查询——不要在这里做任何写操作。 */
|
||||
@Transactional(readOnly = true)
|
||||
fun me(userId: Long): MeResponse {
|
||||
val user = userJpaRepository.findById(userId).orElseThrow {
|
||||
BusinessException(ErrorCode.UNAUTHORIZED, "登录状态已失效,请重新登录", HttpStatus.UNAUTHORIZED)
|
||||
}
|
||||
return MeResponse(user = user.toResponse(), currentStoreId = user.currentStoreId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 选门店:优先沿用上次的,但**必须重新校验它现在还可访问**——
|
||||
* 授权可能在两次登录之间被收回,直接信任 `current_store_id` 就是越权。
|
||||
*/
|
||||
private fun resolveCurrentStore(user: UserEntity): Long {
|
||||
val userId = user.id!!
|
||||
user.currentStoreId
|
||||
?.let { storeRepository.findAccessibleStore(userId, it) }
|
||||
?.let { return it.id }
|
||||
|
||||
return storeRepository.findStoresByUserId(userId).firstOrNull()?.id
|
||||
?: throw BusinessException(
|
||||
ErrorCode.NO_STORE_PERMISSION,
|
||||
"当前账号未被授权任何门店,请联系管理员",
|
||||
HttpStatus.FORBIDDEN,
|
||||
)
|
||||
}
|
||||
|
||||
private fun roleNamesOf(userId: Long, storeId: Long): List<String> =
|
||||
storeRepository.findRoleNames(userId, storeId)
|
||||
?.split(',')
|
||||
?.map { it.trim() }
|
||||
?.filter { it.isNotEmpty() }
|
||||
.orEmpty()
|
||||
|
||||
private fun registerFailure(user: UserEntity) {
|
||||
user.failedAttempts += 1
|
||||
if (user.failedAttempts >= MAX_FAILED_ATTEMPTS) {
|
||||
user.lockedUntil = clock.instant().plus(LOCK_DURATION)
|
||||
user.failedAttempts = 0
|
||||
log.warn("用户 {} 连续登录失败 {} 次,已锁定 {} 分钟", user.id, MAX_FAILED_ATTEMPTS, LOCK_DURATION.toMinutes())
|
||||
}
|
||||
}
|
||||
|
||||
/** 所有失败分支收敛到这一个出口,保证对外文案与错误码完全一致。 */
|
||||
private fun failLogin(internalReason: String): Nothing {
|
||||
countLogin("failure", internalReason)
|
||||
throw BusinessException(ErrorCode.UNAUTHORIZED, "用户名或密码错误", HttpStatus.UNAUTHORIZED)
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录成功率埋点(08-observability.md)。
|
||||
* `reason` 只有有限几个取值,不会把标签基数打爆。
|
||||
*/
|
||||
private fun countLogin(result: String, reason: String = "none") {
|
||||
meterRegistry.counter("auth.login", "result", result, "reason", reason).increment()
|
||||
}
|
||||
|
||||
private fun UserEntity.toResponse() = UserResponse(id = id!!, username = username, displayName = displayName)
|
||||
|
||||
private companion object {
|
||||
const val MAX_FAILED_ATTEMPTS = 5
|
||||
val LOCK_DURATION: Duration = Duration.ofMinutes(15)
|
||||
|
||||
/**
|
||||
* 用户不存在时拿来空跑一次 BCrypt 的假哈希(明文是随机的,没人知道)。
|
||||
* 目的只是消掉时间差,它永远不会匹配成功。
|
||||
*/
|
||||
const val DUMMY_HASH = "{bcrypt}\$2a\$12\$C6UzMDM.H6dfI/f/IKcEe.3XkkkkkkkkkkkkkkkkkkkkkkkkkkkkO"
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package com.continental.retailapp.identitystore.application
|
||||
|
||||
import com.continental.retailapp.platform.web.BusinessException
|
||||
import com.continental.retailapp.platform.web.ErrorCode
|
||||
import org.springframework.http.HttpStatus
|
||||
|
||||
/**
|
||||
* refresh token 不可用的统一出口。
|
||||
*
|
||||
* 对外只有一句"登录状态已失效"——不区分"没见过这个 token""已经被轮换掉了""过期了"。
|
||||
* 这三种情况在服务端走完全不同的分支(见 [RefreshTokenService.rotate]),
|
||||
* 但对外必须长得一模一样,否则调用方可以拿响应差异来探测令牌是否存在。
|
||||
*/
|
||||
class InvalidRefreshTokenException : BusinessException(
|
||||
code = ErrorCode.UNAUTHORIZED,
|
||||
message = "登录状态已失效,请重新登录",
|
||||
httpStatus = HttpStatus.UNAUTHORIZED,
|
||||
)
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.continental.retailapp.identitystore.application
|
||||
|
||||
/**
|
||||
* 角色 → 菜单的最小映射。
|
||||
*
|
||||
* **菜单不是安全边界**:它只决定客户端画不画那个入口。真正的边界是各端点上的
|
||||
* `@PreAuthorize`——即便菜单里没有,直接调接口也必须被拦住(04-security-auth.md)。
|
||||
* 完整的菜单权限模型 04 文档标了"待补充",这里先给一个够跑通链路的实现。
|
||||
*/
|
||||
object MenuCatalog {
|
||||
|
||||
private val BY_ROLE = mapOf(
|
||||
"STORE_MANAGER" to listOf("workbench", "procurement", "warranty", "report", "staff"),
|
||||
"STAFF" to listOf("workbench", "warranty"),
|
||||
)
|
||||
|
||||
fun menusOf(roles: List<String>): List<String> =
|
||||
roles.flatMap { BY_ROLE[it].orEmpty() }.distinct()
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package com.continental.retailapp.identitystore.application
|
||||
|
||||
import com.continental.retailapp.identitystore.infrastructure.persistence.RefreshTokenJpaRepository
|
||||
import net.javacrumbs.shedlock.spring.annotation.SchedulerLock
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.time.Clock
|
||||
|
||||
/**
|
||||
* 清理过期的 refresh token(12-concurrency-and-scheduling.md)。
|
||||
*
|
||||
* 定时任务放 `application` 层:它就是一个由时钟而不是 HTTP 请求触发的用例。
|
||||
*
|
||||
* - `@SchedulerLock` 不能省:`@Scheduled` 在每个 Pod 上都会独立执行,2 个副本就跑 2 遍。
|
||||
* 删除本身是幂等的,但保持"所有定时任务都加锁"这条规则比逐个判断更可靠。
|
||||
* - cron 用 3:17 而不是 3:00:整点是全世界定时任务的尖峰,错开几分钟没有业务代价。
|
||||
* - 自己兜住异常:`@Scheduled` 抛异常只会被 Spring 记一条日志,不会重试,也不会告警。
|
||||
*/
|
||||
@Component
|
||||
class RefreshTokenCleanupJob(
|
||||
private val repository: RefreshTokenJpaRepository,
|
||||
private val clock: Clock,
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(javaClass)
|
||||
|
||||
@Scheduled(cron = "0 17 3 * * *")
|
||||
@SchedulerLock(name = "refreshTokenCleanup", lockAtLeastFor = "PT1M", lockAtMostFor = "PT10M")
|
||||
@Transactional
|
||||
fun cleanup() {
|
||||
runCatching { repository.deleteExpiredBefore(clock.instant()) }
|
||||
.onSuccess { log.info("清理过期 refresh token,删除 {} 条", it) }
|
||||
.onFailure { log.error("清理过期 refresh token 失败", it) }
|
||||
}
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
package com.continental.retailapp.identitystore.application
|
||||
|
||||
import com.continental.retailapp.identitystore.infrastructure.persistence.RefreshTokenEntity
|
||||
import com.continental.retailapp.identitystore.infrastructure.persistence.RefreshTokenJpaRepository
|
||||
import com.continental.retailapp.platform.security.JwtProperties
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import java.time.Clock
|
||||
import java.time.temporal.ChronoUnit
|
||||
import java.util.Base64
|
||||
import java.util.HexFormat
|
||||
|
||||
/** 轮换结果:明文 refresh token 只在这里往外传一次,之后就只剩 hash 了。 */
|
||||
data class RotatedTokens(val userId: Long, val refreshToken: String)
|
||||
|
||||
/**
|
||||
* refresh token 的签发、轮换、撤销,逐条按 04-security-auth.md。
|
||||
*
|
||||
* refresh token 是**不透明随机串**而不是 JWT:它需要能被立即撤销,
|
||||
* 而自包含的 JWT 在过期前撤不掉。库里只存 SHA-256 摘要。
|
||||
*
|
||||
* [Clock] 走构造注入而不是 `Instant.now()`,测试里才能用 `Clock.fixed` 精确控制过期边界(10-testing.md)。
|
||||
*/
|
||||
@Service
|
||||
class RefreshTokenService(
|
||||
private val repository: RefreshTokenJpaRepository,
|
||||
private val props: JwtProperties,
|
||||
private val clock: Clock,
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(javaClass)
|
||||
private val random = SecureRandom()
|
||||
|
||||
@Transactional
|
||||
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,强制重新登录。
|
||||
//
|
||||
// "从来没见过的 token" 和 "已撤销的 token" 必须分成两个分支,
|
||||
// 合成一个 `?: throw` 会让重放检测形同虚设。
|
||||
if (existing.revokedAt != null) {
|
||||
repository.revokeAllByUserId(existing.userId, clock.instant())
|
||||
log.warn("检测到 refresh token 重放,已撤销用户 {} 的全部 refresh token", existing.userId)
|
||||
throw InvalidRefreshTokenException()
|
||||
}
|
||||
if (existing.isExpired(clock.instant())) {
|
||||
throw InvalidRefreshTokenException()
|
||||
}
|
||||
|
||||
val newRawToken = generateOpaqueToken()
|
||||
val rotated = repository.save(
|
||||
RefreshTokenEntity(
|
||||
userId = existing.userId,
|
||||
tokenHash = sha256Hex(newRawToken),
|
||||
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 存不存在"的信号
|
||||
}
|
||||
|
||||
/** 强制下线(改密码、检测到异常登录时用)。 */
|
||||
@Transactional
|
||||
fun revokeAllByUser(userId: Long): Int = repository.revokeAllByUserId(userId, clock.instant())
|
||||
|
||||
/** 256 bit 随机 + URL-safe base64,够长到不可枚举。 */
|
||||
private fun generateOpaqueToken(): String {
|
||||
val bytes = ByteArray(32)
|
||||
random.nextBytes(bytes)
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
|
||||
}
|
||||
|
||||
private fun sha256Hex(raw: String): String =
|
||||
HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(raw.toByteArray()))
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
package com.continental.retailapp.identitystore.application
|
||||
|
||||
import com.continental.retailapp.identitystore.api.response.StoreContextResponse
|
||||
import com.continental.retailapp.identitystore.api.response.StoreResponse
|
||||
import com.continental.retailapp.identitystore.application.mapper.StoreMapper
|
||||
import com.continental.retailapp.identitystore.contract.StoreSwitchedEvent
|
||||
import com.continental.retailapp.identitystore.infrastructure.persistence.StoreRepository
|
||||
import com.continental.retailapp.identitystore.infrastructure.persistence.UserJpaRepository
|
||||
import com.continental.retailapp.platform.observability.audit.Audited
|
||||
import com.continental.retailapp.platform.security.AccessTokenIssuer
|
||||
import com.continental.retailapp.platform.security.StoreContextHolder
|
||||
import com.continental.retailapp.platform.web.BusinessException
|
||||
import com.continental.retailapp.platform.web.ErrorCode
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.time.Clock
|
||||
|
||||
/**
|
||||
* 门店列表与切店。
|
||||
*
|
||||
* 命名说明:11-cross-domain-collaboration.md 里这段逻辑叫 `StoreSwitchAppService`,
|
||||
* 04-security-auth.md 里叫 `StoreAppService`。骨架统一用后者——
|
||||
* "看得到哪些店"和"切到哪家店"本来就是一组用例,拆成两个 service 只会让上下文来回传。
|
||||
*/
|
||||
@Service
|
||||
class StoreAppService(
|
||||
private val storeRepository: StoreRepository,
|
||||
private val storeMapper: StoreMapper,
|
||||
private val userJpaRepository: UserJpaRepository,
|
||||
private val accessTokenIssuer: AccessTokenIssuer,
|
||||
private val storeContextHolder: StoreContextHolder,
|
||||
private val events: ApplicationEventPublisher,
|
||||
private val clock: Clock,
|
||||
) {
|
||||
@Transactional(readOnly = true)
|
||||
fun listAccessibleStores(): List<StoreResponse> =
|
||||
storeMapper.toResponseList(storeRepository.findStoresByUserId(storeContextHolder.currentUserId()))
|
||||
|
||||
@Audited(action = "SWITCH_STORE")
|
||||
@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.findRoleNames(userId, targetStoreId)
|
||||
?.split(',')?.map { it.trim() }?.filter { it.isNotEmpty() }
|
||||
.orEmpty()
|
||||
|
||||
val fromStoreId = storeContextHolder.storeId
|
||||
userJpaRepository.findById(userId).ifPresent { it.currentStoreId = targetStoreId }
|
||||
|
||||
// 2. 重新签发 access token —— 新 token 里的 storeId 是目标门店。
|
||||
// 这一步不能省:token 里的 storeId 决定了后续所有请求的数据范围,
|
||||
// 只改客户端本地状态不换 token,等于门店没真的切过去。
|
||||
// refresh token 不跟着换:它绑定的是身份,不是门店。
|
||||
val accessToken = accessTokenIssuer.issue(userId, targetStoreId, roles)
|
||||
|
||||
// 3. 切店会使旧门店下的 WebView 票据失效。走领域事件,
|
||||
// 不由 identity-store 直接改 webview-ticket 的表(11-cross-domain-collaboration.md)。
|
||||
events.publishEvent(StoreSwitchedEvent(userId, fromStoreId, targetStoreId, clock.instant()))
|
||||
|
||||
return StoreContextResponse(
|
||||
accessToken = accessToken,
|
||||
store = storeMapper.toResponse(store),
|
||||
roles = roles,
|
||||
menus = MenuCatalog.menusOf(roles),
|
||||
)
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package com.continental.retailapp.identitystore.application
|
||||
|
||||
import com.continental.retailapp.identitystore.contract.StoreInfo
|
||||
import com.continental.retailapp.identitystore.contract.StoreQueryService
|
||||
import com.continental.retailapp.identitystore.infrastructure.persistence.StoreRepository
|
||||
import org.springframework.cache.annotation.Cacheable
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
/**
|
||||
* 契约实现:别的 domain 想知道门店信息,只能通过这个接口,
|
||||
* 不能直接注入 identity-store 的 repository(11-cross-domain-collaboration.md)。
|
||||
*
|
||||
* 返回的是契约模型 [StoreInfo] 而不是 Entity/投影——契约模型是独立的数据结构,
|
||||
* 让它跟着 `StoreEntity` 一起长,等于把内部表结构变成了对外承诺。
|
||||
*/
|
||||
@Service
|
||||
class StoreQueryServiceImpl(
|
||||
private val storeRepository: StoreRepository,
|
||||
) : StoreQueryService {
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
override fun listStoresByUserId(userId: Long): List<StoreInfo> =
|
||||
storeRepository.findStoresByUserId(userId).map { StoreInfo(it.id, it.name, it.code) }
|
||||
|
||||
/**
|
||||
* 门店基础信息读多写少、变更不敏感,适合本地缓存(12-concurrency-and-scheduling.md)。
|
||||
*
|
||||
* 注意对比:**用户对门店的访问权限绝不能缓存**——权限收回必须立刻生效,
|
||||
* 所以 [listStoresByUserId] 和 `findAccessibleStore` 上都没有 `@Cacheable`。
|
||||
*/
|
||||
@Cacheable(cacheNames = ["storeBasicInfo"], key = "#storeId")
|
||||
@Transactional(readOnly = true)
|
||||
override fun findStore(storeId: Long): StoreInfo? =
|
||||
storeRepository.findStoreById(storeId)?.let { StoreInfo(it.id, it.name, it.code) }
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.continental.retailapp.identitystore.application.mapper
|
||||
|
||||
import com.continental.retailapp.identitystore.api.response.StoreResponse
|
||||
import com.continental.retailapp.identitystore.infrastructure.persistence.StoreView
|
||||
import org.mapstruct.Mapper
|
||||
|
||||
/**
|
||||
* 投影 → Response 的转换,编译期由 MapStruct 生成实现(06-api-design.md)。
|
||||
*
|
||||
* 放在 `application` 层而不是 `api/mapper/`:它的入参是 `infrastructure` 里的投影类型,
|
||||
* 放进 `api` 就等于让 `api` 依赖 `infrastructure`,会被 ArchUnit 判红。
|
||||
*/
|
||||
@Mapper(componentModel = "spring")
|
||||
interface StoreMapper {
|
||||
|
||||
fun toResponse(view: StoreView): StoreResponse
|
||||
|
||||
fun toResponseList(views: List<StoreView>): List<StoreResponse>
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
package com.continental.retailapp.identitystore.infrastructure.config
|
||||
|
||||
import com.continental.retailapp.identitystore.infrastructure.persistence.StoreEntity
|
||||
import com.continental.retailapp.identitystore.infrastructure.persistence.StoreJpaRepository
|
||||
import com.continental.retailapp.identitystore.infrastructure.persistence.UserEntity
|
||||
import com.continental.retailapp.identitystore.infrastructure.persistence.UserJpaRepository
|
||||
import com.continental.retailapp.identitystore.infrastructure.persistence.UserStoreEntity
|
||||
import com.continental.retailapp.identitystore.infrastructure.persistence.UserStoreJpaRepository
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.boot.ApplicationRunner
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.context.annotation.Profile
|
||||
import org.springframework.security.crypto.password.PasswordEncoder
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
/**
|
||||
* 本地开发用的种子数据:一个 demo 用户 + 两家门店。
|
||||
*
|
||||
* **刻意不写进 Flyway 脚本**:迁移脚本在 dev/uat/prod 也会跑,
|
||||
* 一个带已知密码的账号进到线上库就是一个后门。放在 `@Profile("local")` 的 runner 里,
|
||||
* 跑在别的 profile 上时它根本不会被实例化。
|
||||
*
|
||||
* 账号:`demo` / `demo1234`(仅 local)。
|
||||
*/
|
||||
@Configuration
|
||||
@Profile("local")
|
||||
class LocalSeedDataConfig {
|
||||
|
||||
@Bean
|
||||
fun localSeedDataRunner(seeder: LocalSeedDataSeeder) = ApplicationRunner { seeder.seed() }
|
||||
}
|
||||
|
||||
@Configuration
|
||||
@Profile("local")
|
||||
class LocalSeedDataSeeder(
|
||||
private val userJpaRepository: UserJpaRepository,
|
||||
private val storeJpaRepository: StoreJpaRepository,
|
||||
private val userStoreJpaRepository: UserStoreJpaRepository,
|
||||
private val passwordEncoder: PasswordEncoder,
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(javaClass)
|
||||
|
||||
@Transactional
|
||||
fun seed() {
|
||||
if (userJpaRepository.findByUsername(SEED_USERNAME) != null) {
|
||||
return
|
||||
}
|
||||
|
||||
val stores = storeJpaRepository.saveAll(
|
||||
listOf(
|
||||
StoreEntity(name = "上海徐汇店", code = "SH-XH-001"),
|
||||
StoreEntity(name = "上海浦东店", code = "SH-PD-002"),
|
||||
),
|
||||
)
|
||||
val user = userJpaRepository.save(
|
||||
UserEntity(
|
||||
username = SEED_USERNAME,
|
||||
passwordHash = requireNotNull(passwordEncoder.encode(SEED_PASSWORD)),
|
||||
displayName = "演示店长",
|
||||
currentStoreId = stores.first().id,
|
||||
),
|
||||
)
|
||||
userStoreJpaRepository.saveAll(
|
||||
listOf(
|
||||
UserStoreEntity(userId = user.id!!, storeId = stores[0].id!!, roles = "STORE_MANAGER"),
|
||||
UserStoreEntity(userId = user.id!!, storeId = stores[1].id!!, roles = "STAFF"),
|
||||
),
|
||||
)
|
||||
log.info("已写入本地种子数据:用户 {} / 门店 {} 家", SEED_USERNAME, stores.size)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SEED_USERNAME = "demo"
|
||||
const val SEED_PASSWORD = "demo1234"
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package com.continental.retailapp.identitystore.infrastructure.persistence
|
||||
|
||||
import com.continental.retailapp.platform.persistence.BaseEntity
|
||||
import jakarta.persistence.Column
|
||||
import jakarta.persistence.Entity
|
||||
import jakarta.persistence.GeneratedValue
|
||||
import jakarta.persistence.GenerationType
|
||||
import jakarta.persistence.Id
|
||||
import jakarta.persistence.Table
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* 刷新令牌,见 04-security-auth.md。
|
||||
*
|
||||
* 只存 SHA-256 十六进制摘要,**永远不存明文**:库被拖走也换不出 access token。
|
||||
* 摘要长度固定 64,所以列类型是 `char(64)` 而不是 varchar。
|
||||
*
|
||||
* `revokedAt` + `replacedByTokenId` 一起支撑轮换与重放检测:
|
||||
* 一条已经 revoke 的令牌再次被使用,说明它被人截获重放了,
|
||||
* 此时把该用户名下所有令牌全部作废(见 [RefreshTokenService.rotate])。
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "refresh_token", schema = "identity_store")
|
||||
class RefreshTokenEntity(
|
||||
@Column(name = "user_id", nullable = false)
|
||||
var userId: Long,
|
||||
|
||||
@Column(name = "token_hash", nullable = false, length = 64, unique = true)
|
||||
var tokenHash: String,
|
||||
|
||||
@Column(name = "expires_at", nullable = false)
|
||||
var expiresAt: Instant,
|
||||
|
||||
@Column(name = "revoked_at")
|
||||
var revokedAt: Instant? = null,
|
||||
|
||||
@Column(name = "replaced_by_token_id")
|
||||
var replacedByTokenId: Long? = null,
|
||||
) : BaseEntity() {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
var id: Long? = null
|
||||
|
||||
fun isExpired(now: Instant): Boolean = !expiresAt.isAfter(now)
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package com.continental.retailapp.identitystore.infrastructure.persistence
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.Modifying
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
import org.springframework.data.repository.query.Param
|
||||
import org.springframework.stereotype.Repository
|
||||
import java.time.Instant
|
||||
|
||||
@Repository
|
||||
interface RefreshTokenJpaRepository : JpaRepository<RefreshTokenEntity, Long> {
|
||||
|
||||
fun findByTokenHash(tokenHash: String): RefreshTokenEntity?
|
||||
|
||||
/**
|
||||
* 检测到重放时一次性作废该用户的所有令牌。
|
||||
*
|
||||
* 用批量 update 而不是"查出来逐条改":重放场景下要的是尽快关门,
|
||||
* 而且一个用户的令牌可能有几十条(多端登录 + 轮换历史)。
|
||||
*/
|
||||
@Modifying(clearAutomatically = true, flushAutomatically = true)
|
||||
@Query(
|
||||
"""
|
||||
update RefreshTokenEntity t set t.revokedAt = :now
|
||||
where t.userId = :userId and t.revokedAt is null
|
||||
""",
|
||||
)
|
||||
fun revokeAllByUserId(@Param("userId") userId: Long, @Param("now") now: Instant): Int
|
||||
|
||||
/** 清理任务用:过期且已经没有保留价值的令牌直接删(12-concurrency-and-scheduling.md)。 */
|
||||
@Modifying
|
||||
@Query("delete from RefreshTokenEntity t where t.expiresAt < :before")
|
||||
fun deleteExpiredBefore(@Param("before") before: Instant): Int
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.continental.retailapp.identitystore.infrastructure.persistence
|
||||
|
||||
import com.continental.retailapp.platform.persistence.VersionedEntity
|
||||
import jakarta.persistence.Column
|
||||
import jakarta.persistence.Entity
|
||||
import jakarta.persistence.EnumType
|
||||
import jakarta.persistence.Enumerated
|
||||
import jakarta.persistence.GeneratedValue
|
||||
import jakarta.persistence.GenerationType
|
||||
import jakarta.persistence.Id
|
||||
import jakarta.persistence.Table
|
||||
|
||||
enum class StoreStatus { ACTIVE, CLOSED }
|
||||
|
||||
/** 门店。 */
|
||||
@Entity
|
||||
@Table(name = "store", schema = "identity_store")
|
||||
class StoreEntity(
|
||||
@Column(name = "name", nullable = false, length = 128)
|
||||
var name: String,
|
||||
|
||||
@Column(name = "code", nullable = false, length = 32)
|
||||
var code: String,
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "status", nullable = false, length = 16)
|
||||
var status: StoreStatus = StoreStatus.ACTIVE,
|
||||
) : VersionedEntity() {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
var id: Long? = null
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package com.continental.retailapp.identitystore.infrastructure.persistence
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
import org.springframework.data.repository.query.Param
|
||||
import org.springframework.stereotype.Repository
|
||||
|
||||
@Repository
|
||||
interface StoreJpaRepository : JpaRepository<StoreEntity, Long>, StoreRepository {
|
||||
|
||||
@Query(
|
||||
"""
|
||||
select s.id as id, s.name as name, s.code as code
|
||||
from StoreEntity s
|
||||
join UserStoreEntity us on us.storeId = s.id
|
||||
where us.userId = :userId and s.status = com.continental.retailapp.identitystore.infrastructure.persistence.StoreStatus.ACTIVE
|
||||
order by s.id
|
||||
""",
|
||||
)
|
||||
override fun findStoresByUserId(@Param("userId") userId: Long): List<StoreView>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
select s.id as id, s.name as name, s.code as code
|
||||
from StoreEntity s
|
||||
join UserStoreEntity us on us.storeId = s.id
|
||||
where us.userId = :userId and s.id = :storeId
|
||||
and s.status = com.continental.retailapp.identitystore.infrastructure.persistence.StoreStatus.ACTIVE
|
||||
""",
|
||||
)
|
||||
override fun findAccessibleStore(@Param("userId") userId: Long, @Param("storeId") storeId: Long): StoreView?
|
||||
|
||||
@Query("select s.id as id, s.name as name, s.code as code from StoreEntity s where s.id = :storeId")
|
||||
override fun findStoreById(@Param("storeId") storeId: Long): StoreView?
|
||||
|
||||
@Query("select us.roles from UserStoreEntity us where us.userId = :userId and us.storeId = :storeId")
|
||||
override fun findRoleNames(@Param("userId") userId: Long, @Param("storeId") storeId: Long): String?
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.continental.retailapp.identitystore.infrastructure.persistence
|
||||
|
||||
/**
|
||||
* 门店查询口径,`application` 依赖的是这个接口而不是 [StoreJpaRepository](02-layering.md)。
|
||||
*
|
||||
* [findAccessibleStore] 把权限校验合进了查询条件——"查不到"就是"没权限",
|
||||
* 而不是先查出来再判断。后者很容易漏判,见 04-security-auth.md 的越权规则第 2 条。
|
||||
*/
|
||||
interface StoreRepository {
|
||||
|
||||
fun findStoresByUserId(userId: Long): List<StoreView>
|
||||
|
||||
/** 目标门店对该用户可访问才返回;不可访问返回 null。切店前必须过这一关。 */
|
||||
fun findAccessibleStore(userId: Long, storeId: Long): StoreView?
|
||||
|
||||
fun findStoreById(storeId: Long): StoreView?
|
||||
|
||||
/**
|
||||
* 返回逗号分隔的原始角色串,解析交给 `application`。
|
||||
*
|
||||
* 对 04 文档的小改名:文档里叫 `findRoles` 并直接返回 `List<String>`,
|
||||
* 但角色在库里就是一个逗号串,拆分是业务转换、不该藏在 repository 的方法名背后。
|
||||
*/
|
||||
fun findRoleNames(userId: Long, storeId: Long): String?
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package com.continental.retailapp.identitystore.infrastructure.persistence
|
||||
|
||||
/**
|
||||
* Spring Data 接口投影:只声明这次查询需要的字段,Hibernate 只 select 这几列。
|
||||
*
|
||||
* 用它而不是直接返回 [StoreEntity],是为了让 `application` 拿到的东西不带 Entity 的
|
||||
* 生命周期(游离态/懒加载)和无关字段——不需要额外写一个类,接口本身就是契约。
|
||||
* 见 02-layering.md。
|
||||
*/
|
||||
interface StoreView {
|
||||
val id: Long
|
||||
val name: String
|
||||
val code: String
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package com.continental.retailapp.identitystore.infrastructure.persistence
|
||||
|
||||
import com.continental.retailapp.platform.persistence.VersionedEntity
|
||||
import jakarta.persistence.Column
|
||||
import jakarta.persistence.Entity
|
||||
import jakarta.persistence.EnumType
|
||||
import jakarta.persistence.Enumerated
|
||||
import jakarta.persistence.GeneratedValue
|
||||
import jakarta.persistence.GenerationType
|
||||
import jakarta.persistence.Id
|
||||
import jakarta.persistence.Table
|
||||
import java.time.Instant
|
||||
|
||||
enum class UserStatus { ACTIVE, DISABLED }
|
||||
|
||||
/**
|
||||
* 用户,见 04-security-auth.md。
|
||||
*
|
||||
* 几个约定:
|
||||
* - 密码只存 BCrypt 哈希,字段名叫 `passwordHash` 而不是 `password`,减少误打日志的概率;
|
||||
* - `failedAttempts` / `lockedUntil` 实现"连续失败 5 次锁定 15 分钟";
|
||||
* - `currentStoreId` 记住用户上次选的门店,刷新 token 时用它重签,不至于一刷新就掉回默认门店。
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "user", schema = "identity_store")
|
||||
class UserEntity(
|
||||
@Column(name = "username", nullable = false, length = 64)
|
||||
var username: String,
|
||||
|
||||
@Column(name = "password_hash", nullable = false, length = 100)
|
||||
var passwordHash: String,
|
||||
|
||||
@Column(name = "display_name", nullable = false, length = 64)
|
||||
var displayName: String,
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "status", nullable = false, length = 16)
|
||||
var status: UserStatus = UserStatus.ACTIVE,
|
||||
|
||||
@Column(name = "current_store_id")
|
||||
var currentStoreId: Long? = null,
|
||||
|
||||
@Column(name = "failed_attempts", nullable = false)
|
||||
var failedAttempts: Int = 0,
|
||||
|
||||
@Column(name = "locked_until")
|
||||
var lockedUntil: Instant? = null,
|
||||
) : VersionedEntity() {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
var id: Long? = null
|
||||
|
||||
fun isLocked(now: Instant): Boolean = lockedUntil?.isAfter(now) == true
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package com.continental.retailapp.identitystore.infrastructure.persistence
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.stereotype.Repository
|
||||
|
||||
@Repository
|
||||
interface UserJpaRepository : JpaRepository<UserEntity, Long> {
|
||||
|
||||
fun findByUsername(username: String): UserEntity?
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.continental.retailapp.identitystore.infrastructure.persistence
|
||||
|
||||
import com.continental.retailapp.platform.persistence.BaseEntity
|
||||
import jakarta.persistence.Column
|
||||
import jakarta.persistence.Entity
|
||||
import jakarta.persistence.GeneratedValue
|
||||
import jakarta.persistence.GenerationType
|
||||
import jakarta.persistence.Id
|
||||
import jakarta.persistence.Table
|
||||
|
||||
/**
|
||||
* 用户 ↔ 门店的授权关系。这张表是"能不能看这家店"的唯一事实来源。
|
||||
*
|
||||
* 刻意用 `userId` / `storeId` 裸字段而不是 `@ManyToOne`:
|
||||
* 授权判定全是按 id 的存在性查询,建关联只会让 Hibernate 多拉两张表。
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "user_store", schema = "identity_store")
|
||||
class UserStoreEntity(
|
||||
@Column(name = "user_id", nullable = false)
|
||||
var userId: Long,
|
||||
|
||||
@Column(name = "store_id", nullable = false)
|
||||
var storeId: Long,
|
||||
|
||||
/**
|
||||
* 逗号分隔,写进 JWT 的 `roles` claim。
|
||||
* 角色挂在"用户 × 门店"上而不是用户上:同一个人在 A 店是店长、在 B 店可能只是接待。
|
||||
*/
|
||||
@Column(name = "roles", nullable = false, length = 255)
|
||||
var roles: String = "STAFF",
|
||||
) : BaseEntity() {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
var id: Long? = null
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package com.continental.retailapp.identitystore.infrastructure.persistence
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.stereotype.Repository
|
||||
|
||||
/**
|
||||
* `user_store` 在授权查询里只作为 join 出现,本身不需要仓储;
|
||||
* 建它是为了写入方(本地种子数据、后续的授权管理用例)有一个正常的入口。
|
||||
*/
|
||||
@Repository
|
||||
interface UserStoreJpaRepository : JpaRepository<UserStoreEntity, Long>
|
||||
@@ -0,0 +1,64 @@
|
||||
-- identity_store 库初始化:用户、门店、用户↔门店授权关系。
|
||||
--
|
||||
-- 约定(03-persistence.md):
|
||||
-- * 时间列一律 datetime(6),存 UTC;应用侧对应 Instant。
|
||||
-- * 每张表都带 BaseEntity 的四个审计列;需要乐观锁的再加 version。
|
||||
-- * 字符集 utf8mb4 + utf8mb4_0900_ai_ci,跟 MySQL 8.4 默认保持一致。
|
||||
--
|
||||
-- 种子数据不写在这里,见 infrastructure/config/LocalSeedDataRunner(只在 local profile 生效)。
|
||||
|
||||
create table `user`
|
||||
(
|
||||
id bigint not null auto_increment,
|
||||
username varchar(64) not null,
|
||||
password_hash varchar(100) not null comment 'BCrypt(strength=12),永远不存明文',
|
||||
display_name varchar(64) not null,
|
||||
status varchar(16) not null default 'ACTIVE',
|
||||
current_store_id bigint null comment '上次选中的门店,刷新 token 时据此重签',
|
||||
failed_attempts int not null default 0,
|
||||
locked_until datetime(6) null comment '连续失败 5 次后锁定 15 分钟',
|
||||
version bigint not null default 0,
|
||||
created_at datetime(6) not null,
|
||||
updated_at datetime(6) not null,
|
||||
created_by varchar(64) null,
|
||||
updated_by varchar(64) null,
|
||||
primary key (id),
|
||||
unique key uk_user_username (username)
|
||||
) engine = InnoDB
|
||||
default charset = utf8mb4
|
||||
collate = utf8mb4_0900_ai_ci comment '用户';
|
||||
|
||||
create table store
|
||||
(
|
||||
id bigint not null auto_increment,
|
||||
name varchar(128) not null,
|
||||
code varchar(32) not null,
|
||||
status varchar(16) not null default 'ACTIVE',
|
||||
version bigint not null default 0,
|
||||
created_at datetime(6) not null,
|
||||
updated_at datetime(6) not null,
|
||||
created_by varchar(64) null,
|
||||
updated_by varchar(64) null,
|
||||
primary key (id),
|
||||
unique key uk_store_code (code)
|
||||
) engine = InnoDB
|
||||
default charset = utf8mb4
|
||||
collate = utf8mb4_0900_ai_ci comment '门店';
|
||||
|
||||
create table user_store
|
||||
(
|
||||
id bigint not null auto_increment,
|
||||
user_id bigint not null,
|
||||
store_id bigint not null,
|
||||
roles varchar(255) not null default 'STAFF' comment '逗号分隔;角色是"用户×门店"维度的',
|
||||
created_at datetime(6) not null,
|
||||
updated_at datetime(6) not null,
|
||||
created_by varchar(64) null,
|
||||
updated_by varchar(64) null,
|
||||
primary key (id),
|
||||
-- 唯一约束而不是靠应用去查重:并发授权时数据库才是最后一道闸
|
||||
unique key uk_user_store (user_id, store_id),
|
||||
key idx_user_store_store_id (store_id)
|
||||
) engine = InnoDB
|
||||
default charset = utf8mb4
|
||||
collate = utf8mb4_0900_ai_ci comment '用户可访问的门店(授权的唯一事实来源)';
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
-- refresh token,DDL 逐字取自 04-security-auth.md。
|
||||
--
|
||||
-- token_hash 是 char(64) 而不是 varchar:SHA-256 十六进制摘要长度固定,
|
||||
-- 定长列在唯一索引上更省空间、比较也更快。库里没有明文,拖库换不出 access token。
|
||||
|
||||
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;
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package com.continental.retailapp.identitystore
|
||||
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||
|
||||
/**
|
||||
* `@DataJpaTest` 需要在包层级上能找到一个 `@SpringBootConfiguration`。
|
||||
* 真正的入口类在 `bootstrap` 模块,而 domain 模块不许反向依赖 bootstrap(01-project-structure.md),
|
||||
* 所以这里给测试单独放一个最小入口,只覆盖本模块的实体与仓储。
|
||||
*/
|
||||
@SpringBootApplication(scanBasePackages = ["com.continental.retailapp.identitystore"])
|
||||
class IdentityStoreTestApplication
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
package com.continental.retailapp.identitystore.application
|
||||
|
||||
import com.continental.retailapp.identitystore.fixtures.aJwtProperties
|
||||
import com.continental.retailapp.identitystore.fixtures.aRefreshTokenEntity
|
||||
import com.continental.retailapp.identitystore.infrastructure.persistence.RefreshTokenEntity
|
||||
import com.continental.retailapp.identitystore.infrastructure.persistence.RefreshTokenJpaRepository
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.slot
|
||||
import io.mockk.verify
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNotEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.security.MessageDigest
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
import java.util.HexFormat
|
||||
|
||||
/**
|
||||
* 04-security-auth.md 里 refresh token 的三条分支:正常轮换、重放检测、过期。
|
||||
*
|
||||
* 重放检测那条是这套设计的关键——"从来没见过的 token"和"已撤销的 token"
|
||||
* 必须走不同分支,合并成一个 `?: throw` 就等于没做重放检测。
|
||||
*/
|
||||
class RefreshTokenServiceTest {
|
||||
|
||||
private val now = Instant.parse("2026-01-01T00:00:00Z")
|
||||
private val clock = Clock.fixed(now, ZoneOffset.UTC)
|
||||
private val repository = mockk<RefreshTokenJpaRepository>()
|
||||
private val service = RefreshTokenService(repository, aJwtProperties(), clock)
|
||||
|
||||
private fun sha256Hex(raw: String): String =
|
||||
HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(raw.toByteArray()))
|
||||
|
||||
@Test
|
||||
fun `签发时库里只存摘要,明文只返回给调用方`() {
|
||||
val saved = slot<RefreshTokenEntity>()
|
||||
every { repository.save(capture(saved)) } answers { firstArg() }
|
||||
|
||||
val rawToken = service.issue(userId = 7L)
|
||||
|
||||
assertEquals(sha256Hex(rawToken), saved.captured.tokenHash)
|
||||
assertNotEquals(rawToken, saved.captured.tokenHash)
|
||||
assertEquals(7L, saved.captured.userId)
|
||||
// TTL 30 天,来自 JwtProperties.refreshTokenTtlDays
|
||||
assertEquals(now.plusSeconds(30 * 24 * 3600), saved.captured.expiresAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `轮换后旧令牌被撤销并指向新令牌`() {
|
||||
val rawToken = "old-raw-token"
|
||||
val existing = aRefreshTokenEntity(id = 1L, userId = 7L, tokenHash = sha256Hex(rawToken))
|
||||
every { repository.findByTokenHash(sha256Hex(rawToken)) } returns existing
|
||||
every { repository.save(any()) } answers { firstArg<RefreshTokenEntity>().also { it.id = 2L } }
|
||||
|
||||
val rotated = service.rotate(rawToken)
|
||||
|
||||
assertEquals(7L, rotated.userId)
|
||||
assertNotEquals(rawToken, rotated.refreshToken)
|
||||
assertEquals(now, existing.revokedAt)
|
||||
assertEquals(2L, existing.replacedByTokenId)
|
||||
verify(exactly = 0) { repository.revokeAllByUserId(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `从未存在过的令牌直接拒绝,不牵连该用户其他令牌`() {
|
||||
every { repository.findByTokenHash(any()) } returns null
|
||||
|
||||
assertThrows(InvalidRefreshTokenException::class.java) { service.rotate("never-existed") }
|
||||
|
||||
// 关键:随便伪造一个串就能把别人全部踢下线的话,这里就成了 DoS 入口
|
||||
verify(exactly = 0) { repository.revokeAllByUserId(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `重放已撤销的令牌会撤销该用户的全部令牌`() {
|
||||
val rawToken = "leaked-raw-token"
|
||||
val revoked = aRefreshTokenEntity(
|
||||
userId = 7L,
|
||||
tokenHash = sha256Hex(rawToken),
|
||||
revokedAt = now.minusSeconds(60),
|
||||
)
|
||||
every { repository.findByTokenHash(sha256Hex(rawToken)) } returns revoked
|
||||
every { repository.revokeAllByUserId(7L, now) } returns 3
|
||||
|
||||
assertThrows(InvalidRefreshTokenException::class.java) { service.rotate(rawToken) }
|
||||
|
||||
verify(exactly = 1) { repository.revokeAllByUserId(7L, now) }
|
||||
verify(exactly = 0) { repository.save(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `过期的令牌拒绝轮换`() {
|
||||
val rawToken = "expired-raw-token"
|
||||
val expired = aRefreshTokenEntity(
|
||||
userId = 7L,
|
||||
tokenHash = sha256Hex(rawToken),
|
||||
// 边界:expiresAt 恰好等于 now 就算过期
|
||||
expiresAt = now,
|
||||
)
|
||||
every { repository.findByTokenHash(sha256Hex(rawToken)) } returns expired
|
||||
|
||||
assertThrows(InvalidRefreshTokenException::class.java) { service.rotate(rawToken) }
|
||||
|
||||
verify(exactly = 0) { repository.save(any()) }
|
||||
// 过期不是安全事件,只是自然到期,不该连坐该用户其他令牌
|
||||
verify(exactly = 0) { repository.revokeAllByUserId(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `登出时撤销令牌`() {
|
||||
val rawToken = "active-raw-token"
|
||||
val existing = aRefreshTokenEntity(userId = 7L, tokenHash = sha256Hex(rawToken))
|
||||
every { repository.findByTokenHash(sha256Hex(rawToken)) } returns existing
|
||||
|
||||
service.revoke(rawToken)
|
||||
|
||||
assertEquals(now, existing.revokedAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `登出一个不存在的令牌不报错`() {
|
||||
every { repository.findByTokenHash(any()) } returns null
|
||||
|
||||
// 不抛异常本身就是断言:登出接口不能泄露"这个 token 存不存在"
|
||||
service.revoke("whatever")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `登出已撤销的令牌不会覆盖原撤销时间`() {
|
||||
val firstRevokedAt = now.minusSeconds(600)
|
||||
val existing = aRefreshTokenEntity(userId = 7L, revokedAt = firstRevokedAt)
|
||||
every { repository.findByTokenHash(any()) } returns existing
|
||||
|
||||
service.revoke("already-revoked")
|
||||
|
||||
assertEquals(firstRevokedAt, existing.revokedAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `签发的明文令牌足够长且不重复`() {
|
||||
every { repository.save(any()) } answers { firstArg() }
|
||||
|
||||
val tokens = (1..50).map { service.issue(userId = 7L) }
|
||||
|
||||
assertEquals(50, tokens.toSet().size)
|
||||
// 32 字节 URL-safe base64 去 padding = 43 个字符
|
||||
assertTrue(tokens.all { it.length == 43 }) { "令牌长度异常:${tokens.first().length}" }
|
||||
assertNull(tokens.firstOrNull { it.contains('+') || it.contains('/') })
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package com.continental.retailapp.identitystore.fixtures
|
||||
|
||||
import com.continental.retailapp.identitystore.infrastructure.persistence.RefreshTokenEntity
|
||||
import com.continental.retailapp.platform.security.JwtProperties
|
||||
import java.time.Instant
|
||||
import java.util.Base64
|
||||
|
||||
/** 32 字节,刚好过 [JwtProperties.validate] 的下限——测试里不要用更短的串。 */
|
||||
const val TEST_JWT_SECRET_RAW = "conti-test-secret-key-32bytes!!!"
|
||||
|
||||
fun testJwtSecretBase64(raw: String = TEST_JWT_SECRET_RAW): String =
|
||||
Base64.getEncoder().encodeToString(raw.toByteArray())
|
||||
|
||||
fun aJwtProperties(
|
||||
activeKeyId: String = "v1",
|
||||
keys: Map<String, String> = mapOf("v1" to testJwtSecretBase64()),
|
||||
accessTokenTtlMinutes: Long = 30,
|
||||
refreshTokenTtlDays: Long = 30,
|
||||
) = JwtProperties(
|
||||
activeKeyId = activeKeyId,
|
||||
keys = keys,
|
||||
accessTokenTtlMinutes = accessTokenTtlMinutes,
|
||||
refreshTokenTtlDays = refreshTokenTtlDays,
|
||||
)
|
||||
|
||||
fun aRefreshTokenEntity(
|
||||
id: Long? = 1L,
|
||||
userId: Long = 1L,
|
||||
tokenHash: String = "0".repeat(64),
|
||||
expiresAt: Instant = Instant.parse("2026-02-01T00:00:00Z"),
|
||||
revokedAt: Instant? = null,
|
||||
replacedByTokenId: Long? = null,
|
||||
) = RefreshTokenEntity(
|
||||
userId = userId,
|
||||
tokenHash = tokenHash,
|
||||
expiresAt = expiresAt,
|
||||
revokedAt = revokedAt,
|
||||
replacedByTokenId = replacedByTokenId,
|
||||
).also { it.id = id }
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
package com.continental.retailapp.identitystore.infrastructure.persistence
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Tag
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest
|
||||
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase
|
||||
import org.springframework.boot.test.context.TestConfiguration
|
||||
import org.springframework.boot.testcontainers.service.connection.ServiceConnection
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.test.context.TestPropertySource
|
||||
import org.testcontainers.mysql.MySQLContainer
|
||||
|
||||
/**
|
||||
* 仓储层的集成测试,见 10-testing.md:
|
||||
* **仓储不 mock**。JPQL 写错、`@Query` 投影字段对不上、跨表 join 的门店过滤漏掉,
|
||||
* 这些全都只有真数据库能发现,H2 的方言差异反而会给出假的绿灯。
|
||||
*
|
||||
* 打了 `@Tag("integration")`:本地快速验证用 `./gradlew build -PexcludeIntegration` 跳过,
|
||||
* CI 上跑完整 `./gradlew test`(Runner 需要能起 Docker)。
|
||||
*/
|
||||
@Tag("integration")
|
||||
@DataJpaTest
|
||||
// 必须显式关掉"用内存库替换数据源",否则 Boot 会把 Testcontainers 顶掉,测试悄悄跑在 H2 上
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
@TestPropertySource(
|
||||
properties = [
|
||||
// 表结构由 Flyway 建,和 dev/uat/prod 完全同一套脚本;ddl-auto 只做校验。
|
||||
// 这样"实体和迁移脚本对不上"会在测试里就暴露,而不是等到部署时 validate 失败。
|
||||
"spring.flyway.enabled=true",
|
||||
"spring.flyway.locations=classpath:db/migration/identity_store",
|
||||
"spring.flyway.schemas=identity_store",
|
||||
"spring.jpa.hibernate.ddl-auto=validate",
|
||||
"spring.jpa.properties.hibernate.jdbc.time_zone=UTC",
|
||||
],
|
||||
)
|
||||
class StoreJpaRepositoryTest {
|
||||
|
||||
@Autowired
|
||||
private lateinit var storeJpaRepository: StoreJpaRepository
|
||||
|
||||
@Autowired
|
||||
private lateinit var userStoreJpaRepository: UserStoreJpaRepository
|
||||
|
||||
private var activeStoreId: Long = 0
|
||||
private var otherStoreId: Long = 0
|
||||
private var closedStoreId: Long = 0
|
||||
|
||||
@BeforeEach
|
||||
fun seed() {
|
||||
activeStoreId = storeJpaRepository.save(
|
||||
StoreEntity(name = "静安门店", code = "SH-JA-001", status = StoreStatus.ACTIVE),
|
||||
).id!!
|
||||
otherStoreId = storeJpaRepository.save(
|
||||
StoreEntity(name = "徐汇门店", code = "SH-XH-001", status = StoreStatus.ACTIVE),
|
||||
).id!!
|
||||
closedStoreId = storeJpaRepository.save(
|
||||
StoreEntity(name = "已关停门店", code = "SH-XX-999", status = StoreStatus.CLOSED),
|
||||
).id!!
|
||||
|
||||
userStoreJpaRepository.save(UserStoreEntity(userId = 1L, storeId = activeStoreId, roles = "STORE_MANAGER"))
|
||||
userStoreJpaRepository.save(UserStoreEntity(userId = 1L, storeId = closedStoreId, roles = "STAFF"))
|
||||
// 用户 2 只能看徐汇:越权查询的对照组
|
||||
userStoreJpaRepository.save(UserStoreEntity(userId = 2L, storeId = otherStoreId, roles = "STAFF"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `只返回该用户有权限且在营的门店`() {
|
||||
val stores = storeJpaRepository.findStoresByUserId(1L)
|
||||
|
||||
assertEquals(listOf(activeStoreId), stores.map { it.id })
|
||||
assertEquals("静安门店", stores.single().name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `没有任何授权的用户拿到空列表`() {
|
||||
assertEquals(emptyList<Long>(), storeJpaRepository.findStoresByUserId(999L).map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `查询自己有权限的门店能查到`() {
|
||||
assertNotNull(storeJpaRepository.findAccessibleStore(userId = 1L, storeId = activeStoreId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `查询别人的门店查不到`() {
|
||||
// 切店接口的安全性完全押在这个查询上:查不到就返回 STORE_NOT_ACCESSIBLE
|
||||
assertNull(storeJpaRepository.findAccessibleStore(userId = 1L, storeId = otherStoreId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `已关停门店即使有授权也查不到`() {
|
||||
assertNull(storeJpaRepository.findAccessibleStore(userId = 1L, storeId = closedStoreId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `角色按用户与门店的组合返回`() {
|
||||
assertEquals("STORE_MANAGER", storeJpaRepository.findRoleNames(1L, activeStoreId))
|
||||
assertEquals("STAFF", storeJpaRepository.findRoleNames(1L, closedStoreId))
|
||||
// 同一个人在不同门店可以是不同角色,所以 roles 挂在 user_store 上而不是 user 上
|
||||
assertNull(storeJpaRepository.findRoleNames(1L, otherStoreId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `按 id 查门店不做权限过滤`() {
|
||||
// 这个方法给跨域契约 StoreQueryService 用,只查基础信息;
|
||||
// 权限过滤是调用方的责任,不能靠它兜底
|
||||
assertEquals("徐汇门店", storeJpaRepository.findStoreById(otherStoreId)?.name)
|
||||
}
|
||||
|
||||
@TestConfiguration
|
||||
class ContainerConfig {
|
||||
/** `@ServiceConnection` 自动把容器的 jdbc url / 账号密码接到 DataSource 上,不用手写 DynamicPropertySource。 */
|
||||
@Bean
|
||||
@ServiceConnection
|
||||
fun mysqlContainer(): MySQLContainer =
|
||||
MySQLContainer("mysql:8.4")
|
||||
// 库名要和实体上的 schema = "identity_store" 对上
|
||||
.withDatabaseName("identity_store")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user