fix: project overseas authorization outside jwt

This commit is contained in:
2026-09-07 11:37:06 +08:00
parent 6b479d1a68
commit 4963889024
33 changed files with 382 additions and 92 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);
}