Merge remote-tracking branch 'origin/test' into dev

This commit is contained in:
2026-09-07 12:10:19 +08:00
34 changed files with 390 additions and 94 deletions
+2 -2
View File
@@ -17,7 +17,7 @@
| 登录标识与企业联系人解耦 | `service/AccountManagementService.java`、`dto/AccountManagementDtos.java`、`service/MerchantRegistrationService.java`;注册手机号只在创建时同时初始化首位管理员登录手机号和公司联系电话,创建后二者独立维护;账号手机号变更需撤销该账号全部会话 |
| 统一 JWT 签发与 JWKS | `config/AuthSecurityConfig.java`、`config/AuthSecurityProperties.java`、`service/AuthService.java`;Auth 是国内与海外仓共用的唯一用户 Token 签发方,JWKS 公钥发布按认证接口契约实现 |
| 国内小 JWT 与会话 | `service/AuthService.java`、`service/TokenSessionStore.java`;浏览器登录 Token 只承载稳定身份、应用域和版本声明,国内服务结合 Redis 会话校验 |
| 海外访问 Token 签发 | `service/OverseasTokenService.java`;登录/刷新编排使用同一用户身份同时签发短时 RS256 海外 Token,限定海外 Audience,只保留平台海外仓/海外业务/海外主数据、客户海外仓 `merchant:warehouse:*`、海外财务 API 所需的 `platform:finance:*` 及 `lastleg:*` 权限前缀;前端统一认证层负责按区域 API 自动选择 |
| 海外访问 Token 签发 | `service/OverseasTokenService.java`;登录/刷新编排使用同一用户身份同时签发短时 RS256 海外 Token,限定海外 Audience,只携带稳定身份、客户范围和版本声明,不携带角色或权限集;前端统一认证层负责按区域 API 自动选择 |
| 海外 PDA 实时会话校验 | `controller/InternalAccountController.java`、`service/OverseasSessionValidationService.java`;只接受海外 Gateway 已验签后提取的 userId/tokenVersion/overseasWarehouseId,以内部服务令牌保护并实时联查 IAM 当前仓绑定 |
| 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` |
@@ -28,7 +28,7 @@
## 修改约束
- 密码、Token、敏感 Claim 不得写日志;统一 JWT 使用 RS256,私钥只留在 Auth,JWKS 只发布公钥。国内登录 Token 的实时权限来自 Redis 会话快照;登录/刷新同时签发短时海外 Token并携带必要权限 Claims,海外服务不访问国内 Redis。修改登录响应、Claim、Audience、JWKS、权限快照或过期策略时联查 Admin/Client 统一认证层、`common-security/`、`gateway/`、`iam/` 和海外服务消费者。
- 密码、Token、敏感 Claim 不得写日志;统一 JWT 使用 RS256,私钥只留在 Auth,JWKS 只发布公钥。国内登录 Token 的实时权限来自 Redis 会话快照;短时海外 Token 不携带角色或权限集,海外授权由 IAM 事件投影到 Lastleg,再由海外 Gateway 验证并以可信内网上下文传递,海外服务不访问国内 Redis。修改登录响应、Claim、Audience、JWKS、权限快照或过期策略时联查 Admin/Client 统一认证层、`common-security/`、`gateway/`、`iam/` 和海外服务消费者。
- 注册跨 Auth/IAM 必须保持幂等和失败补偿;内部接口仅接受服务令牌。
- 登录账号的 username、phone、email 唯一性只约束 Auth 账号;IAM 公司联系电话/联系邮箱是企业资料,不得反向覆盖账号登录标识,也不得因其他企业使用相同联系电话而拒绝。成员登录手机号变更只能由 IAM 账号协作入口同步并撤销该账号全部会话。
- 业务错误保持 HTTP 200 + body code;Security 认证失败保留真实 401/403。
@@ -19,24 +19,12 @@ import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
/** 将已经通过国内 HMAC 与 Redis 校验的会话兑换为海外 RS256 短时令牌。 */
@Service
@ConditionalOnProperty(name = "baiyue.security.overseas-jwt.enabled", havingValue = "true")
public class OverseasTokenService {
private static final List<String> OVERSEAS_AUTHORITY_PREFIXES = List.of(
"platform:overseas-warehouse:",
"platform:overseas-business:",
"platform:overseas-master-data:",
"merchant:warehouse:",
"merchant:cross-border:",
"platform:finance:",
"lastleg:"
);
@Resource private AuthAccountRepository accounts;
@Resource private AuthorizationLoader authorizationLoader;
@Resource private OverseasJwtProperties properties;
@@ -61,8 +49,6 @@ public class OverseasTokenService {
.claim("user_id", account.getId()).claim("tenant_id", authorization.getTenantId())
.claim("membership_id", authorization.getMembershipId())
.claim("app_code", authorization.getAppCode()).claim("client_type", authorization.getClientType())
.claim("roles", authorization.getRoles())
.claim("authorities", overseasAuthorities(authorization.getPermissions()))
.claim("token_version", account.getTokenVersion() == null ? 0 : account.getTokenVersion())
.claim("permission_version", authorization.getPermissionVersion() == null ? 0L : authorization.getPermissionVersion());
if (authorization.getOverseasWarehouseId() != null) {
@@ -74,12 +60,5 @@ public class OverseasTokenService {
return new OverseasTokenResponse("Bearer", token, properties.accessTokenTtl().toSeconds());
}
private Set<String> overseasAuthorities(Set<String> permissions) {
if (permissions == null || permissions.isEmpty()) return Set.of();
return permissions.stream()
.filter(permission -> permission != null && OVERSEAS_AUTHORITY_PREFIXES.stream().anyMatch(permission::startsWith))
.collect(Collectors.toUnmodifiableSet());
}
public record OverseasTokenResponse(String tokenType, String accessToken, long expiresIn) { }
}
@@ -79,10 +79,8 @@ class OverseasTokenServiceTest {
.containsEntry("permission_version", 7L)
.containsEntry("token_version", 0)
.containsEntry("overseas_warehouse_id", 900L)
.containsEntry("roles", Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"))
.containsEntry("authorities", Set.of(authority, merchantAuthority, merchantCrossBorderAuthority,
lastlegAuthority, financeAuthority))
.doesNotContainKeys("userId", "tenantId", "appCode", "permissionVersion");
.doesNotContainKeys("roles", "authorities", "permissions", "userId", "tenantId", "appCode",
"permissionVersion");
assertThat(response.expiresIn()).isEqualTo(900);
}
@@ -1,6 +1,7 @@
package com.baiyue.common.event;
import java.time.Instant;
import java.util.Set;
/** IAM 向海外服务发布的 V1 安全投影事件负载。 */
public final class IamSecurityEventsV1 {
@@ -14,5 +15,11 @@ public final class IamSecurityEventsV1 {
public record TenantStatusChanged(long tenantId, String status, Instant changedAt) { }
public record PermissionVersionChanged(long authUserId, long tenantId, String appCode,
long permissionVersion, String reason, Instant changedAt) { }
long permissionVersion, String reason, Instant changedAt,
Set<String> roles, Set<String> authorities) {
public PermissionVersionChanged {
roles = roles == null ? Set.of() : Set.copyOf(roles);
authorities = authorities == null ? Set.of() : Set.copyOf(authorities);
}
}
}
@@ -1,6 +1,7 @@
package com.baiyue.common.security;
import org.springframework.security.oauth2.jwt.Jwt;
import jakarta.servlet.http.HttpServletRequest;
/**
* 访问令牌会话有效性校验扩展点,用于在 JWT 校验之外确认服务端登录会话状态。
@@ -8,4 +9,8 @@ import org.springframework.security.oauth2.jwt.Jwt;
@FunctionalInterface
public interface AccessTokenSessionValidator {
AccessTokenSession validate(Jwt jwt);
default AccessTokenSession validate(Jwt jwt, HttpServletRequest request) {
return validate(jwt);
}
}
@@ -50,9 +50,11 @@ public class BaiyueSecurityAutoConfiguration {
org.springframework.beans.factory.ObjectProvider<StringRedisTemplate> redis,
ObjectMapper objectMapper,
@Value("${baiyue.security.token-key-prefix:baiyue:auth}") String keyPrefix,
@Value("${baiyue.security.gateway-internal-service-token:${baiyue.security.internal-service-token:${INTERNAL_SERVICE_TOKEN:}}}") String gatewayToken,
@Value("${baiyue.security.session-mode:redis}") String sessionMode) {
AccessTokenSessionValidator validator;
if ("claims".equalsIgnoreCase(sessionMode)) validator = new ClaimsAccessTokenSessionValidator();
else if ("gateway".equalsIgnoreCase(sessionMode)) validator = new GatewayAccessTokenSessionValidator(gatewayToken);
else {
StringRedisTemplate template=redis.getIfAvailable();
if(template==null) throw new IllegalStateException("session-mode=redis requires StringRedisTemplate");
@@ -0,0 +1,57 @@
package com.baiyue.common.security;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.oauth2.jwt.Jwt;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.LinkedHashSet;
import java.util.Set;
/** Uses the overseas gateway's authenticated authorization snapshot instead of large JWT claims. */
public final class GatewayAccessTokenSessionValidator implements AccessTokenSessionValidator {
public static final String TOKEN_HEADER = "X-Gateway-Internal-Token";
public static final String ROLES_HEADER = "X-Overseas-Roles";
public static final String AUTHORITIES_HEADER = "X-Overseas-Authorities";
private final byte[] expectedToken;
public GatewayAccessTokenSessionValidator(String expectedToken) {
if (expectedToken == null || expectedToken.isBlank()) {
throw new IllegalStateException("session-mode=gateway requires an internal service token");
}
this.expectedToken = expectedToken.getBytes(StandardCharsets.UTF_8);
}
@Override
public AccessTokenSession validate(Jwt jwt) {
throw new IllegalArgumentException("Gateway authorization headers are required");
}
@Override
public AccessTokenSession validate(Jwt jwt, HttpServletRequest request) {
String supplied = request.getHeader(TOKEN_HEADER);
if (supplied == null || !MessageDigest.isEqual(expectedToken, supplied.getBytes(StandardCharsets.UTF_8))) {
throw new IllegalArgumentException("Untrusted overseas gateway authorization context");
}
Long userId = number(jwt.getClaims().getOrDefault("userId", jwt.getClaims().get("user_id")));
return new AccessTokenSession(1, userId, values(request.getHeader(ROLES_HEADER)),
values(request.getHeader(AUTHORITIES_HEADER)));
}
private static Long number(Object value) {
if (value instanceof Number number) return number.longValue();
try { return value == null ? null : Long.valueOf(value.toString()); }
catch (NumberFormatException exception) { return null; }
}
private static Set<String> values(String header) {
if (header == null || header.isBlank()) return Set.of();
LinkedHashSet<String> values = new LinkedHashSet<>();
for (String value : header.split(",")) {
String normalized = value.trim();
if (!normalized.isEmpty()) values.add(normalized);
}
return Set.copyOf(values);
}
}
@@ -55,7 +55,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
String tokenValue = header.substring(BEARER.length()).trim();
try {
Jwt jwt = jwtDecoder.decode(tokenValue);
AccessTokenSession session = sessionValidator.validate(jwt);
AccessTokenSession session = sessionValidator.validate(jwt, request);
Authentication authentication = claimsMapper.map(jwt, session);
SecurityContextHolder.getContext().setAuthentication(authentication);
filterChain.doFilter(request, response);
@@ -0,0 +1,46 @@
package com.baiyue.common.security;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.security.oauth2.jwt.Jwt;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class GatewayAccessTokenSessionValidatorTest {
private final GatewayAccessTokenSessionValidator validator =
new GatewayAccessTokenSessionValidator("gateway-secret");
@Test void acceptsOnlyGatewayAuthenticatedAuthorizationSnapshot() {
MockHttpServletRequest request = new MockHttpServletRequest();
request.addHeader(GatewayAccessTokenSessionValidator.TOKEN_HEADER, "gateway-secret");
request.addHeader(GatewayAccessTokenSessionValidator.ROLES_HEADER, "PLATFORM_ADMIN");
request.addHeader(GatewayAccessTokenSessionValidator.AUTHORITIES_HEADER,
"platform:overseas-warehouse:last-mile-order:list,platform:finance:fee-source:view");
AccessTokenSession session = validator.validate(jwt(), request);
assertThat(session.userId()).isEqualTo(42L);
assertThat(session.roles()).containsExactly("PLATFORM_ADMIN");
assertThat(session.permissions()).containsExactlyInAnyOrder(
"platform:overseas-warehouse:last-mile-order:list", "platform:finance:fee-source:view");
}
@Test void rejectsForgedOrMissingGatewayToken() {
MockHttpServletRequest forged = new MockHttpServletRequest();
forged.addHeader(GatewayAccessTokenSessionValidator.TOKEN_HEADER, "forged");
forged.addHeader(GatewayAccessTokenSessionValidator.AUTHORITIES_HEADER,
"platform:overseas-warehouse:last-mile-order:list");
assertThatThrownBy(() -> validator.validate(jwt(), forged))
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("Untrusted");
}
private Jwt jwt() {
return Jwt.withTokenValue("small-overseas-token").header("alg", "RS256").subject("42")
.issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300))
.claim("user_id", 42L).claim("tenant_id", 0L).claim("app_code", "PLATFORM")
.claim("permission_version", 7L).claim("token_version", 1).build();
}
}
+1 -1
View File
@@ -23,7 +23,7 @@ baiyue:
security:
internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes}
jwt-mode: jwks
session-mode: claims
session-mode: gateway
issuer: ${JWT_ISSUER:auth-service}
audience: ${JWT_AUDIENCE:lastleg-api}
jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json}
@@ -13,7 +13,7 @@ baiyue:
token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes}
security:
jwt-mode: jwks
session-mode: claims
session-mode: gateway
issuer: ${JWT_ISSUER:auth-service}
audience: ${JWT_AUDIENCE:lastleg-api}
jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json}
+23 -4
View File
@@ -1139,6 +1139,7 @@ CREATE TABLE IF NOT EXISTS ll_outbound_order (
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
outbound_type VARCHAR(32) NOT NULL DEFAULT 'DISPATCH_DELIVERY' COMMENT '出库类型:派送转出库、一件代发',
lx_codes VARCHAR(1000) NULL COMMENT '关联一件代发领星入库单代码汇总',
recipient_info VARCHAR(1000) NULL COMMENT '一件代发收件信息', PRIMARY KEY(id), UNIQUE KEY uk_ll_outbound_no(outbound_no),
UNIQUE KEY uk_ll_outbound_idempotency(customer_id,idempotency_key), UNIQUE KEY uk_ll_outbound_dispatch(customer_id,dispatch_order_id),
KEY idx_ll_outbound_list(customer_id,status,created_at)
@@ -1317,7 +1318,9 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
PREPARE phase5_structure_ddl FROM @ddl; EXECUTE phase5_structure_ddl; DEALLOCATE PREPARE phase5_structure_ddl;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='outbound_type'), 'SELECT 1', 'ALTER TABLE ll_outbound_order ADD COLUMN outbound_type VARCHAR(32) NOT NULL DEFAULT ''DISPATCH_DELIVERY'' COMMENT ''出库类型:派送转出库、一件代发'' AFTER status'));
PREPARE phase5_structure_ddl FROM @ddl; EXECUTE phase5_structure_ddl; DEALLOCATE PREPARE phase5_structure_ddl;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='recipient_info'), 'SELECT 1', 'ALTER TABLE ll_outbound_order ADD COLUMN recipient_info VARCHAR(1000) NULL COMMENT ''一件代发收件信息'' AFTER outbound_type'));
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='lx_codes'), 'SELECT 1', 'ALTER TABLE ll_outbound_order ADD COLUMN lx_codes VARCHAR(1000) NULL COMMENT ''关联一件代发领星入库单代码汇总'' AFTER outbound_type'));
PREPARE phase5_structure_ddl FROM @ddl; EXECUTE phase5_structure_ddl; DEALLOCATE PREPARE phase5_structure_ddl;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='recipient_info'), 'SELECT 1', 'ALTER TABLE ll_outbound_order ADD COLUMN recipient_info VARCHAR(1000) NULL COMMENT ''一件代发收件信息'' AFTER lx_codes'));
PREPARE phase5_structure_ddl FROM @ddl; EXECUTE phase5_structure_ddl; DEALLOCATE PREPARE phase5_structure_ddl;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='sku_code'), 'SELECT 1', 'ALTER TABLE ll_outbound_pallet_item ADD COLUMN sku_code VARCHAR(128) NULL COMMENT ''一件代发SKU'''));
PREPARE phase5_structure_ddl FROM @ddl; EXECUTE phase5_structure_ddl; DEALLOCATE PREPARE phase5_structure_ddl;
@@ -1607,6 +1610,8 @@ CREATE TABLE IF NOT EXISTS ll_security_projection (
aggregate_id VARCHAR(64) NOT NULL COMMENT '用户或客户聚合ID', tenant_id BIGINT NOT NULL COMMENT '平台0或客户ID',
security_status VARCHAR(24) NULL COMMENT '当前安全状态:ACTIVE或DISABLED',
permission_version BIGINT NULL COMMENT '当前单调权限版本',
roles_json JSON NOT NULL COMMENT 'IAM投影的当前角色快照',
authorities_json JSON NOT NULL COMMENT 'IAM投影的当前海外权限快照',
aggregate_version BIGINT NOT NULL COMMENT '事件聚合单调版本', payload_hash CHAR(64) NOT NULL COMMENT '当前版本载荷哈希',
last_event_id VARCHAR(64) NOT NULL COMMENT '最后应用的事件ID',
changed_at DATETIME(3) NOT NULL COMMENT '国内IAM事实发生时间', updated_at DATETIME(3) NOT NULL COMMENT '投影更新时间',
@@ -1614,14 +1619,28 @@ CREATE TABLE IF NOT EXISTS ll_security_projection (
KEY idx_security_projection_permission (aggregate_id,permission_version)
) ENGINE=InnoDB COMMENT='海外IAM安全状态与权限版本投影';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='ll_security_projection' AND column_name='roles_json'), 'SELECT 1',
'ALTER TABLE ll_security_projection ADD COLUMN roles_json JSON NULL COMMENT ''IAM投影的当前角色快照'' AFTER permission_version'));
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='ll_security_projection' AND column_name='authorities_json'), 'SELECT 1',
'ALTER TABLE ll_security_projection ADD COLUMN authorities_json JSON NULL COMMENT ''IAM投影的当前海外权限快照'' AFTER roles_json'));
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
UPDATE ll_security_projection SET roles_json=JSON_ARRAY() WHERE roles_json IS NULL;
UPDATE ll_security_projection SET authorities_json=JSON_ARRAY() WHERE authorities_json IS NULL;
ALTER TABLE ll_security_projection
MODIFY COLUMN roles_json JSON NOT NULL COMMENT 'IAM投影的当前角色快照',
MODIFY COLUMN authorities_json JSON NOT NULL COMMENT 'IAM投影的当前海外权限快照';
-- 与国内固定 auth_account.id=1000001、tenant_id=0 的 platform_admin 一致;真实事件从版本1覆盖。
INSERT IGNORE INTO ll_security_projection (
aggregate_type,aggregate_id,tenant_id,security_status,permission_version,aggregate_version,
aggregate_type,aggregate_id,tenant_id,security_status,permission_version,roles_json,authorities_json,aggregate_version,
payload_hash,last_event_id,changed_at,updated_at
) VALUES
('USER','1000001',0,'ACTIVE',0,0,SHA2('lastleg-baseline:platform_admin:USER:0',256),
('USER','1000001',0,'ACTIVE',0,JSON_ARRAY(),JSON_ARRAY(),0,SHA2('lastleg-baseline:platform_admin:USER:0',256),
'baseline-platform-admin-user-v0','1970-01-01 00:00:00.000','1970-01-01 00:00:00.000'),
('AUTHORIZATION','1000001',0,NULL,0,0,SHA2('lastleg-baseline:platform_admin:AUTHORIZATION:0',256),
('AUTHORIZATION','1000001',0,NULL,0,JSON_ARRAY(),JSON_ARRAY(),0,SHA2('lastleg-baseline:platform_admin:AUTHORIZATION:0',256),
'baseline-platform-admin-authorization-v0','1970-01-01 00:00:00.000','1970-01-01 00:00:00.000');
DROP PROCEDURE IF EXISTS assert_lastleg_security_projection_ready;
+3 -3
View File
@@ -14,9 +14,9 @@
| Audience 校验 | `config/AudienceJwtValidator.java` |
| 必要 Claims 校验 | `config/RequiredClaimsJwtValidator.java` |
| PDA 换仓即时失效 | `filter/PdaSessionValidationFilter.java`、`HttpPdaSessionValidationClient.java`、`config/PdaSessionValidationProperties.java`;仅 `/api/pda/**` 本地验签后同步核对 Auth,任何异常 fail-closed |
| 海外安全事件即时失效 | `filter/OverseasSecurityProjectionFilter.java`、`HttpOverseasSecurityValidationClient.java`;全部 `/api/**` 本地验签后核对海外安全投影,用户/客户停用与权限版本落后立即拒绝 |
| 海外安全事件即时失效 | `filter/OverseasSecurityProjectionFilter.java`、`HttpOverseasSecurityValidationClient.java`;全部 `/api/**` 本地验签后核对海外安全投影,用户/客户停用与权限版本不一致立即拒绝,并取回当前角色/权限快照 |
| 安全配置项 | `config/GatewaySecurityProperties.java` |
| 可信身份头传递 | `filter/TrustedIdentityHeaderFilter.java` |
| 可信身份与授权头传递 | `filter/TrustedIdentityHeaderFilter.java`、`filter/OverseasSecurityProjectionFilter.java`;先清除外部伪造头,再以内部服务令牌保护投影快照 |
| 环境配置 | `src/main/resources/application.yml`、`../config/nacos/` |
| 浏览器业务路由 | `src/main/resources/application-overseas.yml`;Lastleg 显式覆盖 `/api/merchant/v1/last-leg/**`、`/api/platform/v1/last-leg/**`,Warehouse 显式覆盖商户仓库与平台 `warehouse-admin` 路径 |
| 海外 PDA 路由 | 设备端 `../../apps/pda-overseas-uniapp/`,服务端入口 `/api/pda/v1/last-leg/**`;必须在 Gateway 路由中显式声明并保持海外 Token/权限校验,不得让设备直连 Lastleg |
@@ -25,7 +25,7 @@
## 安全规则
- 生产令牌 `aud` 必须包含 `lastleg-api`,并包含配置要求的身份声明。
- 网关必须清除外部请求伪造的可信身份头,仅传递验签后生成的身份信息。
- 网关必须清除外请求伪造的可信身份与授权头,仅传递验签并核对安全投影后生成的上下文;业务服务必须验证内部服务令牌。
- JWKS 地址、Issuer 和 Audience 必须外部配置;不得在源码写死生产密钥。
- merchant、platform 与 PDA 路径分别显式维护路由和授权边界;新增 PDA Controller 不会自动成为公网路由,Gateway 未声明前不得把设备链路标记为已联通。
@@ -5,14 +5,13 @@ import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.security.oauth2.jwt.Jwt;
import java.util.Collection;
import java.util.List;
import java.util.Locale;
/** Validates the identity and authorization claims required by overseas APIs. */
final class RequiredClaimsJwtValidator implements OAuth2TokenValidator<Jwt> {
private static final List<String> REQUIRED_CLAIMS = List.of(
"sub", "tenant_id", "app_code", "token_version", "permission_version", "authorities");
"sub", "tenant_id", "app_code", "token_version", "permission_version");
@Override
public OAuth2TokenValidatorResult validate(Jwt jwt) {
@@ -44,11 +43,6 @@ final class RequiredClaimsJwtValidator implements OAuth2TokenValidator<Jwt> {
&& !("MERCHANT".equals(appCode) && tenantId > 0L)) {
return failure("JWT app_code 与 tenant_id 访问域不一致");
}
Object authorities = jwt.getClaims().get("authorities");
if (!(authorities instanceof Collection<?> values)
|| values.stream().anyMatch(value -> value == null || value.toString().isBlank())) {
return failure("JWT authorities 必须是权限列表");
}
return OAuth2TokenValidatorResult.success();
}
@@ -6,6 +6,8 @@ import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import java.util.LinkedHashSet;
import java.util.Set;
@Component
public class HttpOverseasSecurityValidationClient implements OverseasSecurityValidationClient {
@@ -29,9 +31,20 @@ public class HttpOverseasSecurityValidationClient implements OverseasSecurityVal
return Mono.error(new IllegalStateException("海外安全投影响应无效"));
}
return Mono.just(new Result(data.path("valid").asBoolean(false),
data.path("failureReason").isNull() ? null : data.path("failureReason").asText(null)));
data.path("failureReason").isNull() ? null : data.path("failureReason").asText(null),
strings(data.path("roles")), strings(data.path("authorities"))));
});
}
private static Set<String> strings(JsonNode values) {
if (values == null || !values.isArray()) return Set.of();
LinkedHashSet<String> result = new LinkedHashSet<>();
values.forEach(value -> {
String text = value.asText("");
if (!text.isBlank()) result.add(text);
});
return Set.copyOf(result);
}
private record Request(long userId, long tenantId, long permissionVersion) { }
}
@@ -1,6 +1,7 @@
package com.baiyue.gateway.filter;
import com.baiyue.gateway.config.GatewaySecurityConfig;
import com.baiyue.gateway.config.OverseasSecurityProjectionProperties;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
@@ -11,15 +12,20 @@ import org.springframework.security.oauth2.server.resource.authentication.JwtAut
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import java.util.Collection;
import java.util.stream.Collectors;
@Component
public class OverseasSecurityProjectionFilter implements GlobalFilter, Ordered {
private final OverseasSecurityValidationClient client;
private final ObjectMapper objectMapper;
private final OverseasSecurityProjectionProperties properties;
public OverseasSecurityProjectionFilter(OverseasSecurityValidationClient client, ObjectMapper objectMapper) {
public OverseasSecurityProjectionFilter(OverseasSecurityValidationClient client, ObjectMapper objectMapper,
OverseasSecurityProjectionProperties properties) {
this.client = client;
this.objectMapper = objectMapper;
this.properties = properties;
}
@Override
@@ -45,7 +51,14 @@ public class OverseasSecurityProjectionFilter implements GlobalFilter, Ordered {
return client.validate(userId, tenantId, permissionVersion)
.switchIfEmpty(Mono.error(new SecurityProjectionFailure("A0401", "海外安全状态校验不可用")))
.flatMap(result -> {
if (result.valid()) return chain.filter(exchange);
if (result.valid()) {
var request = exchange.getRequest().mutate()
.header("X-Overseas-Roles", join(result.roles()))
.header("X-Overseas-Authorities", join(result.authorities()))
.header("X-Gateway-Internal-Token", properties.internalServiceToken())
.build();
return chain.filter(exchange.mutate().request(request).build());
}
if ("USER_DISABLED".equals(result.failureReason())) {
return Mono.error(new SecurityProjectionFailure("A0401", "账号已停用"));
}
@@ -64,6 +77,10 @@ public class OverseasSecurityProjectionFilter implements GlobalFilter, Ordered {
catch (NumberFormatException exception) { return null; }
}
private static String join(Collection<String> values) {
return values.stream().sorted().collect(Collectors.joining(","));
}
@Override public int getOrder() { return -95; }
private static final class SecurityProjectionFailure extends RuntimeException {
@@ -1,8 +1,14 @@
package com.baiyue.gateway.filter;
import reactor.core.publisher.Mono;
import java.util.Set;
public interface OverseasSecurityValidationClient {
Mono<Result> validate(long userId, long tenantId, long permissionVersion);
record Result(boolean valid, String failureReason) { }
record Result(boolean valid, String failureReason, Set<String> roles, Set<String> authorities) {
public Result {
roles = roles == null ? Set.of() : Set.copyOf(roles);
authorities = authorities == null ? Set.of() : Set.copyOf(authorities);
}
}
}
@@ -18,7 +18,8 @@ import java.util.List;
public class TrustedIdentityHeaderFilter implements GlobalFilter, Ordered {
private static final List<String> TRUSTED_HEADERS = List.of(
"X-User-Id", "X-Membership-Id", "X-Tenant-Id", "X-Operator-Tenant-Id",
"X-App-Code", "X-Client-Type"
"X-App-Code", "X-Client-Type", "X-Overseas-Roles", "X-Overseas-Authorities",
"X-Gateway-Internal-Token"
);
@Override
@@ -70,4 +71,3 @@ public class TrustedIdentityHeaderFilter implements GlobalFilter, Ordered {
@Override public String getName() { return "anonymous"; }
}
}
@@ -26,19 +26,19 @@ class RequiredClaimsJwtValidatorTest {
Jwt value = Jwt.withTokenValue("token").header("alg", "RS256").subject("1001")
.issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300))
.claim("tenant_id", "0").claim("app_code", "PLATFORM")
.claim("permission_version", 3L).claim("authorities", List.of())
.claim("permission_version", 3L)
.build();
assertThat(validator.validate(value).hasErrors()).isTrue();
}
@Test
void rejectsMissingAuthorities() {
void acceptsTokenWithoutAuthoritiesBecauseProjectionOwnsAuthorization() {
Jwt value = Jwt.withTokenValue("token").header("alg", "RS256").subject("1001")
.issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300))
.claim("tenant_id", "0").claim("app_code", "PLATFORM").claim("token_version", 1)
.claim("permission_version", 3L)
.build();
assertThat(validator.validate(value).hasErrors()).isTrue();
assertThat(validator.validate(value).hasErrors()).isFalse();
}
@Test
@@ -1,6 +1,7 @@
package com.baiyue.gateway.filter;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.baiyue.gateway.config.OverseasSecurityProjectionProperties;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
@@ -13,7 +14,9 @@ import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import java.time.Instant;
import java.time.Duration;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import static org.assertj.core.api.Assertions.assertThat;
@@ -23,11 +26,15 @@ import static org.mockito.Mockito.when;
class OverseasSecurityProjectionFilterTest {
private final OverseasSecurityValidationClient client = mock(OverseasSecurityValidationClient.class);
private final OverseasSecurityProjectionFilter filter = new OverseasSecurityProjectionFilter(client, new ObjectMapper());
private final OverseasSecurityProjectionProperties properties =
new OverseasSecurityProjectionProperties("http://lastleg",
"gateway-internal-test-token-32-bytes", Duration.ofSeconds(2));
private final OverseasSecurityProjectionFilter filter =
new OverseasSecurityProjectionFilter(client, new ObjectMapper(), properties);
@Test void disabledUserFailsWithA0401() {
when(client.validate(42, 0, 8)).thenReturn(Mono.just(
new OverseasSecurityValidationClient.Result(false, "USER_DISABLED")));
new OverseasSecurityValidationClient.Result(false, "USER_DISABLED", Set.of(), Set.of())));
var exchange = exchange("/api/platform/v1/last-leg/orders");
StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete();
assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
@@ -37,7 +44,7 @@ class OverseasSecurityProjectionFilterTest {
@Test void disabledTenantAndStalePermissionFailWithA0403() {
for (String reason : new String[]{"TENANT_DISABLED", "PERMISSION_VERSION_STALE"}) {
when(client.validate(42, 0, 8)).thenReturn(Mono.just(
new OverseasSecurityValidationClient.Result(false, reason)));
new OverseasSecurityValidationClient.Result(false, reason, Set.of(), Set.of())));
var exchange = exchange("/api/merchant/v1/last-leg/orders");
StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete();
assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
@@ -54,15 +61,22 @@ class OverseasSecurityProjectionFilterTest {
@Test void acceptedSecurityStateContinuesAndNonApiPathsDoNotCallProjection() {
when(client.validate(42, 0, 8)).thenReturn(Mono.just(
new OverseasSecurityValidationClient.Result(true, null)));
new OverseasSecurityValidationClient.Result(true, null, Set.of("PLATFORM_ADMIN"),
Set.of("platform:overseas-warehouse:last-mile-order:list"))));
AtomicBoolean routed = new AtomicBoolean();
StepVerifier.create(filter.filter(exchange("/api/platform/v1/last-leg/orders"), ignored -> {
routed.set(true); return Mono.empty();
var accepted = exchange("/api/platform/v1/last-leg/orders");
StepVerifier.create(filter.filter(accepted, routedExchange -> {
routed.set(true);
assertThat(routedExchange.getRequest().getHeaders().getFirst("X-Gateway-Internal-Token"))
.isEqualTo("gateway-secret");
assertThat(routedExchange.getRequest().getHeaders().getFirst("X-Overseas-Authorities"))
.isEqualTo("platform:overseas-warehouse:last-mile-order:list");
return Mono.empty();
})).verifyComplete();
assertThat(routed).isTrue();
var isolatedClient = mock(OverseasSecurityValidationClient.class);
var isolatedFilter = new OverseasSecurityProjectionFilter(isolatedClient, new ObjectMapper());
var isolatedFilter = new OverseasSecurityProjectionFilter(isolatedClient, new ObjectMapper(), properties);
StepVerifier.create(isolatedFilter.filter(exchange("/actuator/health"), ignored -> Mono.empty())).verifyComplete();
verifyNoInteractions(isolatedClient);
}
@@ -63,6 +63,42 @@ public interface AuthorizationQueryMapper {
@Param("membershipId") long membershipId,
@Param("appCode") String appCode);
@Select("""
SELECT DISTINCT r.role_code
FROM iam_user u
JOIN iam_membership m ON m.user_id = u.id
JOIN iam_membership_role mr ON mr.membership_id = m.id AND mr.tenant_id = m.tenant_id
JOIN iam_role r ON r.id = mr.role_id
WHERE u.auth_user_id = #{userId} AND u.status = 'ACTIVE' AND u.deleted = 0
AND m.tenant_id = #{tenantId} AND m.status = 'ACTIVE' AND m.deleted = 0
AND (r.tenant_id = 0 OR r.tenant_id = #{tenantId})
AND r.app_code = #{appCode} AND r.status = 'ACTIVE' AND r.deleted = 0
ORDER BY r.role_code
""")
List<String> findRolesByIdentity(@Param("userId") long userId,
@Param("tenantId") long tenantId,
@Param("appCode") String appCode);
@Select("""
SELECT DISTINCT p.permission_code
FROM iam_user u
JOIN iam_membership m ON m.user_id = u.id
JOIN iam_membership_role mr ON mr.membership_id = m.id AND mr.tenant_id = m.tenant_id
JOIN iam_role r ON r.id = mr.role_id
JOIN iam_role_permission rp ON rp.role_id = r.id
JOIN iam_permission p ON p.id = rp.permission_id
WHERE u.auth_user_id = #{userId} AND u.status = 'ACTIVE' AND u.deleted = 0
AND m.tenant_id = #{tenantId} AND m.status = 'ACTIVE' AND m.deleted = 0
AND (r.tenant_id = 0 OR r.tenant_id = #{tenantId})
AND r.app_code = #{appCode} AND r.status = 'ACTIVE' AND r.deleted = 0
AND (p.tenant_id = 0 OR p.tenant_id = #{tenantId})
AND p.app_code = #{appCode} AND p.status = 'ACTIVE' AND p.deleted = 0
ORDER BY p.permission_code
""")
List<String> findPermissionsByIdentity(@Param("userId") long userId,
@Param("tenantId") long tenantId,
@Param("appCode") String appCode);
@Select("SELECT COALESCE(MAX(permission_version),0) FROM iam_security_subject_version WHERE auth_user_id=#{userId}")
long findPermissionVersion(@Param("userId") long userId);
@@ -7,6 +7,7 @@ import com.baiyue.common.event.EventPayloadHash;
import com.baiyue.common.event.EventTypes;
import com.baiyue.common.event.IamSecurityEventsV1;
import com.baiyue.common.event.RegionCode;
import com.baiyue.iam.mapper.AuthorizationQueryMapper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
@@ -14,12 +15,20 @@ import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
/** 在 IAM 状态事务内递增安全版本并写入可靠事件发送箱。 */
@Service
public class SecurityEventOutboxService {
private static final List<String> OVERSEAS_AUTHORITY_PREFIXES = List.of(
"platform:overseas-warehouse:", "platform:overseas-business:",
"platform:overseas-master-data:", "merchant:warehouse:",
"merchant:cross-border:", "platform:finance:", "lastleg:");
@Resource private JdbcTemplate jdbc;
@Resource private ObjectMapper objectMapper;
@Resource private AuthorizationQueryMapper authorizationQueries;
public void userStatus(long authUserId, long tenantId, String appCode, String status) {
long version = bump(authUserId);
@@ -39,8 +48,13 @@ public class SecurityEventOutboxService {
public void permissionChanged(long authUserId, long tenantId, String appCode, String reason) {
long version = bump(authUserId);
Set<String> roles = Set.copyOf(authorizationQueries.findRolesByIdentity(authUserId, tenantId, appCode));
Set<String> authorities = authorizationQueries.findPermissionsByIdentity(authUserId, tenantId, appCode)
.stream().filter(permission -> permission != null && OVERSEAS_AUTHORITY_PREFIXES.stream()
.anyMatch(permission::startsWith)).collect(Collectors.toUnmodifiableSet());
write(EventTypes.IAM_PERMISSION_VERSION_CHANGED, "AUTHORIZATION", String.valueOf(authUserId), tenantId, version,
new IamSecurityEventsV1.PermissionVersionChanged(authUserId, tenantId, appCode, version, reason, Instant.now()));
new IamSecurityEventsV1.PermissionVersionChanged(authUserId, tenantId, appCode, version, reason,
Instant.now(), roles, authorities));
}
private long bump(long authUserId) {
@@ -80,7 +80,9 @@ class SecurityEventOutboxRelayTest {
private EventEnvelope envelope(RegionCode target) {
var value = new IamSecurityEventsV1.PermissionVersionChanged(
101L, 0L, "PLATFORM", 3L, "contract", Instant.parse("2026-08-25T07:00:00Z"));
101L, 0L, "PLATFORM", 3L, "contract", Instant.parse("2026-08-25T07:00:00Z"),
java.util.Set.of("PLATFORM_ADMIN"),
java.util.Set.of("platform:overseas-warehouse:last-mile-order:list"));
var payload = objectMapper.valueToTree(value);
return new EventEnvelope("00000000-0000-4000-8000-000000000081",
EventTypes.IAM_PERMISSION_VERSION_CHANGED, IamSecurityEventsV1.VERSION,
@@ -2,6 +2,7 @@ package com.baiyue.iam.service;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.IamSecurityEventsV1;
import com.baiyue.iam.mapper.AuthorizationQueryMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -14,6 +15,8 @@ import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
@@ -24,6 +27,7 @@ import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class SecurityEventOutboxServiceTest {
@Mock JdbcTemplate jdbc;
@Mock AuthorizationQueryMapper authorizationQueries;
private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();
private SecurityEventOutboxService service;
@@ -32,6 +36,7 @@ class SecurityEventOutboxServiceTest {
service = new SecurityEventOutboxService();
ReflectionTestUtils.setField(service, "jdbc", jdbc);
ReflectionTestUtils.setField(service, "objectMapper", objectMapper);
ReflectionTestUtils.setField(service, "authorizationQueries", authorizationQueries);
}
@Test
@@ -71,4 +76,27 @@ class SecurityEventOutboxServiceTest {
assertThat(payload.status()).isEqualTo(expectedSecurityStatus);
assertThat(envelope.aggregateVersion()).isEqualTo(3L);
}
@Test
void permissionEventCarriesOnlyTheOverseasAuthorizationSnapshot() throws Exception {
when(jdbc.queryForObject(anyString(), eq(Long.class), any())).thenReturn(7L);
when(authorizationQueries.findRolesByIdentity(101L, 0L, "ADMIN"))
.thenReturn(List.of("PLATFORM_ADMIN"));
when(authorizationQueries.findPermissionsByIdentity(101L, 0L, "ADMIN"))
.thenReturn(List.of("platform:overseas-warehouse:orders:view", "platform:iam:users:view",
"lastleg:orders:view"));
service.permissionChanged(101L, 0L, "ADMIN", "ROLE_CHANGED");
ArgumentCaptor<Object[]> parameters = ArgumentCaptor.forClass(Object[].class);
verify(jdbc).update(eq("INSERT INTO iam_security_event_outbox(event_id,event_type,aggregate_type,aggregate_id,payload_json) "
+ "VALUES (?,?,?,?,CAST(? AS JSON))"), parameters.capture());
EventEnvelope envelope = objectMapper.readValue(String.valueOf(parameters.getValue()[4]), EventEnvelope.class);
IamSecurityEventsV1.PermissionVersionChanged payload = objectMapper.treeToValue(
envelope.payload(), IamSecurityEventsV1.PermissionVersionChanged.class);
assertThat(payload.roles()).containsExactly("PLATFORM_ADMIN");
assertThat(payload.authorities()).containsExactlyInAnyOrder(
"platform:overseas-warehouse:orders:view", "lastleg:orders:view");
assertThat(payload.authorities()).doesNotContain("platform:iam:users:view");
}
}
@@ -4,6 +4,7 @@ import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import java.time.Instant;
import java.util.Set;
public final class LastlegSecurityDtos {
private LastlegSecurityDtos() { }
@@ -14,10 +15,17 @@ public final class LastlegSecurityDtos {
@NotNull @Min(0) Long tenantId,
@NotNull @Min(0) Long permissionVersion) { }
public record ValidationResponse(boolean valid, FailureReason failureReason) {
public static ValidationResponse accepted() { return new ValidationResponse(true, null); }
public record ValidationResponse(boolean valid, FailureReason failureReason,
Set<String> roles, Set<String> authorities) {
public ValidationResponse {
roles = roles == null ? Set.of() : Set.copyOf(roles);
authorities = authorities == null ? Set.of() : Set.copyOf(authorities);
}
public static ValidationResponse accepted(Set<String> roles, Set<String> authorities) {
return new ValidationResponse(true, null, roles, authorities);
}
public static ValidationResponse rejected(FailureReason reason) {
return new ValidationResponse(false, reason);
return new ValidationResponse(false, reason, Set.of(), Set.of());
}
}
@@ -19,6 +19,7 @@ import java.time.Instant;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
@Service
public class LastlegSecurityProjectionService {
@@ -103,7 +104,7 @@ public class LastlegSecurityProjectionService {
requireStatus(value.status());
yield new ProjectionInput("USER", String.valueOf(value.authUserId()), value.tenantId(),
value.status(), value.permissionVersion(), envelope.aggregateVersion(),
payloadHash, envelope.eventId(), value.changedAt());
payloadHash, envelope.eventId(), value.changedAt(), Set.of(), Set.of());
}
case EventTypes.IAM_TENANT_STATUS_CHANGED -> {
var value = objectMapper.treeToValue(envelope.payload(),
@@ -114,7 +115,7 @@ public class LastlegSecurityProjectionService {
requireStatus(value.status());
yield new ProjectionInput("TENANT", String.valueOf(value.tenantId()), value.tenantId(),
value.status(), null, envelope.aggregateVersion(), payloadHash,
envelope.eventId(), value.changedAt());
envelope.eventId(), value.changedAt(), Set.of(), Set.of());
}
case EventTypes.IAM_PERMISSION_VERSION_CHANGED -> {
var value = objectMapper.treeToValue(envelope.payload(),
@@ -122,9 +123,10 @@ public class LastlegSecurityProjectionService {
requireIdentity(envelope, "AUTHORIZATION", value.authUserId(), value.tenantId());
requirePayload(value.appCode(), value.changedAt(), value.permissionVersion(),
envelope.aggregateVersion());
requireAuthorizationSnapshot(value.roles(), value.authorities());
yield new ProjectionInput("AUTHORIZATION", String.valueOf(value.authUserId()), value.tenantId(),
null, value.permissionVersion(), envelope.aggregateVersion(), payloadHash,
envelope.eventId(), value.changedAt());
envelope.eventId(), value.changedAt(), value.roles(), value.authorities());
}
default -> throw new IllegalArgumentException("unsupported type");
};
@@ -158,6 +160,14 @@ public class LastlegSecurityProjectionService {
}
}
private static void requireAuthorizationSnapshot(Set<String> roles, Set<String> authorities) {
if (roles == null || authorities == null
|| roles.stream().anyMatch(value -> value == null || value.isBlank())
|| authorities.stream().anyMatch(value -> value == null || value.isBlank())) {
throw new BizException(ResultCode.BAD_REQUEST, "IAM 海外授权快照无效");
}
}
private ProjectionRow findForUpdate(String type, String id) {
List<ProjectionRow> rows = jdbc.query(
"SELECT aggregate_version,payload_hash FROM ll_security_projection "
@@ -167,15 +177,24 @@ public class LastlegSecurityProjectionService {
}
private void upsert(ProjectionInput value) {
final String rolesJson;
final String authoritiesJson;
try {
rolesJson = objectMapper.writeValueAsString(value.roles());
authoritiesJson = objectMapper.writeValueAsString(value.authorities());
} catch (Exception exception) {
throw new IllegalStateException("海外授权快照序列化失败", exception);
}
jdbc.update("INSERT INTO ll_security_projection(aggregate_type,aggregate_id,tenant_id,security_status,"
+ "permission_version,aggregate_version,payload_hash,last_event_id,changed_at,updated_at) "
+ "VALUES (?,?,?,?,?,?,?,?,?,NOW(3)) ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id),"
+ "permission_version,roles_json,authorities_json,aggregate_version,payload_hash,last_event_id,changed_at,updated_at) "
+ "VALUES (?,?,?,?,?,?,?,?,?,?,?,NOW(3)) ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id),"
+ "security_status=VALUES(security_status),permission_version=VALUES(permission_version),"
+ "roles_json=VALUES(roles_json),authorities_json=VALUES(authorities_json),"
+ "aggregate_version=VALUES(aggregate_version),payload_hash=VALUES(payload_hash),"
+ "last_event_id=VALUES(last_event_id),changed_at=VALUES(changed_at),updated_at=NOW(3)",
value.aggregateType(), value.aggregateId(), value.tenantId(), value.status(),
value.permissionVersion(), value.aggregateVersion(), value.payloadHash(), value.eventId(),
value.changedAt());
value.permissionVersion(), rolesJson, authoritiesJson, value.aggregateVersion(),
value.payloadHash(), value.eventId(), value.changedAt());
}
private record InboxRow(String eventType, String payloadHash, String status) { }
@@ -184,5 +203,6 @@ public class LastlegSecurityProjectionService {
public record ProjectionChanged(ProjectionInput projection) { }
public record ProjectionInput(String aggregateType, String aggregateId, long tenantId, String status,
Long permissionVersion, long aggregateVersion, String payloadHash,
String eventId, Instant changedAt) { }
String eventId, Instant changedAt, Set<String> roles,
Set<String> authorities) { }
}
@@ -3,17 +3,22 @@ package com.baiyue.lastleg.security.service;
import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.FailureReason;
import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.ValidationRequest;
import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.ValidationResponse;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Set;
@Service
public class LastlegSecurityValidationService {
private final JdbcTemplate jdbc;
private final ObjectMapper objectMapper;
public LastlegSecurityValidationService(JdbcTemplate jdbc) {
public LastlegSecurityValidationService(JdbcTemplate jdbc, ObjectMapper objectMapper) {
this.jdbc = jdbc;
this.objectMapper = objectMapper;
}
public ValidationResponse validate(ValidationRequest request) {
@@ -34,10 +39,10 @@ public class LastlegSecurityValidationService {
}
}
List<Long> projectedVersions = jdbc.query(
"SELECT MAX(permission_version) FROM ll_security_projection "
+ "WHERE aggregate_id=? AND tenant_id=? "
+ "AND aggregate_type IN ('USER','AUTHORIZATION') HAVING COUNT(permission_version)>0",
(rs, row) -> rs.getLong(1), String.valueOf(request.userId()), request.tenantId());
"SELECT permission_version FROM ll_security_projection "
+ "WHERE aggregate_type='AUTHORIZATION' AND aggregate_id=? AND tenant_id=?",
(rs, row) -> rs.getLong(1),
String.valueOf(request.userId()), request.tenantId());
if (projectedVersions.isEmpty()) {
return ValidationResponse.rejected(FailureReason.SECURITY_PROJECTION_MISSING);
}
@@ -48,7 +53,19 @@ public class LastlegSecurityValidationService {
if (request.permissionVersion() > projectedVersion) {
return ValidationResponse.rejected(FailureReason.SECURITY_PROJECTION_NOT_READY);
}
return ValidationResponse.accepted();
List<String> snapshots = jdbc.query(
"SELECT CONCAT(roles_json, CHAR(10), authorities_json) FROM ll_security_projection "
+ "WHERE aggregate_type='AUTHORIZATION' AND aggregate_id=? AND tenant_id=?",
(rs, row) -> rs.getString(1), String.valueOf(request.userId()), request.tenantId());
if (snapshots.isEmpty() || snapshots.get(0) == null || !snapshots.get(0).contains("\n")) {
return ValidationResponse.rejected(FailureReason.SECURITY_PROJECTION_MISSING);
}
try {
String[] values = snapshots.get(0).split("\n", 2);
return ValidationResponse.accepted(readSet(values[0]), readSet(values[1]));
} catch (Exception exception) {
return ValidationResponse.rejected(FailureReason.SECURITY_PROJECTION_MISSING);
}
}
private String status(String type, String id, long tenantId) {
@@ -57,4 +74,9 @@ public class LastlegSecurityValidationService {
(rs, row) -> rs.getString(1), type, id, tenantId);
return values.isEmpty() ? null : values.get(0);
}
private Set<String> readSet(String json) throws Exception {
if (json == null || json.isBlank()) return Set.of();
return objectMapper.readValue(json, new TypeReference<Set<String>>() { });
}
}
@@ -82,7 +82,7 @@ baiyue:
security:
internal-service-token: ${INTERNAL_SERVICE_TOKEN:}
jwt-mode: jwks
session-mode: claims
session-mode: gateway
issuer: ${JWT_ISSUER:auth-service}
audience: ${JWT_AUDIENCE:lastleg-api}
jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json}
@@ -1295,9 +1295,11 @@ CREATE TABLE IF NOT EXISTS ll_outbound_pda_pick (
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='outbound_type'), 'SELECT 1', 'ALTER TABLE ll_outbound_order ADD COLUMN outbound_type VARCHAR(32) NOT NULL DEFAULT ''DISPATCH_DELIVERY'' COMMENT ''出库类型'' AFTER status'));
PREPARE outbound_upgrade_stmt FROM @ddl; EXECUTE outbound_upgrade_stmt; DEALLOCATE PREPARE outbound_upgrade_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='lx_codes'), 'SELECT 1', 'ALTER TABLE ll_outbound_order ADD COLUMN lx_codes VARCHAR(1000) NULL COMMENT ''关联一件代发领星入库单代码汇总'' AFTER outbound_type'));
PREPARE outbound_upgrade_stmt FROM @ddl; EXECUTE outbound_upgrade_stmt; DEALLOCATE PREPARE outbound_upgrade_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='sku_code'), 'SELECT 1', 'ALTER TABLE ll_outbound_pallet_item ADD COLUMN sku_code VARCHAR(128) NULL COMMENT ''一件代发SKU'''));
PREPARE outbound_upgrade_stmt FROM @ddl; EXECUTE outbound_upgrade_stmt; DEALLOCATE PREPARE outbound_upgrade_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='recipient_info'), 'SELECT 1', 'ALTER TABLE ll_outbound_order ADD COLUMN recipient_info VARCHAR(1000) NULL COMMENT ''一件代发收件信息'' AFTER outbound_type'));
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='recipient_info'), 'SELECT 1', 'ALTER TABLE ll_outbound_order ADD COLUMN recipient_info VARCHAR(1000) NULL COMMENT ''一件代发收件信息'' AFTER lx_codes'));
PREPARE outbound_upgrade_stmt FROM @ddl; EXECUTE outbound_upgrade_stmt; DEALLOCATE PREPARE outbound_upgrade_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='picked_package_count'), 'SELECT 1', 'ALTER TABLE ll_outbound_pallet_item ADD COLUMN picked_package_count INT NOT NULL DEFAULT 0 COMMENT ''已拣箱数'' AFTER package_count'));
PREPARE outbound_upgrade_stmt FROM @ddl; EXECUTE outbound_upgrade_stmt; DEALLOCATE PREPARE outbound_upgrade_stmt;
@@ -1871,6 +1873,8 @@ CREATE TABLE IF NOT EXISTS ll_security_projection (
tenant_id BIGINT NOT NULL COMMENT '平台0或客户ID',
security_status VARCHAR(24) NULL COMMENT '当前安全状态:ACTIVE或DISABLED',
permission_version BIGINT NULL COMMENT '当前单调权限版本',
roles_json JSON NOT NULL COMMENT 'IAM投影的当前角色快照',
authorities_json JSON NOT NULL COMMENT 'IAM投影的当前海外权限快照',
aggregate_version BIGINT NOT NULL COMMENT '事件聚合单调版本',
payload_hash CHAR(64) NOT NULL COMMENT '当前版本载荷哈希',
last_event_id VARCHAR(64) NOT NULL COMMENT '最后应用的事件ID',
@@ -1883,12 +1887,12 @@ CREATE TABLE IF NOT EXISTS ll_security_projection (
-- 开发基线固定管理员;后续真实 IAM 事件从 aggregate_version=1 单调覆盖该 version=0 投影。
INSERT IGNORE INTO ll_security_projection (
aggregate_type,aggregate_id,tenant_id,security_status,permission_version,aggregate_version,
aggregate_type,aggregate_id,tenant_id,security_status,permission_version,roles_json,authorities_json,aggregate_version,
payload_hash,last_event_id,changed_at,updated_at
) VALUES
('USER','1000001',0,'ACTIVE',0,0,SHA2('lastleg-baseline:platform_admin:USER:0',256),
('USER','1000001',0,'ACTIVE',0,JSON_ARRAY(),JSON_ARRAY(),0,SHA2('lastleg-baseline:platform_admin:USER:0',256),
'baseline-platform-admin-user-v0','1970-01-01 00:00:00.000','1970-01-01 00:00:00.000'),
('AUTHORIZATION','1000001',0,NULL,0,0,SHA2('lastleg-baseline:platform_admin:AUTHORIZATION:0',256),
('AUTHORIZATION','1000001',0,NULL,0,JSON_ARRAY(),JSON_ARRAY(),0,SHA2('lastleg-baseline:platform_admin:AUTHORIZATION:0',256),
'baseline-platform-admin-authorization-v0','1970-01-01 00:00:00.000','1970-01-01 00:00:00.000');
@@ -44,6 +44,7 @@ class Phase5OverseasUpgradeSqlContractTest {
"ALTER TABLE ll_outbound_pallet_item RENAME COLUMN tenant_id TO customer_id",
"ALTER TABLE ll_delivery_order RENAME COLUMN tenant_id TO customer_id",
"ALTER TABLE ll_outbound_order ADD COLUMN outbound_type",
"ALTER TABLE ll_outbound_order ADD COLUMN lx_codes",
"ALTER TABLE ll_outbound_pallet_item ADD COLUMN picked_package_count",
"ALTER TABLE ll_delivery_order ADD COLUMN overseas_warehouse_name",
"ALTER TABLE ll_delivery_order ADD COLUMN waybill_no",
@@ -21,10 +21,10 @@ class LastlegSecurityContractTest {
"PRIMARY KEY (event_id)", "payload_hash CHAR(64)",
"CREATE TABLE IF NOT EXISTS ll_security_projection",
"PRIMARY KEY (aggregate_type,aggregate_id)", "aggregate_version BIGINT",
"permission_version BIGINT");
"permission_version BIGINT", "roles_json JSON", "authorities_json JSON");
for (String sql : new String[]{baseline, migration}) {
assertThat(sql).contains("('USER','1000001',0,'ACTIVE',0,0",
"('AUTHORIZATION','1000001',0,NULL,0,0",
assertThat(sql).contains("('USER','1000001',0,'ACTIVE',0,JSON_ARRAY(),JSON_ARRAY(),0",
"('AUTHORIZATION','1000001',0,NULL,0,JSON_ARRAY(),JSON_ARRAY(),0",
"baseline-platform-admin-user-v0",
"baseline-platform-admin-authorization-v0")
.doesNotContain("baseline-merchant-admin");
@@ -39,7 +39,7 @@ class LastlegSecurityContractTest {
assertThat(service).contains("EventPayloadHash.matches", "validatedStorageHash")
.doesNotContain("LoadingCompletedConsumer");
assertThat(mirror).contains("TransactionPhase.AFTER_COMMIT", "Redis 镜像写入失败");
assertThat(validation).contains("SELECT MAX(permission_version)", "tenant_id=?",
assertThat(validation).contains("SELECT permission_version", "authorities_json", "tenant_id=?",
"SECURITY_PROJECTION_MISSING", "SECURITY_PROJECTION_NOT_READY", "USER_DISABLED",
"TENANT_DISABLED", "PERMISSION_VERSION_STALE");
assertThat(validationDto).contains("Long userId", "Long tenantId", "Long permissionVersion")
@@ -43,7 +43,9 @@ class LastlegSecurityProjectionTransportTest {
private EventEnvelope envelope() {
var value = new IamSecurityEventsV1.PermissionVersionChanged(
101L, 0L, "PLATFORM", 3L, "contract", Instant.parse("2026-08-25T07:00:00Z"));
101L, 0L, "PLATFORM", 3L, "contract", Instant.parse("2026-08-25T07:00:00Z"),
java.util.Set.of("PLATFORM_ADMIN"),
java.util.Set.of("platform:overseas-warehouse:last-mile-order:list"));
var payload = objectMapper.valueToTree(value);
return new EventEnvelope("00000000-0000-4000-8000-000000000091",
EventTypes.IAM_PERMISSION_VERSION_CHANGED, IamSecurityEventsV1.VERSION,
@@ -5,6 +5,7 @@ import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.ValidationRequest;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.List;
@@ -16,7 +17,7 @@ import static org.mockito.Mockito.mock;
class LastlegSecurityValidationServiceTest {
private final JdbcTemplate jdbc = mock(JdbcTemplate.class);
private final LastlegSecurityValidationService service = new LastlegSecurityValidationService(jdbc);
private final LastlegSecurityValidationService service = new LastlegSecurityValidationService(jdbc, new ObjectMapper());
@Test void disabledUserFailsClosed() {
doReturn(List.of("DISABLED")).when(jdbc)
@@ -40,7 +41,8 @@ class LastlegSecurityValidationServiceTest {
}
@Test void customerProjectionIsMandatoryButPlatformTenantDoesNotNeedOne() {
doReturn(List.of("ACTIVE"), List.of(), List.of("ACTIVE"), List.of(8L)).when(jdbc)
doReturn(List.of("ACTIVE"), List.of(), List.of("ACTIVE"), List.of(8L),
List.of("[]\n[\"platform:overseas-warehouse:last-mile-order:list\"]")).when(jdbc)
.query(anyString(), any(RowMapper.class), any(Object[].class));
assertThat(service.validate(new ValidationRequest(42L, 100L, 8L)).failureReason())
.isEqualTo(FailureReason.SECURITY_PROJECTION_MISSING);
@@ -49,7 +51,7 @@ class LastlegSecurityValidationServiceTest {
@Test void permissionVersionMustExactlyMatchReliableProjection() {
doReturn(List.of("ACTIVE"), List.of(9L), List.of("ACTIVE"), List.of(9L),
List.of("ACTIVE"), List.of(9L)).when(jdbc)
List.of("ACTIVE"), List.of(9L), List.of("[]\n[]")).when(jdbc)
.query(anyString(), any(RowMapper.class), any(Object[].class));
assertThat(service.validate(new ValidationRequest(42L, 0L, 8L)).failureReason())
.isEqualTo(FailureReason.PERMISSION_VERSION_STALE);
@@ -46,7 +46,7 @@ baiyue:
token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes}
security:
jwt-mode: jwks
session-mode: claims
session-mode: gateway
issuer: ${JWT_ISSUER:auth-service}
audience: ${JWT_AUDIENCE:lastleg-api}
jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json}