优化
This commit is contained in:
+4
-2
@@ -18,7 +18,7 @@
|
||||
| 统一 JWT 签发与 JWKS | `config/AuthSecurityConfig.java`、`config/AuthSecurityProperties.java`、`service/AuthService.java`;Auth 是国内与海外仓共用的唯一用户 Token 签发方,JWKS 公钥发布按认证接口契约实现 |
|
||||
| 统一小 JWT 与会话 | `service/AuthService.java`、`service/TokenSessionStore.java`;浏览器登录 Token 只承载稳定身份、应用域和版本声明,各业务服务结合 Redis 会话校验 |
|
||||
| 统一 RS256 签发 | `config/UnifiedJwtConfiguration.java`、`config/UnifiedJwtProperties.java`;登录、刷新、代登录使用唯一 Access Token,`JWT_RSA_JWK_SET` 和 `JWT_CURRENT_KID` 仅配置在 Auth;不再提供海外 Token 兑换 |
|
||||
| 海外 PDA 实时会话校验 | `controller/InternalAccountController.java`、`service/OverseasSessionValidationService.java`;只接受海外 Gateway 已验签后提取的 userId/tokenVersion/overseasWarehouseId,以内部服务令牌保护并实时联查 IAM 当前仓绑定 |
|
||||
| 统一会话权威校验 | `controller/InternalAccountController.java`、`service/SessionStateService.java`;内部 `/internal/auth/accounts/sessions/validate` 接收 jwtId/userId,核对 Redis Access 与状态指纹,再查询当前 Auth/IAM;内部服务令牌保护,不接收或转发用户 JWT |
|
||||
| Token 会话与权限快照 | `service/TokenSessionStore.java`、`client/AuthorizationLoader.java`、`client/IamAuthorizationClient.java`;消费端映射见 `../common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java`、`JwtClaimsMapper.java` |
|
||||
| 注册协作 | `service/MerchantRegistrationService.java` |
|
||||
| IAM 授权客户端 | `client/IamAuthorizationClient.java`、`client/IamOnboardingClient.java` |
|
||||
@@ -28,7 +28,9 @@
|
||||
|
||||
## 修改约束
|
||||
|
||||
- 密码、Token、敏感 Claim 不得写日志;统一 JWT 使用 RS256,私钥只留在 Auth,JWKS 只发布公钥。统一 Token 不携带角色或权限集,权限来自共享 Redis 会话;用户、客户、权限和仓绑定变更必须撤销受影响会话。独立海外网关与安全投影仍处于迁移阶段,不能将本地签发改造视为整体已部署。修改登录响应、Claim、Audience、JWKS、权限快照或过期策略时联查 Admin/Client 统一认证层、`common-security/`、`gateway/`、`iam/` 和海外服务消费者。
|
||||
- 密码、Token、敏感 Claim 不得写日志;统一 JWT 使用 RS256,私钥只留在 Auth,JWKS 只发布公钥。统一 Token 不携带角色或权限集,权限来自共享 Redis 会话;用户、客户、权限和仓绑定变更必须撤销受影响会话。本地源码已采用统一权威状态校验并移除安全投影,不能将本地改造视为测试环境已部署。修改登录响应、Claim、Audience、JWKS、权限快照或过期策略时联查 Admin/Client 统一认证层、`common-security/`、`gateway/`、`iam/` 和海外服务消费者。
|
||||
- 登录、刷新和账号安全修改通过账号行锁串行化;IAM 身份、权限、客户版本、仓绑定由单条 SQL 快照组成指纹,Access/Refresh 均绑定指纹。Gateway 与业务服务逐请求在线核对,无正结果缓存;提交后开始的请求不得接受旧指纹,提交前已经完成认证的在途业务请求不追溯中断。撤销失败使变更失败或回滚,丢失撤销响应也不得恢复旧会话。
|
||||
- 校验链为 Gateway/业务服务 → Auth → IAM,不转发 Bearer、无递归。统一 `baiyue.security.session-validation-uri` 默认 `http://auth:9100/internal/auth/accounts/sessions/validate`;外层请求总超时 4 秒,Auth→IAM 连接 1 秒/读取 2 秒且不自动重试。Auth/IAM/Redis 任一不可用均失败关闭;真实吞吐与耗时仍需测试环境验证。
|
||||
- 注册跨 Auth/IAM 必须保持幂等和失败补偿;内部接口仅接受服务令牌。
|
||||
- 登录账号的 username、phone、email 唯一性只约束 Auth 账号;IAM 公司联系电话/联系邮箱是企业资料,不得反向覆盖账号登录标识,也不得因其他企业使用相同联系电话而拒绝。成员登录手机号变更只能由 IAM 账号协作入口同步并撤销该账号全部会话。
|
||||
- 业务错误保持 HTTP 200 + body code;Security 认证失败保留真实 401/403。
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
<dependency><groupId>com.mysql</groupId><artifactId>mysql-connector-j</artifactId><scope>runtime</scope></dependency>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
|
||||
<dependency><groupId>org.springframework.security</groupId><artifactId>spring-security-test</artifactId><scope>test</scope></dependency>
|
||||
<dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>test</scope></dependency>
|
||||
</dependencies>
|
||||
<build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
|
||||
</project>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.baiyue.auth.client;
|
||||
|
||||
import feign.Request;
|
||||
import feign.Retryer;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/** Only the IAM authorization client uses this bounded, single-attempt chain. */
|
||||
public class AuthorizationFeignConfiguration {
|
||||
@Bean public Request.Options authorizationRequestOptions() {
|
||||
return new Request.Options(1, TimeUnit.SECONDS, 2, TimeUnit.SECONDS, false);
|
||||
}
|
||||
@Bean public Retryer authorizationRetryer() { return Retryer.NEVER_RETRY; }
|
||||
}
|
||||
@@ -23,6 +23,14 @@ public class AuthorizationSnapshot {
|
||||
private Long permissionVersion;
|
||||
private Long overseasWarehouseId;
|
||||
private Long domesticWarehouseId;
|
||||
private Long tenantVersion;
|
||||
|
||||
public AuthorizationSnapshot(Long userId, Long tenantId, Long membershipId, String appCode,
|
||||
String clientType, Set<String> roles, Set<String> permissions, Long permissionVersion,
|
||||
Long overseasWarehouseId, Long domesticWarehouseId) {
|
||||
this(userId, tenantId, membershipId, appCode, clientType, roles, permissions,
|
||||
permissionVersion, overseasWarehouseId, domesticWarehouseId, 0L);
|
||||
}
|
||||
|
||||
public AuthorizationSnapshot(Long userId, Long tenantId, Long membershipId, String appCode,
|
||||
String clientType, Set<String> roles, Set<String> permissions,
|
||||
|
||||
@@ -9,7 +9,7 @@ import org.springframework.web.bind.annotation.RequestHeader;
|
||||
/**
|
||||
* 认证服务访问 IAM 内部授权接口的声明式客户端,用于加载用户在指定应用和租户下的权限快照。
|
||||
*/
|
||||
@FeignClient(name = "iam-service", contextId = "iamAuthorizationClient",
|
||||
@FeignClient(name = "iam-service", contextId = "iamAuthorizationClient", configuration = AuthorizationFeignConfiguration.class,
|
||||
url = "${baiyue.clients.iam.url:}", path = "/internal/iam")
|
||||
public interface IamAuthorizationClient {
|
||||
@PostMapping("/authorization-snapshot")
|
||||
|
||||
@@ -3,15 +3,12 @@ package com.baiyue.auth.controller;
|
||||
import com.baiyue.auth.dto.AccountManagementDtos.*;
|
||||
import com.baiyue.auth.service.AccountManagementService;
|
||||
import com.baiyue.auth.service.AuthService;
|
||||
import com.baiyue.auth.service.OverseasSessionValidationService;
|
||||
import com.baiyue.auth.dto.OverseasSessionValidationDtos;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
@@ -22,14 +19,13 @@ import java.security.MessageDigest;
|
||||
/** 仅供可信内部服务调用的账号管理接口。 */
|
||||
@RestController
|
||||
@RequestMapping("/internal/auth/accounts")
|
||||
@Tag(name = "Internal Auth / 内部认证", description = "Service-token protected internal account and overseas PDA session APIs")
|
||||
@Tag(name = "Internal Auth / 内部认证", description = "Service-token protected internal account and unified session APIs")
|
||||
public class InternalAccountController {
|
||||
@Resource
|
||||
private AccountManagementService service;
|
||||
@Resource
|
||||
private AuthService authService;
|
||||
@Resource
|
||||
private OverseasSessionValidationService overseasSessionValidationService;
|
||||
@Resource private com.baiyue.auth.service.SessionStateService sessionStateService;
|
||||
|
||||
@Value("${baiyue.security.internal-service-token}")
|
||||
private String configuredToken;
|
||||
@@ -71,15 +67,17 @@ public class InternalAccountController {
|
||||
@Valid @RequestBody RevokeAccountSessionsRequest request) {
|
||||
verify(token); service.revokeSessions(request.accountIds()); return ApiResponse.success(null);
|
||||
}
|
||||
@PostMapping("/overseas-session/validate")
|
||||
@Operation(summary = "Validate verified overseas PDA claims / 校验已验签的海外PDA声明",
|
||||
description = "Accepts only claims extracted after Gateway JWT verification; raw tokens and user credentials are forbidden.")
|
||||
public ApiResponse<OverseasSessionValidationDtos.Response> validateOverseasSession(
|
||||
public record SessionValidationRequest(@jakarta.validation.constraints.NotBlank String jwtId,
|
||||
@jakarta.validation.constraints.NotNull Long userId) { }
|
||||
|
||||
@PostMapping("/sessions/validate")
|
||||
public ApiResponse<Boolean> validateSession(
|
||||
@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
|
||||
@Valid @RequestBody OverseasSessionValidationDtos.Request request) {
|
||||
@Valid @RequestBody SessionValidationRequest request) {
|
||||
verify(token);
|
||||
return ApiResponse.success(overseasSessionValidationService.validate(request));
|
||||
return ApiResponse.success(sessionStateService.validate(request.jwtId(), request.userId()));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/profile")
|
||||
public ApiResponse<Void> profile(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
|
||||
@PathVariable Long id, @Valid @RequestBody UpdateAccountProfileRequest request) {
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
package com.baiyue.auth.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
/** 海外 PDA 已验签身份的实时会话校验契约;绝不接收 raw JWT 或用户凭据。 */
|
||||
public final class OverseasSessionValidationDtos {
|
||||
private OverseasSessionValidationDtos() { }
|
||||
|
||||
@Schema(name = "OverseasSessionValidationRequest",
|
||||
description = "Claims extracted only after overseas Gateway verifies the JWT signature")
|
||||
public record Request(
|
||||
@NotNull Long userId,
|
||||
@NotNull Integer tokenVersion,
|
||||
Long overseasWarehouseId) { }
|
||||
|
||||
@Schema(name = "OverseasSessionValidationResponse")
|
||||
public record Response(boolean valid, FailureReason failureReason) {
|
||||
public static Response accepted() { return new Response(true, null); }
|
||||
public static Response rejected(FailureReason reason) { return new Response(false, reason); }
|
||||
}
|
||||
|
||||
public enum FailureReason {
|
||||
ACCOUNT_INVALID,
|
||||
TOKEN_VERSION_MISMATCH,
|
||||
WAREHOUSE_UNBOUND,
|
||||
WAREHOUSE_MISMATCH
|
||||
}
|
||||
}
|
||||
@@ -19,13 +19,13 @@ public class AuthAccountRepository {
|
||||
public AuthAccount findByUsername(String username) {
|
||||
return mapper.selectOne(new LambdaQueryWrapper<AuthAccount>()
|
||||
.eq(AuthAccount::getUsername, username)
|
||||
.last("LIMIT 1"));
|
||||
.last("LIMIT 1 FOR UPDATE"));
|
||||
}
|
||||
|
||||
public AuthAccount findByPhone(String phone) {
|
||||
return mapper.selectOne(new LambdaQueryWrapper<AuthAccount>()
|
||||
.eq(AuthAccount::getPhone, phone)
|
||||
.last("LIMIT 1"));
|
||||
.last("LIMIT 1 FOR UPDATE"));
|
||||
}
|
||||
|
||||
public boolean existsByUsername(String username) {
|
||||
@@ -53,13 +53,15 @@ public class AuthAccountRepository {
|
||||
public void insert(AuthAccount account) { mapper.insert(account); }
|
||||
|
||||
public AuthAccount findById(Long id) {
|
||||
return mapper.selectById(id);
|
||||
return mapper.selectOne(new LambdaQueryWrapper<AuthAccount>().eq(AuthAccount::getId, id).last("FOR UPDATE"));
|
||||
}
|
||||
|
||||
public AuthAccount findCurrentById(Long id) { return mapper.selectById(id); }
|
||||
|
||||
public List<AuthAccount> findMerchantByTenant(Long tenantId) {
|
||||
return mapper.selectList(new LambdaQueryWrapper<AuthAccount>()
|
||||
.eq(AuthAccount::getDefaultTenantId, tenantId)
|
||||
.eq(AuthAccount::getDefaultAppCode, "MERCHANT"));
|
||||
.eq(AuthAccount::getDefaultAppCode, "MERCHANT").orderByAsc(AuthAccount::getId).last("FOR UPDATE"));
|
||||
}
|
||||
|
||||
public void save(AuthAccount account) {
|
||||
|
||||
@@ -108,7 +108,7 @@ public class AccountManagementService {
|
||||
resourceId = "'RBAC_CHANGE'", description = "RBAC 变更批量撤销账号会话",
|
||||
captureBefore = false, captureAfter = false)
|
||||
public void revokeSessions(Set<Long> accountIds) {
|
||||
accountIds.forEach(accountId -> bumpVersion(required(accountId)));
|
||||
accountIds.stream().sorted().forEach(accountId -> bumpVersion(required(accountId)));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
|
||||
@@ -92,7 +92,8 @@ public class AuthService {
|
||||
AuthAccount account = accountRepository.findById(session.userId());
|
||||
if (account == null || !"ACTIVE".equals(account.getAccountStatus()))
|
||||
throw new BizException(ResultCode.UNAUTHORIZED, "账号不可用");
|
||||
return issue(account);
|
||||
if (session.fingerprint() == null) throw new BizException(ResultCode.UNAUTHORIZED, "刷新会话缺少授权状态");
|
||||
return issue(account, session.fingerprint());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -109,8 +110,13 @@ public class AuthService {
|
||||
accountRepository.save(account);
|
||||
}
|
||||
|
||||
private TokenResponse issue(AuthAccount account) {
|
||||
private TokenResponse issue(AuthAccount account) { return issue(account, null); }
|
||||
|
||||
private TokenResponse issue(AuthAccount account, String expectedState) {
|
||||
AuthorizationSnapshot authorization = authorizationLoader.load(account);
|
||||
if (expectedState != null && !expectedState.equals(SessionStateService.fingerprint(account, authorization))) {
|
||||
throw new BizException(ResultCode.UNAUTHORIZED, "授权状态已变更,请重新登录");
|
||||
}
|
||||
Instant issuedAt = Instant.now();
|
||||
String jwtId = UUID.randomUUID().toString();
|
||||
Instant expiresAt = issuedAt.plus(properties.accessTokenTtl());
|
||||
@@ -145,7 +151,8 @@ public class AuthService {
|
||||
byte[] random = new byte[48];
|
||||
secureRandom.nextBytes(random);
|
||||
String rawRefresh = Base64.getUrlEncoder().withoutPadding().encodeToString(random);
|
||||
tokenSessionStore.store(jwtId, account.getId(), authorization.getRoles(), authorization.getPermissions(), rawRefresh,
|
||||
tokenSessionStore.store(jwtId, account.getId(), authorization.getRoles(), authorization.getPermissions(),
|
||||
SessionStateService.fingerprint(account, authorization), rawRefresh,
|
||||
properties.accessTokenTtl(), properties.refreshTokenTtl());
|
||||
return new TokenResponse("Bearer", accessToken, properties.accessTokenTtl().toSeconds(),
|
||||
rawRefresh, properties.refreshTokenTtl().toSeconds(),
|
||||
@@ -202,7 +209,7 @@ public class AuthService {
|
||||
.claim("password_change_required", Boolean.TRUE.equals(account.getPasswordChangeRequired())).build();
|
||||
String accessToken = jwtEncoder.encode(JwtEncoderParameters.from(signingHeader(), claims)).getTokenValue();
|
||||
tokenSessionStore.storeAccessOnly(jwtId, account.getId(), authorization.getRoles(),
|
||||
authorization.getPermissions(), Duration.ofMinutes(15));
|
||||
authorization.getPermissions(), SessionStateService.fingerprint(account, authorization), Duration.ofMinutes(15));
|
||||
return new ImpersonationTokenResponse("Bearer", accessToken, 900,
|
||||
authorization.getRoles(), authorization.getPermissions());
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
package com.baiyue.auth.service;
|
||||
|
||||
import com.baiyue.auth.client.AuthorizationLoader;
|
||||
import com.baiyue.auth.dto.OverseasSessionValidationDtos.FailureReason;
|
||||
import com.baiyue.auth.dto.OverseasSessionValidationDtos.Request;
|
||||
import com.baiyue.auth.dto.OverseasSessionValidationDtos.Response;
|
||||
import com.baiyue.auth.entity.AuthAccount;
|
||||
import com.baiyue.auth.repository.AuthAccountRepository;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/** 实时核对海外 PDA token 的账号版本与当前 IAM 仓绑定。 */
|
||||
@Service
|
||||
public class OverseasSessionValidationService {
|
||||
@Resource private AuthAccountRepository accounts;
|
||||
@Resource private AuthorizationLoader authorizationLoader;
|
||||
|
||||
public Response validate(Request request) {
|
||||
AuthAccount account = accounts.findById(request.userId());
|
||||
if (account == null || !"ACTIVE".equals(account.getAccountStatus())) {
|
||||
return Response.rejected(FailureReason.ACCOUNT_INVALID);
|
||||
}
|
||||
int currentVersion = account.getTokenVersion() == null ? 0 : account.getTokenVersion();
|
||||
if (currentVersion != request.tokenVersion()) {
|
||||
return Response.rejected(FailureReason.TOKEN_VERSION_MISMATCH);
|
||||
}
|
||||
Long currentWarehouseId = authorizationLoader.load(account).getOverseasWarehouseId();
|
||||
if (currentWarehouseId == null || request.overseasWarehouseId() == null) {
|
||||
return Response.rejected(FailureReason.WAREHOUSE_UNBOUND);
|
||||
}
|
||||
if (!Objects.equals(currentWarehouseId, request.overseasWarehouseId())) {
|
||||
return Response.rejected(FailureReason.WAREHOUSE_MISMATCH);
|
||||
}
|
||||
return Response.accepted();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.baiyue.auth.service;
|
||||
|
||||
import com.baiyue.auth.client.AuthorizationLoader;
|
||||
import com.baiyue.auth.client.AuthorizationSnapshot;
|
||||
import com.baiyue.auth.entity.AuthAccount;
|
||||
import com.baiyue.auth.repository.AuthAccountRepository;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.security.MessageDigest;
|
||||
import java.util.HexFormat;
|
||||
import java.util.TreeSet;
|
||||
|
||||
/** No cache: a validation started after a security commit must observe that commit. */
|
||||
@Service
|
||||
public class SessionStateService {
|
||||
private static final ObjectMapper FINGERPRINT_JSON = new ObjectMapper();
|
||||
@Resource private AuthAccountRepository accounts;
|
||||
@Resource private AuthorizationLoader authorizationLoader;
|
||||
@Resource private TokenSessionStore sessions;
|
||||
|
||||
public static String fingerprint(AuthAccount account, AuthorizationSnapshot snapshot) {
|
||||
try {
|
||||
if (account.getTokenVersion() == null || snapshot.getPermissionVersion() == null
|
||||
|| snapshot.getTenantVersion() == null) throw new IllegalArgumentException("Missing persisted security version");
|
||||
// Ordered JSON array avoids set ordering and delimiter ambiguities.
|
||||
Object[] state = {account.getId(), account.getTokenVersion(), account.getDefaultTenantId(),
|
||||
account.getDefaultMembershipId(), account.getDefaultAppCode(), account.getOperatorTenantId(),
|
||||
snapshot.getPermissionVersion(), snapshot.getTenantVersion(), snapshot.getClientType(),
|
||||
snapshot.getOverseasWarehouseId(), snapshot.getDomesticWarehouseId(),
|
||||
new TreeSet<>(snapshot.getRoles()), new TreeSet<>(snapshot.getPermissions())};
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest(FINGERPRINT_JSON.writeValueAsBytes(state)));
|
||||
} catch (Exception failure) {
|
||||
throw new IllegalStateException("Cannot fingerprint authorization state", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private void requireCurrent(Long userId, String expected) {
|
||||
AuthAccount account = accounts.findCurrentById(userId);
|
||||
if (account == null || !"ACTIVE".equals(account.getAccountStatus())
|
||||
|| Integer.valueOf(1).equals(account.getDeleted()) || expected == null
|
||||
|| !expected.equals(fingerprint(account, authorizationLoader.load(account)))) {
|
||||
throw new BizException(ResultCode.UNAUTHORIZED, "登录授权已变更,请重新登录");
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true, propagation = Propagation.REQUIRES_NEW,
|
||||
isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
|
||||
public boolean validate(String jwtId, Long userId) {
|
||||
TokenSessionStore.SessionState state = sessions.loadState(jwtId);
|
||||
if (state == null || !state.userId().equals(userId)) {
|
||||
throw new BizException(ResultCode.UNAUTHORIZED, "登录会话已失效");
|
||||
}
|
||||
requireCurrent(userId, state.fingerprint());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,6 @@ import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.data.redis.core.SetOperations;
|
||||
import org.springframework.data.redis.core.Cursor;
|
||||
import org.springframework.data.redis.core.ScanOptions;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
@@ -21,8 +19,6 @@ import java.security.MessageDigest;
|
||||
import java.time.Duration;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Set;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 认证会话存储服务,使用 Redis 保存和一次性消费刷新会话,支持刷新令牌轮换及主动撤销。
|
||||
@@ -42,7 +38,7 @@ public class TokenSessionStore {
|
||||
}
|
||||
|
||||
public void store(String jwtId, Long userId, Set<String> roles, Set<String> permissions,
|
||||
String rawRefreshToken, Duration accessTtl, Duration refreshTtl) {
|
||||
String fingerprint, String rawRefreshToken, Duration accessTtl, Duration refreshTtl) {
|
||||
String accessKey = RedisTokenKeys.access(keyPrefix, jwtId);
|
||||
String refreshKey = RedisTokenKeys.refresh(keyPrefix, sha256(rawRefreshToken));
|
||||
String userKey = userSessionsKey(userId);
|
||||
@@ -50,25 +46,27 @@ public class TokenSessionStore {
|
||||
String accessValue = objectMapper.writeValueAsString(
|
||||
new AccessSession(1, userId, normalized(roles), normalized(permissions)));
|
||||
redisTemplate.opsForValue().set(accessKey, accessValue, accessTtl);
|
||||
String refreshValue = objectMapper.writeValueAsString(new RefreshSession(userId, jwtId));
|
||||
storeState(jwtId, userId, fingerprint, accessTtl);
|
||||
String refreshValue = objectMapper.writeValueAsString(new RefreshSession(userId, jwtId, fingerprint));
|
||||
redisTemplate.opsForValue().set(refreshKey, refreshValue, refreshTtl);
|
||||
SetOperations<String, String> sets = redisTemplate.opsForSet();
|
||||
if (sets != null) sets.add(userKey, accessKey, refreshKey);
|
||||
if (sets != null) sets.add(userKey, accessKey, refreshKey, stateKey(jwtId));
|
||||
redisTemplate.expire(userKey, refreshTtl);
|
||||
} catch (Exception exception) {
|
||||
try { redisTemplate.delete(Set.of(accessKey, refreshKey, userKey)); } catch (DataAccessException ignored) { }
|
||||
try { redisTemplate.delete(Set.of(accessKey, refreshKey, stateKey(jwtId))); } catch (DataAccessException ignored) { }
|
||||
throw unavailable(exception);
|
||||
}
|
||||
}
|
||||
|
||||
public void storeAccessOnly(String jwtId, Long userId, Set<String> roles,
|
||||
Set<String> permissions, Duration ttl) {
|
||||
Set<String> permissions, String fingerprint, Duration ttl) {
|
||||
try {
|
||||
String accessKey = RedisTokenKeys.access(keyPrefix, jwtId);
|
||||
String accessValue = objectMapper.writeValueAsString(
|
||||
new AccessSession(1, userId, normalized(roles), normalized(permissions)));
|
||||
redisTemplate.opsForValue().set(accessKey, accessValue, ttl);
|
||||
redisTemplate.opsForSet().add(userSessionsKey(userId), accessKey);
|
||||
storeState(jwtId, userId, fingerprint, ttl);
|
||||
redisTemplate.opsForSet().add(userSessionsKey(userId), accessKey, stateKey(jwtId));
|
||||
redisTemplate.expire(userSessionsKey(userId), ttl);
|
||||
} catch (Exception exception) {
|
||||
throw unavailable(exception);
|
||||
@@ -103,6 +101,7 @@ public class TokenSessionStore {
|
||||
if (value == null || value.isBlank()) return null;
|
||||
RefreshSession session = objectMapper.readValue(value, RefreshSession.class);
|
||||
redisTemplate.delete(RedisTokenKeys.access(keyPrefix, session.accessJwtId()));
|
||||
redisTemplate.delete(stateKey(session.accessJwtId()));
|
||||
return session;
|
||||
} catch (DataAccessException exception) {
|
||||
throw unavailable(exception);
|
||||
@@ -121,43 +120,26 @@ public class TokenSessionStore {
|
||||
try {
|
||||
Set<String> keys = redisTemplate.opsForSet().members(userKey);
|
||||
if (keys != null && !keys.isEmpty()) redisTemplate.delete(keys);
|
||||
// 兼容功能上线前尚未写入用户会话索引的存量令牌,SCAN 不阻塞 Redis 主线程。
|
||||
List<String> legacyKeys = new ArrayList<>();
|
||||
collectLegacyKeys(keyPrefix + ":access:*", userId, legacyKeys, false);
|
||||
collectLegacyKeys(keyPrefix + ":refresh:*", userId, legacyKeys, true);
|
||||
if (!legacyKeys.isEmpty()) redisTemplate.delete(legacyKeys);
|
||||
redisTemplate.delete(userKey);
|
||||
} catch (DataAccessException exception) {
|
||||
throw unavailable(exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void collectLegacyKeys(String pattern, Long userId, List<String> result, boolean refresh) {
|
||||
Cursor<String> cursor = redisTemplate.scan(ScanOptions.scanOptions().match(pattern).count(500).build());
|
||||
if (cursor == null) return;
|
||||
try (cursor) {
|
||||
while (cursor.hasNext()) {
|
||||
String key = cursor.next();
|
||||
String value = redisTemplate.opsForValue().get(key);
|
||||
if ((!refresh && accessBelongsTo(value, userId)) || (refresh && belongsTo(value, userId))) {
|
||||
result.add(key);
|
||||
}
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
throw unavailable(exception);
|
||||
}
|
||||
private String stateKey(String jwtId) { return keyPrefix + ":state:" + jwtId; }
|
||||
|
||||
private void storeState(String jwtId, Long userId, String fingerprint, Duration ttl) throws Exception {
|
||||
if (fingerprint == null || fingerprint.isBlank()) throw new IllegalArgumentException("Missing security state");
|
||||
redisTemplate.opsForValue().set(stateKey(jwtId),
|
||||
objectMapper.writeValueAsString(new SessionState(userId, fingerprint)), ttl);
|
||||
}
|
||||
|
||||
private boolean belongsTo(String value, Long userId) {
|
||||
if (value == null || value.isBlank()) return false;
|
||||
try { return userId.equals(objectMapper.readValue(value, RefreshSession.class).userId()); }
|
||||
catch (Exception ignored) { return false; }
|
||||
}
|
||||
|
||||
private boolean accessBelongsTo(String value, Long userId) {
|
||||
if (value == null || value.isBlank()) return false;
|
||||
try { return userId.equals(objectMapper.readValue(value, AccessSession.class).userId()); }
|
||||
catch (Exception ignored) { return false; }
|
||||
public SessionState loadState(String jwtId) {
|
||||
try {
|
||||
if (!Boolean.TRUE.equals(redisTemplate.hasKey(RedisTokenKeys.access(keyPrefix, jwtId)))) return null;
|
||||
String value = redisTemplate.opsForValue().get(stateKey(jwtId));
|
||||
return value == null ? null : objectMapper.readValue(value, SessionState.class);
|
||||
} catch (Exception failure) { throw unavailable(failure); }
|
||||
}
|
||||
|
||||
private static Set<String> normalized(Set<String> values) {
|
||||
@@ -182,7 +164,8 @@ public class TokenSessionStore {
|
||||
}
|
||||
}
|
||||
|
||||
public record RefreshSession(Long userId, String accessJwtId) { }
|
||||
public record SessionState(Long userId, String fingerprint) { }
|
||||
public record RefreshSession(Long userId, String accessJwtId, String fingerprint) { }
|
||||
public record AccessSession(int version, Long userId, Set<String> roles, Set<String> permissions) {
|
||||
public AccessSession {
|
||||
roles = normalized(roles);
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
spring:
|
||||
cloud:
|
||||
nacos:
|
||||
discovery:
|
||||
ip: ${NACOS_DISCOVERY_IP:127.0.0.1}
|
||||
datasource:
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
url: ${AUTH_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_auth?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
|
||||
username: ${AUTH_DB_USERNAME:root}
|
||||
password: ${AUTH_DB_PASSWORD:123456}
|
||||
@@ -1,15 +0,0 @@
|
||||
spring:
|
||||
cloud:
|
||||
nacos:
|
||||
config:
|
||||
namespace: ${NACOS_NAMESPACE:test}
|
||||
group: ${NACOS_GROUP:BAIYUE}
|
||||
encode: UTF-8
|
||||
discovery:
|
||||
namespace: ${NACOS_NAMESPACE:test}
|
||||
group: ${NACOS_GROUP:BAIYUE}
|
||||
datasource:
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
url: ${AUTH_TEST_DB_URL:jdbc:mysql://${TEST_DB_HOST:8.163.30.128}:${TEST_DB_PORT:3306}/baiyue_auth?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true}
|
||||
username: ${AUTH_TEST_DB_USERNAME:${TEST_DB_USERNAME:root}}
|
||||
password: ${AUTH_TEST_DB_PASSWORD:${TEST_DB_PASSWORD}}
|
||||
@@ -2,35 +2,35 @@ server:
|
||||
port: ${AUTH_PORT:9100}
|
||||
spring:
|
||||
profiles:
|
||||
active: ${SPRING_PROFILES_ACTIVE:dev}
|
||||
active: ${SPRING_PROFILES_ACTIVE:test}
|
||||
application:
|
||||
name: auth-service
|
||||
config:
|
||||
import:
|
||||
- optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true
|
||||
- optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true
|
||||
- nacos:baiyue-common.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true
|
||||
- nacos:${spring.application.name}.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true
|
||||
data:
|
||||
redis:
|
||||
host: ${REDIS_HOST:127.0.0.1}
|
||||
host: ${REDIS_HOST}
|
||||
port: ${REDIS_PORT:6379}
|
||||
password: ${REDIS_PASSWORD:}
|
||||
database: ${REDIS_DATABASE:0}
|
||||
timeout: ${REDIS_TIMEOUT:3s}
|
||||
cloud:
|
||||
nacos:
|
||||
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
|
||||
username: ${NACOS_USERNAME:nacos}
|
||||
password: ${NACOS_PASSWORD:nacos}
|
||||
server-addr: ${NACOS_SERVER_ADDR}
|
||||
username: ${NACOS_USERNAME}
|
||||
password: ${NACOS_PASSWORD}
|
||||
discovery:
|
||||
enabled: ${NACOS_DISCOVERY_ENABLED:true}
|
||||
register-enabled: ${NACOS_REGISTER_ENABLED:true}
|
||||
group: ${NACOS_GROUP:BAIYUE}
|
||||
namespace: ${NACOS_NAMESPACE:public}
|
||||
namespace: ${NACOS_NAMESPACE}
|
||||
config:
|
||||
namespace: ${NACOS_NAMESPACE:public}
|
||||
namespace: ${NACOS_NAMESPACE}
|
||||
encode: UTF-8
|
||||
baiyue.security:
|
||||
internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long}
|
||||
internal-service-token: ${INTERNAL_SERVICE_TOKEN}
|
||||
token-key-prefix: ${REDIS_TOKEN_KEY_PREFIX:baiyue:auth}
|
||||
issuer: ${JWT_ISSUER:auth-service}
|
||||
audience: ${JWT_AUDIENCE:lastleg-api}
|
||||
@@ -40,7 +40,7 @@ baiyue.security:
|
||||
lock-duration: ${LOGIN_LOCK_DURATION:15m}
|
||||
jwt-mode: jwks
|
||||
session-mode: redis
|
||||
jwk-set-uri: ${JWT_JWKS_URI:http://auth-service:9100/api/auth/.well-known/jwks.json}
|
||||
jwk-set-uri: ${JWT_JWKS_URI:http://auth:9100/api/auth/.well-known/jwks.json}
|
||||
jwt:
|
||||
rsa-jwk-set: ${JWT_RSA_JWK_SET:}
|
||||
current-kid: ${JWT_CURRENT_KID:}
|
||||
@@ -50,3 +50,5 @@ baiyue.clients:
|
||||
url: ${IAM_SERVICE_URL:}
|
||||
|
||||
management.endpoints.web.exposure.include: health,info,prometheus
|
||||
|
||||
baiyue.security.session-validation-uri: ${AUTH_SESSION_VALIDATION_URL:http://auth:9100/internal/auth/accounts/sessions/validate}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Auth internal session validation
|
||||
version: 1.0.0
|
||||
paths:
|
||||
/internal/auth/accounts/sessions/validate:
|
||||
post:
|
||||
summary: Validate persisted account and IAM authorization state
|
||||
description: Internal network only. No Gateway public route. Verifies the Redis Access session and state fingerprint against current Auth account and a consistent IAM snapshot. No positive cache; missing state, stale state and dependency failure deny access. Does not accept or forward a user Bearer token.
|
||||
security:
|
||||
- internalServiceToken: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [jwtId, userId]
|
||||
properties:
|
||||
jwtId: {type: string, minLength: 1}
|
||||
userId: {type: integer, format: int64}
|
||||
responses:
|
||||
'200':
|
||||
description: Only string code 200 and boolean data true constitute success. Business errors deny access.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [code]
|
||||
properties:
|
||||
code: {type: string}
|
||||
message: {type: string}
|
||||
data: {type: boolean, nullable: true}
|
||||
components:
|
||||
securitySchemes:
|
||||
internalServiceToken:
|
||||
type: apiKey
|
||||
in: header
|
||||
name: X-Internal-Service-Token
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
package com.baiyue.auth.controller;
|
||||
|
||||
import com.baiyue.auth.dto.OverseasSessionValidationDtos;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class OverseasSessionValidationControllerContractTest {
|
||||
@Test
|
||||
void internalEndpointAcceptsOnlyVerifiedClaimValuesAndIsDocumented() {
|
||||
var method = Arrays.stream(InternalAccountController.class.getDeclaredMethods())
|
||||
.filter(candidate -> candidate.getName().equals("validateOverseasSession"))
|
||||
.findFirst().orElseThrow();
|
||||
|
||||
assertThat(method.getAnnotation(PostMapping.class).value())
|
||||
.containsExactly("/overseas-session/validate");
|
||||
assertThat(method.getAnnotation(Operation.class).description())
|
||||
.contains("Gateway JWT verification", "raw tokens and user credentials are forbidden");
|
||||
assertThat(OverseasSessionValidationDtos.Request.class.getRecordComponents())
|
||||
.extracting(java.lang.reflect.RecordComponent::getName)
|
||||
.containsExactly("userId", "tokenVersion", "overseasWarehouseId")
|
||||
.doesNotContain("token", "accessToken", "authorization", "password");
|
||||
}
|
||||
}
|
||||
@@ -93,7 +93,7 @@ class AuthServiceSmallJwtTest {
|
||||
assertThat(jwt.getValue().getJwsHeader().getKeyId()).isEqualTo("current");
|
||||
verify(tokenSessionStore).store(any(), org.mockito.ArgumentMatchers.eq(42L),
|
||||
org.mockito.ArgumentMatchers.eq(Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR")),
|
||||
org.mockito.ArgumentMatchers.eq(Set.of("platform:order:list")), any(),
|
||||
org.mockito.ArgumentMatchers.eq(Set.of("platform:order:list")), anyString(), any(),
|
||||
org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)),
|
||||
org.mockito.ArgumentMatchers.eq(Duration.ofDays(7)));
|
||||
assertThat(response.roles()).containsExactly("OVERSEAS_WAREHOUSE_PDA_OPERATOR");
|
||||
@@ -156,7 +156,7 @@ class AuthServiceSmallJwtTest {
|
||||
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
|
||||
|
||||
when(tokenSessionStore.consume("refresh-old")).thenReturn(
|
||||
new TokenSessionStore.RefreshSession(52L, "refresh-session"));
|
||||
new TokenSessionStore.RefreshSession(52L, "refresh-session", SessionStateService.fingerprint(account, snapshot)));
|
||||
when(repository.findById(52L)).thenReturn(account);
|
||||
when(authorizationLoader.load(account)).thenReturn(snapshot);
|
||||
when(jwtEncoder.encode(any())).thenReturn(encoded);
|
||||
@@ -175,7 +175,7 @@ class AuthServiceSmallJwtTest {
|
||||
verify(tokenSessionStore).store(anyString(), org.mockito.ArgumentMatchers.eq(52L),
|
||||
org.mockito.ArgumentMatchers.eq(Set.of("MERCHANT_ADMIN")),
|
||||
org.mockito.ArgumentMatchers.eq(Set.of("merchant:order:list")),
|
||||
org.mockito.ArgumentMatchers.eq(response.refreshToken()),
|
||||
anyString(), org.mockito.ArgumentMatchers.eq(response.refreshToken()),
|
||||
org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)),
|
||||
org.mockito.ArgumentMatchers.eq(Duration.ofDays(7)));
|
||||
verify(authorizationLoader).load(account);
|
||||
@@ -231,6 +231,6 @@ class AuthServiceSmallJwtTest {
|
||||
verify(tokenSessionStore).storeAccessOnly(any(), org.mockito.ArgumentMatchers.eq(43L),
|
||||
org.mockito.ArgumentMatchers.eq(Set.of("MERCHANT_OWNER")),
|
||||
org.mockito.ArgumentMatchers.eq(Set.of("merchant:order:list")),
|
||||
org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(15)));
|
||||
anyString(), org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(15)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
package com.baiyue.auth.service;
|
||||
|
||||
import com.baiyue.auth.client.AuthorizationLoader;
|
||||
import com.baiyue.auth.client.AuthorizationSnapshot;
|
||||
import com.baiyue.auth.dto.OverseasSessionValidationDtos.FailureReason;
|
||||
import com.baiyue.auth.dto.OverseasSessionValidationDtos.Request;
|
||||
import com.baiyue.auth.entity.AuthAccount;
|
||||
import com.baiyue.auth.repository.AuthAccountRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class OverseasSessionValidationServiceTest {
|
||||
private final AuthAccountRepository accounts = mock(AuthAccountRepository.class);
|
||||
private final AuthorizationLoader authorizationLoader = mock(AuthorizationLoader.class);
|
||||
private final OverseasSessionValidationService service = new OverseasSessionValidationService();
|
||||
private AuthAccount account;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
inject("accounts", accounts);
|
||||
inject("authorizationLoader", authorizationLoader);
|
||||
account = new AuthAccount();
|
||||
account.setId(42L);
|
||||
account.setAccountStatus("ACTIVE");
|
||||
account.setTokenVersion(8);
|
||||
when(accounts.findById(42L)).thenReturn(account);
|
||||
}
|
||||
|
||||
@Test
|
||||
void oldTokenIsRejectedAfterWarehouseChangeBumpsAccountVersion() {
|
||||
assertThat(service.validate(new Request(42L, 7, 900L)))
|
||||
.extracting("valid", "failureReason")
|
||||
.containsExactly(false, FailureReason.TOKEN_VERSION_MISMATCH);
|
||||
}
|
||||
|
||||
@Test
|
||||
void newlyIssuedTokenWithCurrentWarehousePasses() {
|
||||
when(authorizationLoader.load(account)).thenReturn(snapshot(8L, 901L));
|
||||
|
||||
assertThat(service.validate(new Request(42L, 8, 901L)).valid()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingOrChangedWarehouseBindingFailsClosed() {
|
||||
when(authorizationLoader.load(account)).thenReturn(snapshot(8L, null));
|
||||
assertThat(service.validate(new Request(42L, 8, null)).failureReason())
|
||||
.isEqualTo(FailureReason.WAREHOUSE_UNBOUND);
|
||||
|
||||
when(authorizationLoader.load(account)).thenReturn(snapshot(8L, 901L));
|
||||
assertThat(service.validate(new Request(42L, 8, 900L)).failureReason())
|
||||
.isEqualTo(FailureReason.WAREHOUSE_MISMATCH);
|
||||
}
|
||||
|
||||
@Test
|
||||
void disabledAccountFailsClosedBeforeIamLookup() {
|
||||
account.setAccountStatus("DISABLED");
|
||||
assertThat(service.validate(new Request(42L, 8, 901L)).failureReason())
|
||||
.isEqualTo(FailureReason.ACCOUNT_INVALID);
|
||||
}
|
||||
|
||||
private static AuthorizationSnapshot snapshot(Long version, Long warehouseId) {
|
||||
return new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", "PDA",
|
||||
Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of("lastleg:pda:operate"), version, warehouseId);
|
||||
}
|
||||
|
||||
private void inject(String fieldName, Object value) throws Exception {
|
||||
var field = OverseasSessionValidationService.class.getDeclaredField(fieldName);
|
||||
field.setAccessible(true);
|
||||
field.set(service, value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package com.baiyue.auth.service;
|
||||
|
||||
import com.baiyue.auth.client.AuthorizationLoader;
|
||||
import com.baiyue.auth.client.AuthorizationSnapshot;
|
||||
import com.baiyue.auth.config.AuthSecurityProperties;
|
||||
import com.baiyue.auth.config.UnifiedJwtProperties;
|
||||
import com.baiyue.auth.dto.AccountManagementDtos.ResetPasswordRequest;
|
||||
import com.baiyue.auth.dto.LoginRequest;
|
||||
import com.baiyue.auth.entity.AuthAccount;
|
||||
import com.baiyue.auth.repository.AuthAccountRepository;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.h2.jdbcx.JdbcDataSource;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.aop.framework.ProxyFactory;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.data.redis.core.ValueOperations;
|
||||
import org.springframework.data.redis.core.SetOperations;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.jwt.JwtEncoder;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
|
||||
import org.springframework.transaction.interceptor.TransactionInterceptor;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/** Real MyBatis repository, JDBC transactions/row locks and production login/refresh/revocation services.
|
||||
* Redis is an atomic in-memory transport double; this is not a Redis/MySQL environment test. */
|
||||
class SessionStateConcurrencyTest {
|
||||
JdbcTemplate authDb, iamDb;
|
||||
DataSourceTransactionManager authTx, iamTx;
|
||||
AuthService auth;
|
||||
AccountManagementService accounts;
|
||||
SessionStateService states;
|
||||
TokenSessionStore sessions;
|
||||
JwtEncoder encoder;
|
||||
AtomicBoolean redisFailure;
|
||||
final ConcurrentMap<String, String> values = new ConcurrentHashMap<>();
|
||||
final ConcurrentMap<String, Set<String>> sets = new ConcurrentHashMap<>();
|
||||
|
||||
@BeforeEach void setup() throws Exception {
|
||||
authDb = database(); iamDb = database();
|
||||
authTx = new DataSourceTransactionManager(authDb.getDataSource());
|
||||
iamTx = new DataSourceTransactionManager(iamDb.getDataSource());
|
||||
authDb.execute("CREATE TABLE auth_account(id BIGINT PRIMARY KEY, username VARCHAR(50),phone VARCHAR(50),"
|
||||
+ "password_hash VARCHAR(100),display_name VARCHAR(50),account_status VARCHAR(20),failed_login_count INT,"
|
||||
+ "locked_until TIMESTAMP,token_version INT,password_change_required BOOLEAN,default_tenant_id BIGINT,"
|
||||
+ "operator_tenant_id BIGINT,default_membership_id BIGINT,default_app_code VARCHAR(20),deleted INT,"
|
||||
+ "last_login_at TIMESTAMP,created_at TIMESTAMP,updated_at TIMESTAMP)");
|
||||
authDb.update("INSERT INTO auth_account(id,username,password_hash,account_status,token_version,default_tenant_id,"
|
||||
+ "operator_tenant_id,default_membership_id,default_app_code,deleted) VALUES(1,'operator','old-password','ACTIVE',1,7,7,9,'MERCHANT',0)");
|
||||
iamDb.execute("CREATE TABLE authorization_state(id BIGINT PRIMARY KEY, version BIGINT, tenant_version BIGINT, permission VARCHAR(50), warehouse BIGINT, active BOOLEAN)");
|
||||
iamDb.update("INSERT INTO authorization_state VALUES (1,1,1,'write',10,true)");
|
||||
|
||||
var factory = new com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean();
|
||||
factory.setDataSource(authDb.getDataSource());
|
||||
var config = new com.baomidou.mybatisplus.core.MybatisConfiguration();
|
||||
config.setMapUnderscoreToCamelCase(true);
|
||||
config.addMapper(com.baiyue.auth.mapper.AuthAccountMapper.class);
|
||||
factory.setConfiguration(config);
|
||||
var mapper = new org.mybatis.spring.SqlSessionTemplate(factory.getObject())
|
||||
.getMapper(com.baiyue.auth.mapper.AuthAccountMapper.class);
|
||||
var repository = new AuthAccountRepository();
|
||||
ReflectionTestUtils.setField(repository, "mapper", mapper);
|
||||
AuthorizationLoader loader = mock(AuthorizationLoader.class);
|
||||
when(loader.load(any())).thenAnswer(call -> {
|
||||
var tx = new TransactionTemplate(iamTx);
|
||||
tx.setIsolationLevel(org.springframework.transaction.TransactionDefinition.ISOLATION_REPEATABLE_READ);
|
||||
return tx.execute(status -> iamDb.queryForObject("SELECT * FROM authorization_state WHERE id=1", (rs, row) -> {
|
||||
if (!rs.getBoolean("active")) throw new IllegalArgumentException("IAM identity disabled");
|
||||
return new AuthorizationSnapshot(1L, 7L, 9L, "MERCHANT", "MERCHANT", Set.of("OPERATOR"),
|
||||
Set.of(rs.getString("permission")), rs.getLong("version"), rs.getLong("warehouse"), null,
|
||||
rs.getLong("tenant_version"));
|
||||
}));
|
||||
});
|
||||
sessions = new TokenSessionStore();
|
||||
ReflectionTestUtils.setField(sessions, "objectMapper", new ObjectMapper());
|
||||
ReflectionTestUtils.setField(sessions, "keyPrefix", "test");
|
||||
ReflectionTestUtils.setField(sessions, "redisTemplate", redis());
|
||||
PasswordEncoder passwords = mock(PasswordEncoder.class);
|
||||
when(passwords.matches(anyString(), anyString())).thenAnswer(c -> c.getArgument(0).equals(c.getArgument(1)));
|
||||
when(passwords.encode(anyString())).thenAnswer(c -> c.getArgument(0));
|
||||
encoder = mock(JwtEncoder.class);
|
||||
when(encoder.encode(any())).thenAnswer(c -> {
|
||||
var claims = ((org.springframework.security.oauth2.jwt.JwtEncoderParameters)c.getArgument(0)).getClaims();
|
||||
return Jwt.withTokenValue(claims.getId()).header("alg", "RS256").claims(m -> m.putAll(claims.getClaims())).build();
|
||||
});
|
||||
var service = new AuthService();
|
||||
ReflectionTestUtils.setField(service, "accountRepository", repository);
|
||||
ReflectionTestUtils.setField(service, "authorizationLoader", loader);
|
||||
ReflectionTestUtils.setField(service, "tokenSessionStore", sessions);
|
||||
ReflectionTestUtils.setField(service, "passwordEncoder", passwords);
|
||||
ReflectionTestUtils.setField(service, "jwtEncoder", encoder);
|
||||
ReflectionTestUtils.setField(service, "properties", new AuthSecurityProperties("issuer", "aud", Duration.ofMinutes(5), Duration.ofHours(1), 5, Duration.ofMinutes(1)));
|
||||
ReflectionTestUtils.setField(service, "signingProperties", new UnifiedJwtProperties("unused", "kid"));
|
||||
auth = transactional(service);
|
||||
var management = new AccountManagementService();
|
||||
ReflectionTestUtils.setField(management, "repository", repository);
|
||||
ReflectionTestUtils.setField(management, "tokenSessionStore", sessions);
|
||||
ReflectionTestUtils.setField(management, "passwordEncoder", passwords);
|
||||
accounts = transactional(management);
|
||||
var state = new SessionStateService();
|
||||
ReflectionTestUtils.setField(state, "accounts", repository);
|
||||
ReflectionTestUtils.setField(state, "authorizationLoader", loader);
|
||||
ReflectionTestUtils.setField(state, "sessions", sessions);
|
||||
states = transactional(state);
|
||||
}
|
||||
|
||||
@Test void lateLoginAfterRevokeBeforeIamCommitCannotUseOldPermissionsOrRefresh() throws Exception {
|
||||
var revoked = new CountDownLatch(1);
|
||||
var snapshotLoaded = new CountDownLatch(1);
|
||||
var committed = new CountDownLatch(1);
|
||||
doAnswer(c -> {
|
||||
snapshotLoaded.countDown(); await(committed);
|
||||
var claims = ((org.springframework.security.oauth2.jwt.JwtEncoderParameters)c.getArgument(0)).getClaims();
|
||||
return Jwt.withTokenValue(claims.getId()).header("alg", "RS256").claims(m -> m.putAll(claims.getClaims())).build();
|
||||
}).when(encoder).encode(any());
|
||||
ExecutorService pool = Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
Future<?> change = pool.submit(() -> {
|
||||
new TransactionTemplate(iamTx).executeWithoutResult(tx -> {
|
||||
iamDb.update("UPDATE authorization_state SET version=version+1, permission='read' WHERE id=1");
|
||||
accounts.revokeSessions(Set.of(1L));
|
||||
revoked.countDown(); await(snapshotLoaded);
|
||||
});
|
||||
committed.countDown();
|
||||
});
|
||||
await(revoked);
|
||||
var login = pool.submit(() -> auth.login(new LoginRequest("operator", "old-password")));
|
||||
var stale = login.get(5, TimeUnit.SECONDS); change.get(5, TimeUnit.SECONDS);
|
||||
// Access and state were written AFTER revoke, yet the first post-commit request is denied.
|
||||
assertThat(values).containsKey("test:access:" + stale.accessToken());
|
||||
assertThatThrownBy(() -> states.validate(stale.accessToken(), 1L)).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> auth.refresh(stale.refreshToken())).isInstanceOf(BizException.class);
|
||||
var fresh = auth.login(new LoginRequest("operator", "old-password"));
|
||||
assertThat(states.validate(fresh.accessToken(), 1L)).isTrue();
|
||||
assertThat(fresh.authorities()).containsExactly("read");
|
||||
assertThatThrownBy(() -> auth.refresh(stale.refreshToken())).isInstanceOf(BizException.class);
|
||||
} finally { committed.countDown(); pool.shutdownNow(); }
|
||||
}
|
||||
|
||||
@Test void passwordResetWaitsForConcurrentLoginThenInvalidatesAccessAndRefresh() throws Exception {
|
||||
var loaded = new CountDownLatch(1); var release = new CountDownLatch(1);
|
||||
doAnswer(c -> {
|
||||
loaded.countDown(); await(release);
|
||||
var claims = ((org.springframework.security.oauth2.jwt.JwtEncoderParameters)c.getArgument(0)).getClaims();
|
||||
return Jwt.withTokenValue(claims.getId()).header("alg", "RS256").claims(m -> m.putAll(claims.getClaims())).build();
|
||||
}).when(encoder).encode(any());
|
||||
ExecutorService pool = Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
var login = pool.submit(() -> auth.login(new LoginRequest("operator", "old-password")));
|
||||
await(loaded);
|
||||
var resetting = new CountDownLatch(1);
|
||||
var reset = pool.submit(() -> { resetting.countDown(); accounts.resetPassword(1L, new ResetPasswordRequest("new-password", false), false); });
|
||||
await(resetting);
|
||||
assertThatThrownBy(() -> reset.get(100, TimeUnit.MILLISECONDS)).isInstanceOf(TimeoutException.class);
|
||||
release.countDown();
|
||||
var old = login.get(5, TimeUnit.SECONDS); reset.get(5, TimeUnit.SECONDS);
|
||||
assertThat(authDb.queryForObject("SELECT token_version FROM auth_account WHERE id=1", Integer.class)).isEqualTo(2);
|
||||
assertThatThrownBy(() -> states.validate(old.accessToken(), 1L)).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> auth.refresh(old.refreshToken())).isInstanceOf(BizException.class);
|
||||
} finally { release.countDown(); pool.shutdownNow(); }
|
||||
}
|
||||
|
||||
@Test void failedRevocationRollsBackAuthAndIamAndDoesNotReportSuccess() {
|
||||
var old = auth.login(new LoginRequest("operator", "old-password"));
|
||||
redisFailure.set(true);
|
||||
assertThatThrownBy(() -> new TransactionTemplate(iamTx).executeWithoutResult(tx -> {
|
||||
iamDb.update("UPDATE authorization_state SET version=version+1, active=false WHERE id=1");
|
||||
accounts.revokeSessions(Set.of(1L));
|
||||
})).isInstanceOf(BizException.class);
|
||||
redisFailure.set(false);
|
||||
assertThat(authDb.queryForObject("SELECT token_version FROM auth_account WHERE id=1", Integer.class)).isEqualTo(1);
|
||||
assertThat(iamDb.queryForObject("SELECT version FROM authorization_state WHERE id=1", Long.class)).isEqualTo(1);
|
||||
assertThat(states.validate(old.accessToken(), 1L)).isTrue();
|
||||
}
|
||||
|
||||
@Test void committedIamVersionRejectsResidualSessionWhenNoRedisDeletionHappens() {
|
||||
var old = auth.login(new LoginRequest("operator", "old-password"));
|
||||
new TransactionTemplate(iamTx).executeWithoutResult(tx ->
|
||||
iamDb.update("UPDATE authorization_state SET tenant_version=tenant_version+1 WHERE id=1"));
|
||||
assertThatThrownBy(() -> states.validate(old.accessToken(), 1L)).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> auth.refresh(old.refreshToken())).isInstanceOf(BizException.class);
|
||||
var current = auth.login(new LoginRequest("operator", "old-password"));
|
||||
assertThat(states.validate(current.accessToken(), 1L)).isTrue();
|
||||
iamDb.update("UPDATE authorization_state SET warehouse=20 WHERE id=1");
|
||||
assertThatThrownBy(() -> states.validate(current.accessToken(), 1L)).isInstanceOf(BizException.class);
|
||||
}
|
||||
|
||||
@Test void iamRollbackPreservesFingerprintAndOtherUserCannotUseSession() {
|
||||
var old = auth.login(new LoginRequest("operator", "old-password"));
|
||||
new TransactionTemplate(iamTx).executeWithoutResult(tx -> {
|
||||
iamDb.update("UPDATE authorization_state SET version=version+1, permission='read' WHERE id=1");
|
||||
tx.setRollbackOnly();
|
||||
});
|
||||
assertThat(states.validate(old.accessToken(), 1L)).isTrue();
|
||||
assertThatThrownBy(() -> states.validate(old.accessToken(), 2L)).isInstanceOf(BizException.class);
|
||||
var refreshed = auth.refresh(old.refreshToken());
|
||||
assertThat(states.validate(refreshed.accessToken(), 1L)).isTrue();
|
||||
}
|
||||
|
||||
@Test void lostRevokeAcknowledgementRollsBackIamButCannotRestoreOldAuthSession() {
|
||||
var old = auth.login(new LoginRequest("operator", "old-password"));
|
||||
assertThatThrownBy(() -> new TransactionTemplate(iamTx).executeWithoutResult(tx -> {
|
||||
iamDb.update("UPDATE authorization_state SET version=version+1, permission='read' WHERE id=1");
|
||||
accounts.revokeSessions(Set.of(1L));
|
||||
throw new IllegalStateException("Auth response timed out after its commit");
|
||||
})).isInstanceOf(IllegalStateException.class);
|
||||
assertThat(iamDb.queryForObject("SELECT permission FROM authorization_state WHERE id=1", String.class)).isEqualTo("write");
|
||||
assertThat(authDb.queryForObject("SELECT token_version FROM auth_account WHERE id=1", Integer.class)).isEqualTo(2);
|
||||
assertThatThrownBy(() -> states.validate(old.accessToken(), 1L)).isInstanceOf(BizException.class);
|
||||
var fresh = auth.login(new LoginRequest("operator", "old-password"));
|
||||
assertThat(states.validate(fresh.accessToken(), 1L)).isTrue();
|
||||
assertThat(fresh.authorities()).containsExactly("write");
|
||||
}
|
||||
|
||||
@Test void disabledCustomerOrMemberAndUnavailableIamRejectAccessAndRefresh() {
|
||||
var old = auth.login(new LoginRequest("operator", "old-password"));
|
||||
// No Redis deletion: the authoritative identity gate alone closes the session.
|
||||
iamDb.update("UPDATE authorization_state SET active=false, tenant_version=tenant_version+1 WHERE id=1");
|
||||
assertThatThrownBy(() -> states.validate(old.accessToken(), 1L)).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> auth.refresh(old.refreshToken())).isInstanceOf(IllegalArgumentException.class);
|
||||
iamDb.update("UPDATE authorization_state SET active=true WHERE id=1");
|
||||
var fresh = auth.login(new LoginRequest("operator", "old-password"));
|
||||
iamDb.execute("DROP TABLE authorization_state");
|
||||
assertThatThrownBy(() -> states.validate(fresh.accessToken(), 1L)).isInstanceOf(org.springframework.dao.DataAccessException.class);
|
||||
assertThatThrownBy(() -> auth.refresh(fresh.refreshToken())).isInstanceOf(org.springframework.dao.DataAccessException.class);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked") private StringRedisTemplate redis() {
|
||||
redisFailure = new AtomicBoolean();
|
||||
var redis = mock(StringRedisTemplate.class);
|
||||
ValueOperations<String,String> ops = mock(ValueOperations.class);
|
||||
SetOperations<String,String> index = mock(SetOperations.class);
|
||||
when(redis.opsForValue()).thenReturn(ops); when(redis.opsForSet()).thenReturn(index);
|
||||
doAnswer(c -> { values.put(c.getArgument(0), c.getArgument(1)); return null; }).when(ops).set(anyString(), anyString(), any(Duration.class));
|
||||
when(ops.get(anyString())).thenAnswer(c -> values.get(c.getArgument(0)));
|
||||
when(ops.getAndDelete(anyString())).thenAnswer(c -> values.remove(c.getArgument(0)));
|
||||
when(redis.hasKey(anyString())).thenAnswer(c -> values.containsKey(c.getArgument(0)));
|
||||
when(redis.delete(anyString())).thenAnswer(c -> values.remove(c.getArgument(0)) != null);
|
||||
when(redis.delete(anyCollection())).thenAnswer(c -> {
|
||||
if (redisFailure.get()) throw new org.springframework.data.redis.RedisConnectionFailureException("offline");
|
||||
long count = 0; for (Object key : (java.util.Collection<?>) c.getArgument(0)) if (values.remove(key) != null) count++;
|
||||
return count;
|
||||
});
|
||||
when(index.members(anyString())).thenAnswer(c -> sets.getOrDefault(c.getArgument(0), Set.of()));
|
||||
doAnswer(c -> {
|
||||
Set<String> members = sets.computeIfAbsent(c.getArgument(0), key -> ConcurrentHashMap.newKeySet());
|
||||
for (int i=1; i<c.getArguments().length; i++) members.add(c.getArgument(i));
|
||||
return 1L;
|
||||
}).when(index).add(anyString(), any(String[].class));
|
||||
return redis;
|
||||
}
|
||||
|
||||
static JdbcTemplate database() {
|
||||
var ds = new JdbcDataSource(); ds.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=3000");
|
||||
return new JdbcTemplate(ds);
|
||||
}
|
||||
@SuppressWarnings("unchecked") <T> T transactional(T target) {
|
||||
var factory = new ProxyFactory(target); factory.setProxyTargetClass(true);
|
||||
factory.addAdvice(new TransactionInterceptor(authTx, new AnnotationTransactionAttributeSource()));
|
||||
return (T) factory.getProxy();
|
||||
}
|
||||
static void await(CountDownLatch latch) {
|
||||
try { if (!latch.await(5, TimeUnit.SECONDS)) throw new AssertionError("Barrier timed out"); }
|
||||
catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new AssertionError(e); }
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,7 @@ class TokenSessionStoreTest {
|
||||
when(redis.opsForValue()).thenReturn(values);
|
||||
|
||||
store.store("jti-1", 42L, Set.of("ADMIN"), Set.of("order:read"),
|
||||
"raw-refresh", Duration.ofMinutes(30), Duration.ofDays(7));
|
||||
"state", "raw-refresh", Duration.ofMinutes(30), Duration.ofDays(7));
|
||||
|
||||
verify(values).set(eq("baiyue:auth:access:jti-1"),
|
||||
org.mockito.ArgumentMatchers.argThat(value -> value.contains("\"version\":1")
|
||||
@@ -64,7 +64,7 @@ class TokenSessionStoreTest {
|
||||
ValueOperations<String, String> values = mock(ValueOperations.class);
|
||||
when(redis.opsForValue()).thenReturn(values);
|
||||
String json = new ObjectMapper().writeValueAsString(
|
||||
new TokenSessionStore.RefreshSession(42L, "jti-1"));
|
||||
new TokenSessionStore.RefreshSession(42L, "jti-1", "state"));
|
||||
when(values.getAndDelete(anyString())).thenReturn(json);
|
||||
|
||||
TokenSessionStore.RefreshSession session = store.consume("raw-refresh");
|
||||
|
||||
Reference in New Issue
Block a user