1. 修复界面bug

2. 新增 api 可配置匿名访问固定用户配置
3. 新增密码弱口令校验规则
4. e 办使用 loginName 确认唯一用户逻辑
This commit is contained in:
chenbowen
2025-10-24 17:02:10 +08:00
parent 27796ff67d
commit 6e4cc4d55e
56 changed files with 1268 additions and 246 deletions

View File

@@ -8,6 +8,7 @@ import com.zt.plat.module.databus.controller.admin.gateway.vo.credential.ApiClie
import com.zt.plat.module.databus.controller.admin.gateway.vo.credential.ApiClientCredentialSaveReqVO;
import com.zt.plat.module.databus.controller.admin.gateway.vo.credential.ApiClientCredentialSimpleRespVO;
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiClientCredentialDO;
import com.zt.plat.module.databus.service.gateway.ApiAnonymousUserService;
import com.zt.plat.module.databus.service.gateway.ApiClientCredentialService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -26,6 +27,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import static com.zt.plat.framework.common.pojo.CommonResult.success;
import static org.springframework.util.CollectionUtils.isEmpty;
@Tag(name = "管理后台 - API 客户端凭证")
@RestController
@@ -35,19 +37,24 @@ import static com.zt.plat.framework.common.pojo.CommonResult.success;
public class ApiClientCredentialController {
private final ApiClientCredentialService credentialService;
private final ApiAnonymousUserService anonymousUserService;
@GetMapping("/page")
@Operation(summary = "分页查询客户端凭证")
public CommonResult<PageResult<ApiClientCredentialRespVO>> page(ApiClientCredentialPageReqVO reqVO) {
PageResult<ApiClientCredentialDO> page = credentialService.getPage(reqVO);
return success(ApiClientCredentialConvert.INSTANCE.convertPage(page));
PageResult<ApiClientCredentialRespVO> respPage = ApiClientCredentialConvert.INSTANCE.convertPage(page);
populateAnonymousInfo(respPage.getList());
return success(respPage);
}
@GetMapping("/get")
@Operation(summary = "查询凭证详情")
public CommonResult<ApiClientCredentialRespVO> get(@RequestParam("id") Long id) {
ApiClientCredentialDO credential = credentialService.get(id);
return success(ApiClientCredentialConvert.INSTANCE.convert(credential));
ApiClientCredentialRespVO respVO = ApiClientCredentialConvert.INSTANCE.convert(credential);
populateAnonymousInfo(List.of(respVO));
return success(respVO);
}
@PostMapping("/create")
@@ -76,4 +83,14 @@ public class ApiClientCredentialController {
List<ApiClientCredentialDO> list = credentialService.listEnabled();
return success(ApiClientCredentialConvert.INSTANCE.convertSimpleList(list));
}
private void populateAnonymousInfo(List<ApiClientCredentialRespVO> list) {
if (isEmpty(list)) {
return;
}
list.stream()
.filter(item -> Boolean.TRUE.equals(item.getAllowAnonymous()) && item.getAnonymousUserId() != null)
.forEach(item -> anonymousUserService.find(item.getAnonymousUserId())
.ifPresent(details -> item.setAnonymousUserNickname(details.getNickname())));
}
}

View File

@@ -5,6 +5,7 @@ import com.zt.plat.module.databus.controller.admin.gateway.convert.ApiDefinition
import com.zt.plat.module.databus.controller.admin.gateway.vo.ApiGatewayInvokeReqVO;
import com.zt.plat.module.databus.controller.admin.gateway.vo.definition.ApiDefinitionDetailRespVO;
import com.zt.plat.module.databus.framework.integration.gateway.core.ApiGatewayExecutionService;
import com.zt.plat.module.databus.framework.integration.gateway.core.IntegrationFlowManager;
import com.zt.plat.module.databus.framework.integration.gateway.model.ApiGatewayResponse;
import com.zt.plat.module.databus.service.gateway.ApiDefinitionService;
import io.swagger.v3.oas.annotations.Operation;
@@ -27,6 +28,7 @@ public class ApiGatewayController {
private final ApiGatewayExecutionService executionService;
private final ApiDefinitionService apiDefinitionService;
private final IntegrationFlowManager integrationFlowManager;
@PostMapping(value = "/invoke", consumes = MediaType.APPLICATION_JSON_VALUE)
@Operation(summary = "测试调用 API 编排")
@@ -43,4 +45,12 @@ public class ApiGatewayController {
return success(definitions);
}
@PostMapping("/cache/refresh")
@Operation(summary = "刷新 API 缓存")
public CommonResult<Boolean> refreshCache() {
apiDefinitionService.refreshAllCache();
integrationFlowManager.refreshAll();
return success(Boolean.TRUE);
}
}

View File

@@ -33,6 +33,15 @@ public class ApiClientCredentialRespVO {
@Schema(description = "备注", example = "默认应用凭证")
private String remark;
@Schema(description = "允许匿名访问", example = "false")
private Boolean allowAnonymous;
@Schema(description = "匿名访问固定用户 ID", example = "1024")
private Long anonymousUserId;
@Schema(description = "匿名访问固定用户昵称", example = "张三")
private String anonymousUserNickname;
@Schema(description = "创建时间")
private LocalDateTime createTime;

View File

@@ -38,4 +38,11 @@ public class ApiClientCredentialSaveReqVO {
@Schema(description = "备注", example = "默认应用凭证")
private String remark;
@Schema(description = "允许匿名访问", example = "false")
@NotNull(message = "匿名访问标识不能为空")
private Boolean allowAnonymous;
@Schema(description = "匿名访问固定用户 ID", example = "1024")
private Long anonymousUserId;
}

View File

@@ -34,4 +34,8 @@ public class ApiClientCredentialDO extends BaseDO {
private String remark;
private Boolean allowAnonymous;
private Long anonymousUserId;
}

View File

@@ -17,6 +17,10 @@ import java.util.Set;
@ConfigurationProperties(prefix = "databus.api-portal")
public class ApiGatewayProperties {
public static String APP_ID_HEADER = "ZT-App-Id";
public static String TIMESTAMP_HEADER = "ZT-Timestamp";
public static String NONCE_HEADER = "ZT-Nonce";
public static String SIGNATURE_HEADER = "ZT-Signature";
public static final String DEFAULT_BASE_PATH = "/admin-api/databus/api/portal";
public static final String LEGACY_BASE_PATH = "/databus/api/portal";
@@ -62,14 +66,6 @@ public class ApiGatewayProperties {
private boolean enabled = true;
private String appIdHeader = "ZT-App-Id";
private String timestampHeader = "ZT-Timestamp";
private String nonceHeader = "ZT-Nonce";
private String signatureHeader = "ZT-Signature";
private String signatureType = CryptoSignatureUtils.SIGNATURE_TYPE_MD5;
private String encryptionType = CryptoSignatureUtils.ENCRYPT_TYPE_AES;

View File

@@ -0,0 +1,24 @@
package com.zt.plat.module.databus.framework.integration.gateway.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.reactive.function.client.WebClientCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class GatewayWebClientConfiguration {
private final int maxInMemorySize;
public GatewayWebClientConfiguration(
@Value("${databus.gateway.web-client.max-in-memory-size:2097152}") int maxInMemorySize) {
this.maxInMemorySize = maxInMemorySize;
}
@Bean
public WebClientCustomizer gatewayWebClientCustomizer() {
return builder -> builder.codecs(configurer ->
configurer.defaultCodecs().maxInMemorySize(maxInMemorySize));
}
}

View File

@@ -3,11 +3,17 @@ package com.zt.plat.module.databus.framework.integration.gateway.security;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.zt.plat.framework.common.util.json.JsonUtils;
import com.zt.plat.framework.common.util.security.CryptoSignatureUtils;
import com.zt.plat.framework.common.util.servlet.ServletUtils;
import com.zt.plat.framework.security.core.LoginUser;
import com.zt.plat.framework.security.core.util.SecurityFrameworkUtils;
import com.zt.plat.framework.tenant.core.context.TenantContextHolder;
import com.zt.plat.framework.web.core.util.WebFrameworkUtils;
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiClientCredentialDO;
import com.zt.plat.module.databus.framework.integration.config.ApiGatewayProperties;
import com.zt.plat.module.databus.framework.integration.gateway.model.ApiGatewayResponse;
import com.zt.plat.module.databus.service.gateway.ApiAnonymousUserService;
import com.zt.plat.module.databus.service.gateway.ApiClientCredentialService;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
@@ -25,6 +31,7 @@ import org.springframework.web.util.ContentCachingResponseWrapper;
import org.springframework.web.util.UriComponentsBuilder;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
@@ -32,6 +39,9 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static com.zt.plat.framework.common.util.security.CryptoSignatureUtils.SIGNATURE_FIELD;
import static com.zt.plat.module.databus.framework.integration.config.ApiGatewayProperties.*;
/**
* 对进入网关的请求统一执行 IP 校验、报文签名、加解密与防重复校验。
*/
@@ -43,6 +53,7 @@ public class GatewaySecurityFilter extends OncePerRequestFilter {
private final ApiGatewayProperties properties;
private final StringRedisTemplate stringRedisTemplate;
private final ApiClientCredentialService credentialService;
private final ApiAnonymousUserService anonymousUserService;
private final ObjectMapper objectMapper;
private final AntPathMatcher pathMatcher = new AntPathMatcher();
private static final TypeReference<Map<String, Object>> MAP_TYPE = new TypeReference<>() {};
@@ -51,6 +62,7 @@ public class GatewaySecurityFilter extends OncePerRequestFilter {
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String pathWithinApplication = pathWithinApplication(request);
// 仅处理配置的 API 门户路径,不符合的请求直接放行
boolean matchesPortalPath = properties.getAllBasePaths()
.stream()
.map(this::normalizeBasePath)
@@ -59,6 +71,7 @@ public class GatewaySecurityFilter extends OncePerRequestFilter {
filterChain.doFilter(request, response);
return;
}
// 校验访问 IP 是否落在允许范围内
if (!isIpAllowed(request)) {
log.warn("[API-PORTAL] 拦截来自 IP {} 访问 {} 的请求", request.getRemoteAddr(), pathWithinApplication);
response.sendError(HttpStatus.FORBIDDEN.value(), "IP 禁止访问");
@@ -72,28 +85,46 @@ public class GatewaySecurityFilter extends OncePerRequestFilter {
}
try {
Long tenantId = resolveTenantId(request);
String appId = requireHeader(request, security.getAppIdHeader(), "缺少应用标识");
// 从请求头解析 appId 并加载客户端凭证,包含匿名访问配置
String appId = requireHeader(request, APP_ID_HEADER, "缺少应用标识");
credential = credentialService.findActiveCredential(appId)
.orElseThrow(() -> new SecurityValidationException(HttpStatus.UNAUTHORIZED, "应用凭证不存在或已禁用"));
boolean allowAnonymous = Boolean.TRUE.equals(credential.getAllowAnonymous());
ApiAnonymousUserService.AnonymousUserDetails anonymousDetails = null;
if (allowAnonymous) {
Long anonymousUserId = credential.getAnonymousUserId();
if (anonymousUserId == null) {
throw new SecurityValidationException(HttpStatus.UNAUTHORIZED, "匿名访问未配置固定用户");
}
anonymousDetails = anonymousUserService.find(anonymousUserId)
.orElseThrow(() -> new SecurityValidationException(HttpStatus.UNAUTHORIZED, "匿名访问固定用户不可用"));
}
String timestampHeader = requireHeader(request, security.getTimestampHeader(), "缺少时间戳");
String timestampHeader = requireHeader(request, TIMESTAMP_HEADER, "缺少时间戳");
// 校验时间戳与随机数,防止请求被重放
validateTimestamp(timestampHeader, security);
String nonce = requireHeader(request, security.getNonceHeader(), "缺少随机数");
String nonce = requireHeader(request, NONCE_HEADER, "缺少随机数");
if (nonce.length() < 8) {
throw new SecurityValidationException(HttpStatus.BAD_REQUEST, "随机数长度不足");
}
String signature = requireHeader(request, security.getSignatureHeader(), "缺少签名");
String signature = requireHeader(request, SIGNATURE_HEADER, "缺少签名");
byte[] originalBody = StreamUtils.copyToByteArray(request.getInputStream());
// 尝试按凭证配置解密请求体,并构建签名载荷进行校验
byte[] decryptedBody = decryptRequestBody(originalBody, credential, security);
verifySignature(request, decryptedBody, signature, credential, security);
verifySignature(request, decryptedBody, signature, credential, security, appId, timestampHeader);
ensureNonce(tenantId, appId, nonce, security);
// 使用可重复读取的请求包装,供后续过滤器继续消费
CachedBodyHttpServletRequest securedRequest = new CachedBodyHttpServletRequest(request, decryptedBody);
if (StringUtils.hasText(request.getCharacterEncoding())) {
securedRequest.setCharacterEncoding(request.getCharacterEncoding());
}
propagateJwtToken(request, securedRequest);
if (allowAnonymous) {
applyAnonymousLogin(securedRequest, anonymousDetails);
} else {
propagateJwtToken(request, securedRequest);
}
ContentCachingResponseWrapper responseWrapper = new ContentCachingResponseWrapper(response);
try {
filterChain.doFilter(securedRequest, responseWrapper);
@@ -213,11 +244,15 @@ public class GatewaySecurityFilter extends OncePerRequestFilter {
byte[] decryptedBody,
String signature,
ApiClientCredentialDO credential,
ApiGatewayProperties.Security security) {
ApiGatewayProperties.Security security,
String appId,
String timestampHeader) {
Map<String, Object> signaturePayload = new LinkedHashMap<>();
mergeQueryParameters(signaturePayload, request);
mergeBodyParameters(signaturePayload, decryptedBody);
signaturePayload.put("signature", signature);
signaturePayload.put(APP_ID_HEADER, appId);
signaturePayload.put(TIMESTAMP_HEADER, timestampHeader);
signaturePayload.put(SIGNATURE_FIELD, signature);
String signatureType = resolveSignatureType(credential, security);
try {
@@ -331,12 +366,16 @@ public class GatewaySecurityFilter extends OncePerRequestFilter {
if (!security.isEncryptResponse()) {
return;
}
byte[] plainBody = responseWrapper.getContentAsByteArray();
if (plainBody.length == 0) {
// 空响应无需加密,直接返回避免因密钥缺失导致异常
return;
}
String encryptionKey = credential.getEncryptionKey();
String encryptionType = resolveEncryptionType(credential, security);
if (!StringUtils.hasText(encryptionKey) || !StringUtils.hasText(encryptionType)) {
throw new SecurityValidationException(HttpStatus.INTERNAL_SERVER_ERROR, "应用未配置加密密钥");
}
byte[] plainBody = responseWrapper.getContentAsByteArray();
String charsetName = responseWrapper.getCharacterEncoding();
if (!StringUtils.hasText(charsetName)) {
charsetName = StandardCharsets.UTF_8.name();
@@ -372,6 +411,33 @@ public class GatewaySecurityFilter extends OncePerRequestFilter {
securedRequest.setHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token);
}
/**
* 匿名访问场景下,将固定用户写入安全上下文并同步到请求头,确保后续业务链路能够识别身份。
*/
private void applyAnonymousLogin(CachedBodyHttpServletRequest securedRequest,
ApiAnonymousUserService.AnonymousUserDetails anonymousDetails) {
LoginUser loginUser = anonymousDetails.getLoginUser();
SecurityFrameworkUtils.setLoginUser(loginUser, securedRequest);
Long tenantId = loginUser.getTenantId();
// 设置租户标识到请求头与上下文
securedRequest.setHeader(WebFrameworkUtils.HEADER_TENANT_ID, tenantId != null ? tenantId.toString() : null);
TenantContextHolder.setTenantId(tenantId);
try {
String serialized = JsonUtils.toJsonString(loginUser);
String encoded = URLEncoder.encode(serialized, StandardCharsets.UTF_8);
securedRequest.setHeader(SecurityFrameworkUtils.LOGIN_USER_HEADER, encoded);
} catch (Exception ex) {
log.warn("[API-PORTAL] 序列化匿名访问用户失败", ex);
}
securedRequest.removeHeader(GatewayJwtResolver.HEADER_ZT_AUTH_TOKEN);
securedRequest.removeHeader(HttpHeaders.AUTHORIZATION);
anonymousUserService.issueAccessToken(anonymousDetails)
.ifPresent(token -> {
securedRequest.setHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token);
securedRequest.setHeader(GatewayJwtResolver.HEADER_ZT_AUTH_TOKEN, token);
});
}
private static final class SecurityValidationException extends RuntimeException {
private final HttpStatus status;

View File

@@ -0,0 +1,14 @@
package com.zt.plat.module.databus.framework.rpc.config;
import com.zt.plat.framework.common.biz.system.oauth2.OAuth2TokenCommonApi;
import com.zt.plat.module.system.api.user.AdminUserApi;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.context.annotation.Configuration;
/**
* Databus 模块的 RPC 配置,开启所需的 Feign 客户端。
*/
@Configuration(value = "databusRpcConfiguration", proxyBeanMethods = false)
@EnableFeignClients(clients = {AdminUserApi.class, OAuth2TokenCommonApi.class})
public class RpcConfiguration {
}

View File

@@ -0,0 +1,150 @@
package com.zt.plat.module.databus.service.gateway;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.LoadingCache;
import com.zt.plat.framework.common.biz.system.oauth2.OAuth2TokenCommonApi;
import com.zt.plat.framework.common.biz.system.oauth2.dto.OAuth2AccessTokenCreateReqDTO;
import com.zt.plat.framework.common.biz.system.oauth2.dto.OAuth2AccessTokenRespDTO;
import com.zt.plat.framework.common.enums.CommonStatusEnum;
import com.zt.plat.framework.common.enums.UserTypeEnum;
import com.zt.plat.framework.security.core.LoginUser;
import com.zt.plat.framework.tenant.core.aop.TenantIgnore;
import com.zt.plat.module.system.api.user.AdminUserApi;
import com.zt.plat.module.system.api.user.dto.AdminUserRespDTO;
import com.zt.plat.module.system.enums.oauth2.OAuth2ClientConstants;
import jakarta.annotation.PostConstruct;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
import java.time.Duration;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import static com.zt.plat.framework.security.core.LoginUser.INFO_KEY_NICKNAME;
import static com.zt.plat.framework.security.core.LoginUser.INFO_KEY_TENANT_ID;
/**
* 缓存并提供匿名访问所需的固定用户信息,包含对应的 LoginUser 快照。
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class ApiAnonymousUserService {
private final AdminUserApi adminUserApi;
private final OAuth2TokenCommonApi oauth2TokenApi;
private LoadingCache<Long, Optional<AnonymousUserDetails>> cache;
@PostConstruct
void init() {
cache = Caffeine.newBuilder()
.maximumSize(256)
.expireAfterWrite(Duration.ofMinutes(5))
.build(this::loadUserDetails);
}
public Optional<AnonymousUserDetails> find(Long userId) {
if (userId == null) {
return Optional.empty();
}
return cache.get(userId);
}
public AnonymousUserDetails getOrThrow(Long userId) {
return find(userId).orElseThrow(() -> new IllegalStateException("匿名访问固定用户不存在或不可用"));
}
public void invalidate(Long userId) {
if (userId != null) {
cache.invalidate(userId);
}
}
private Optional<AnonymousUserDetails> loadUserDetails(Long userId) {
AdminUserRespDTO user = adminUserApi.getUser(userId).getCheckedData();
if (user == null) {
return Optional.empty();
}
if (!CommonStatusEnum.isEnable(user.getStatus())) {
return Optional.empty();
}
LoginUser loginUser = buildLoginUser(user);
return Optional.of(new AnonymousUserDetails(user.getId(), user.getNickname(), loginUser));
}
private LoginUser buildLoginUser(AdminUserRespDTO user) {
LoginUser loginUser = new LoginUser();
loginUser.setId(user.getId());
loginUser.setUserType(UserTypeEnum.ADMIN.getValue());
loginUser.setTenantId(user.getTenantId());
loginUser.setVisitTenantId(user.getTenantId());
if (!CollectionUtils.isEmpty(user.getDeptIds())) {
loginUser.setVisitDeptId(user.getDeptIds().get(0));
}
Map<String, String> info = new HashMap<>(2);
if (user.getNickname() != null) {
info.put(INFO_KEY_NICKNAME, user.getNickname());
}
if (user.getTenantId() != null) {
info.put(INFO_KEY_TENANT_ID, String.valueOf(user.getTenantId()));
}
loginUser.setInfo(info);
loginUser.setScopes(Collections.emptyList());
return loginUser;
}
@TenantIgnore
public Optional<String> issueAccessToken(AnonymousUserDetails details) {
if (details == null) {
return Optional.empty();
}
try {
OAuth2AccessTokenCreateReqDTO req = buildAccessTokenRequest(details);
OAuth2AccessTokenRespDTO resp = oauth2TokenApi.createAccessToken(req).getCheckedData();
if (resp == null || !StringUtils.hasText(resp.getAccessToken())) {
log.warn("[ANONYMOUS] 获取用户 {} 的访问令牌失败: 响应为空", details.getUserId());
return Optional.empty();
}
return Optional.of(resp.getAccessToken());
} catch (Exception ex) {
log.error("[ANONYMOUS] 获取用户 {} 的访问令牌时发生异常", details.getUserId(), ex);
return Optional.empty();
}
}
private OAuth2AccessTokenCreateReqDTO buildAccessTokenRequest(AnonymousUserDetails details) {
OAuth2AccessTokenCreateReqDTO req = new OAuth2AccessTokenCreateReqDTO();
req.setUserId(details.getUserId());
Integer userType = Optional.ofNullable(details.getLoginUser())
.map(LoginUser::getUserType)
.orElse(UserTypeEnum.ADMIN.getValue());
req.setUserType(userType);
req.setClientId(OAuth2ClientConstants.CLIENT_ID_DEFAULT);
if (details.getLoginUser() != null && !CollectionUtils.isEmpty(details.getLoginUser().getScopes())) {
req.setScopes(details.getLoginUser().getScopes());
} else {
req.setScopes(Collections.emptyList());
}
return req;
}
@Getter
public static final class AnonymousUserDetails {
private final Long userId;
private final String nickname;
private final LoginUser loginUser;
public AnonymousUserDetails(Long userId, String nickname, LoginUser loginUser) {
this.userId = userId;
this.nickname = nickname;
this.loginUser = loginUser;
}
}
}

View File

@@ -29,6 +29,11 @@ public interface ApiDefinitionService {
*/
Optional<ApiDefinitionAggregate> refresh(String apiCode, String version);
/**
* Evict all cached API definitions, forcing rebuild on next access.
*/
void refreshAllCache();
/**
* Lookup API definition aggregate by primary key.
*/

View File

@@ -9,6 +9,7 @@ import com.zt.plat.module.databus.controller.admin.gateway.vo.credential.ApiClie
import com.zt.plat.module.databus.controller.admin.gateway.vo.credential.ApiClientCredentialSaveReqVO;
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiClientCredentialDO;
import com.zt.plat.module.databus.dal.mysql.gateway.ApiClientCredentialMapper;
import com.zt.plat.module.databus.service.gateway.ApiAnonymousUserService;
import com.zt.plat.module.databus.service.gateway.ApiClientCredentialService;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
@@ -24,6 +25,8 @@ import java.util.Optional;
import static com.zt.plat.module.databus.service.gateway.impl.GatewayServiceErrorCodeConstants.API_CREDENTIAL_DUPLICATE_APP;
import static com.zt.plat.module.databus.service.gateway.impl.GatewayServiceErrorCodeConstants.API_CREDENTIAL_NOT_FOUND;
import static com.zt.plat.module.databus.service.gateway.impl.GatewayServiceErrorCodeConstants.API_CREDENTIAL_ANONYMOUS_USER_INVALID;
import static com.zt.plat.module.databus.service.gateway.impl.GatewayServiceErrorCodeConstants.API_CREDENTIAL_ANONYMOUS_USER_REQUIRED;
@Slf4j
@Service
@@ -31,6 +34,7 @@ import static com.zt.plat.module.databus.service.gateway.impl.GatewayServiceErro
public class ApiClientCredentialServiceImpl implements ApiClientCredentialService {
private final ApiClientCredentialMapper credentialMapper;
private final ApiAnonymousUserService anonymousUserService;
private LoadingCache<String, Optional<ApiClientCredentialDO>> credentialCache;
@@ -51,10 +55,17 @@ public class ApiClientCredentialServiceImpl implements ApiClientCredentialServic
@Transactional(rollbackFor = Exception.class)
public Long create(ApiClientCredentialSaveReqVO reqVO) {
ensureAppIdUnique(reqVO.getAppId(), null);
normalizeAnonymousSettings(reqVO);
ApiClientCredentialDO credential = BeanUtils.toBean(reqVO, ApiClientCredentialDO.class);
credential.setId(null);
credential.setDeleted(Boolean.FALSE);
if (credential.getAllowAnonymous() == null) {
credential.setAllowAnonymous(Boolean.FALSE);
}
if (Boolean.FALSE.equals(credential.getAllowAnonymous())) {
credential.setAnonymousUserId(null);
}
credentialMapper.insert(credential);
invalidateCache(credential.getAppId());
return credential.getId();
@@ -65,11 +76,22 @@ public class ApiClientCredentialServiceImpl implements ApiClientCredentialServic
public void update(ApiClientCredentialSaveReqVO reqVO) {
ApiClientCredentialDO existing = ensureExists(reqVO.getId());
ensureAppIdUnique(reqVO.getAppId(), existing.getId());
normalizeAnonymousSettings(reqVO);
ApiClientCredentialDO updateObj = BeanUtils.toBean(reqVO, ApiClientCredentialDO.class);
if (updateObj.getAllowAnonymous() == null) {
updateObj.setAllowAnonymous(Boolean.FALSE);
}
if (Boolean.FALSE.equals(updateObj.getAllowAnonymous())) {
updateObj.setAnonymousUserId(null);
}
credentialMapper.updateById(updateObj);
invalidateCache(existing.getAppId());
invalidateCache(updateObj.getAppId());
if (!Objects.equals(existing.getAnonymousUserId(), updateObj.getAnonymousUserId())) {
anonymousUserService.invalidate(existing.getAnonymousUserId());
anonymousUserService.invalidate(updateObj.getAnonymousUserId());
}
}
@Override
@@ -78,6 +100,7 @@ public class ApiClientCredentialServiceImpl implements ApiClientCredentialServic
ApiClientCredentialDO existing = ensureExists(id);
credentialMapper.deleteById(id);
invalidateCache(existing.getAppId());
anonymousUserService.invalidate(existing.getAnonymousUserId());
}
@Override
@@ -130,4 +153,18 @@ public class ApiClientCredentialServiceImpl implements ApiClientCredentialServic
}
credentialCache.invalidate(appId.trim());
}
private void normalizeAnonymousSettings(ApiClientCredentialSaveReqVO reqVO) {
if (Boolean.TRUE.equals(reqVO.getAllowAnonymous())) {
if (reqVO.getAnonymousUserId() == null) {
throw ServiceExceptionUtil.exception(API_CREDENTIAL_ANONYMOUS_USER_REQUIRED);
}
if (anonymousUserService.find(reqVO.getAnonymousUserId()).isEmpty()) {
throw ServiceExceptionUtil.exception(API_CREDENTIAL_ANONYMOUS_USER_INVALID);
}
} else {
reqVO.setAllowAnonymous(Boolean.FALSE);
reqVO.setAnonymousUserId(null);
}
}
}

View File

@@ -38,6 +38,7 @@ import org.springframework.dao.DataAccessException;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
import java.time.Duration;
@@ -102,6 +103,12 @@ public class ApiDefinitionServiceImpl implements ApiDefinitionService {
return findByCodeAndVersion(apiCode, version);
}
@Override
public void refreshAllCache() {
definitionCache.invalidateAll();
clearRedisCacheForTenant(TenantContextHolder.getTenantId());
}
@Override
public Optional<ApiDefinitionAggregate> findById(Long id) {
return Optional.ofNullable(apiDefinitionMapper.selectById(id))
@@ -205,11 +212,28 @@ public class ApiDefinitionServiceImpl implements ApiDefinitionService {
}
}
private void clearRedisCacheForTenant(Long tenantId) {
String tenantPart = tenantId == null ? "global" : tenantId.toString();
String pattern = REDIS_CACHE_PREFIX + tenantPart + ":*";
try {
Set<String> keys = stringRedisTemplate.keys(pattern);
if (CollectionUtils.isEmpty(keys)) {
return;
}
stringRedisTemplate.delete(keys);
} catch (DataAccessException ex) {
log.warn("批量删除 Redis 中匹配 {} 的 API 定义聚合失败", pattern, ex);
}
}
private String buildCacheKey(String apiCode, String version) {
Long tenantId = TenantContextHolder.getTenantId();
return buildCacheKeyForTenant(tenantId, apiCode, version);
}
/**
* 构建包含步骤、变换、策略等元数据的聚合对象,供缓存与运行时直接使用。
*/
private ApiDefinitionAggregate buildAggregate(ApiDefinitionDO definition) {
List<ApiStepDO> stepDOS = apiStepMapper.selectByApiId(definition.getId());
List<ApiStepDefinition> stepDefinitions = new ArrayList<>(stepDOS.size());
@@ -313,6 +337,9 @@ public class ApiDefinitionServiceImpl implements ApiDefinitionService {
}
}
/**
* 顺序持久化步骤定义,并针对开始/结束节点清理不必要配置避免脏数据。
*/
private void persistSteps(Long apiId, List<ApiDefinitionStepSaveReqVO> steps) {
if (CollUtil.isEmpty(steps)) {
return;
@@ -323,14 +350,7 @@ public class ApiDefinitionServiceImpl implements ApiDefinitionService {
ApiStepDO stepDO = BeanUtils.toBean(stepVO, ApiStepDO.class);
stepDO.setId(null);
stepDO.setApiId(apiId);
if (isStartStep(stepVO)) {
stepDO.setParallelGroup(null);
stepDO.setTargetEndpoint(null);
stepDO.setFallbackStrategy(null);
stepDO.setConditionExpr(null);
stepDO.setStopOnError(Boolean.FALSE);
stepDO.setTimeout(null);
} else if (isEndStep(stepVO)) {
if (isStartStep(stepVO) || isEndStep(stepVO)) {
stepDO.setParallelGroup(null);
stepDO.setTargetEndpoint(null);
stepDO.setFallbackStrategy(null);
@@ -380,6 +400,9 @@ public class ApiDefinitionServiceImpl implements ApiDefinitionService {
.ifPresent(definition -> { throw ServiceExceptionUtil.exception(API_DEFINITION_DUPLICATE); });
}
/**
* 校验步骤编排的拓扑约束,确保开始/结束节点唯一且位置正确。
*/
private void validateStructure(ApiDefinitionSaveReqVO reqVO) {
if (CollUtil.isEmpty(reqVO.getSteps())) {
throw ServiceExceptionUtil.exception(API_DEFINITION_STEP_EMPTY);

View File

@@ -52,5 +52,8 @@ public interface GatewayServiceErrorCodeConstants {
ErrorCode API_SIGNATURE_CONFIG_INVALID = new ErrorCode(1_010_000_043, "签名策略配置异常");
ErrorCode API_CREDENTIAL_NOT_FOUND = new ErrorCode(1_010_000_044, "应用凭证不存在或已删除");
ErrorCode API_CREDENTIAL_DUPLICATE_APP = new ErrorCode(1_010_000_045, "应用标识已存在");
ErrorCode API_STEP_MAPPING_CONFIG_INVALID = new ErrorCode(1_010_000_046, "步骤映射配置 JSON 非法");
ErrorCode API_CREDENTIAL_ANONYMOUS_USER_REQUIRED = new ErrorCode(1_010_000_047, "启用匿名访问时必须指定固定用户");
ErrorCode API_CREDENTIAL_ANONYMOUS_USER_INVALID = new ErrorCode(1_010_000_048, "匿名访问固定用户不存在或已被禁用");
}

View File

@@ -3,12 +3,15 @@ package com.zt.plat.module.databus.controller.admin.gateway;
import com.zt.plat.module.databus.controller.admin.gateway.vo.ApiGatewayInvokeReqVO;
import com.zt.plat.module.databus.framework.integration.gateway.core.ApiGatewayExecutionService;
import com.zt.plat.module.databus.framework.integration.gateway.model.ApiGatewayResponse;
import com.zt.plat.module.databus.framework.integration.config.ApiGatewayProperties;
import com.zt.plat.module.databus.service.gateway.ApiClientCredentialService;
import com.zt.plat.module.databus.service.gateway.ApiDefinitionService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.TestPropertySource;
@@ -25,7 +28,6 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
@WebMvcTest(ApiGatewayController.class)
@AutoConfigureMockMvc(addFilters = false)
@TestPropertySource(properties = {
"spring.config.import=optional:",
"spring.cloud.nacos.config.enabled=false",
"spring.cloud.nacos.discovery.enabled=false"
})
@@ -40,6 +42,15 @@ class ApiGatewayControllerTest {
@MockBean
private ApiDefinitionService apiDefinitionService;
@MockBean
private ApiGatewayProperties apiGatewayProperties;
@MockBean
private StringRedisTemplate stringRedisTemplate;
@MockBean
private ApiClientCredentialService apiClientCredentialService;
@Test
void invokeShouldReturnGatewayEnvelope() throws Exception {
ApiGatewayResponse response = ApiGatewayResponse.builder()

View File

@@ -0,0 +1,258 @@
package com.zt.plat.module.databus.framework.integration.gateway.sample;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.zt.plat.framework.common.util.security.CryptoSignatureUtils;
import java.io.PrintStream;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.TreeMap;
import java.util.UUID;
/**
* 可直接运行的示例,演示如何使用 appId=test 与对应密钥调用本地 Databus API。
*/
public final class DatabusApiInvocationExample {
public static final String TIMESTAMP = Long.toString(System.currentTimeMillis());
// private static final String APP_ID = "ztmy";
// private static final String APP_SECRET = "zFre/nTRGi7LpoFjN7oQkKeOT09x1fWTyIswrc702QQ=";
private static final String APP_ID = "test";
private static final String APP_SECRET = "RSYtKXrXPLMy3oeh0cOro6QCioRUgqfnKCkDkNq78sI=";
private static final String ENCRYPTION_TYPE = CryptoSignatureUtils.ENCRYPT_TYPE_AES;
// private static final String TARGET_API = "http://127.0.0.1:48080/admin-api/databus/api/portal/lgstOpenApi/v1";
private static final String TARGET_API = "http://127.0.0.1:48080/admin-api/databus/api/portal/test11111/233";
private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private static final PrintStream OUT = buildConsolePrintStream();
public static final String ZT_APP_ID = "ZT-App-Id";
public static final String ZT_TIMESTAMP = "ZT-Timestamp";
public static final String ZT_NONCE = "ZT-Nonce";
public static final String ZT_SIGNATURE = "ZT-Signature";
public static final String ZT_AUTH_TOKEN = "ZT-Auth-Token";
public static final String CONTENT_TYPE = "Content-Type";
private DatabusApiInvocationExample() {
}
public static void main(String[] args) throws Exception {
OUT.println("=== GET 请求示例 ===");
executeGetExample();
// OUT.println();
// OUT.println("=== POST 请求示例 ===");
// executePostExample();
}
private static void executeGetExample() throws Exception {
Map<String, Object> queryParams = new LinkedHashMap<>();
queryParams.put("businessCode", "waybillUnLoadingImage");
queryParams.put("fileId", "1979463299195412481");
String signature = generateSignature(queryParams, Map.of());
URI requestUri = buildUri(TARGET_API, queryParams);
String nonce = randomNonce();
HttpRequest request = HttpRequest.newBuilder(requestUri)
.timeout(Duration.ofSeconds(10))
.header(ZT_APP_ID, APP_ID)
.header(ZT_TIMESTAMP, TIMESTAMP)
.header(ZT_NONCE, nonce)
.header(ZT_SIGNATURE, signature)
// .header("ZT-Auth-Token", "a75c0ea94c7f4a88b86b60bbc0b432c3")
.GET()
.build();
HttpResponse<String> response = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
printResponse(response);
}
private static void executePostExample() throws Exception {
Map<String, Object> queryParams = new LinkedHashMap<>();
LinkedHashMap<String, Object> bodyParams = new LinkedHashMap<>();
bodyParams.put("businessCode", "waybillUnLoadingImage");
bodyParams.put("fileId", "1979463299195412481");
LinkedHashMap<String, Object> extra = new LinkedHashMap<>();
extra.put("remark", "demo invocation");
extra.put("timestamp", System.currentTimeMillis());
bodyParams.put("extra", extra);
String signature = generateSignature(queryParams, bodyParams);
URI requestUri = buildUri(TARGET_API, queryParams);
String nonce = randomNonce();
String bodyJson = OBJECT_MAPPER.writeValueAsString(bodyParams);
String cipherBody = encryptPayload(bodyJson);
OUT.println("原始 Request Body: " + bodyJson);
OUT.println("加密 Request Body: " + cipherBody);
HttpRequest request = HttpRequest.newBuilder(requestUri)
.timeout(Duration.ofSeconds(10))
.header(ZT_APP_ID, APP_ID)
.header(ZT_TIMESTAMP, TIMESTAMP)
.header(ZT_NONCE, nonce)
.header(ZT_SIGNATURE, signature)
.header(ZT_AUTH_TOKEN, "a5d7cf609c0b47038ea405c660726ee9")
.header(CONTENT_TYPE, "application/json")
.POST(HttpRequest.BodyPublishers.ofString(cipherBody, StandardCharsets.UTF_8))
.build();
HttpResponse<String> response = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
printResponse(response);
}
private static String encryptPayload(String plaintext) {
try {
return CryptoSignatureUtils.encrypt(plaintext, APP_SECRET, ENCRYPTION_TYPE);
} catch (Exception ex) {
throw new IllegalStateException("Failed to encrypt request body", ex);
}
}
private static void printResponse(HttpResponse<String> response) {
OUT.println("HTTP Status: " + response.statusCode());
String cipherText = response.body();
OUT.println("加密 Response: " + cipherText);
String plain = tryDecrypt(cipherText);
OUT.println("原始 Response: " + normalizePotentialMojibake(plain));
}
private static String randomNonce() {
return UUID.randomUUID().toString().replace("-", "");
}
private static URI buildUri(String baseUrl, Map<String, Object> queryParams) {
if (queryParams == null || queryParams.isEmpty()) {
return URI.create(baseUrl);
}
StringBuilder builder = new StringBuilder(baseUrl);
builder.append(baseUrl.contains("?") ? '&' : '?');
boolean first = true;
for (Map.Entry<String, Object> entry : queryParams.entrySet()) {
if (!first) {
builder.append('&');
}
first = false;
builder.append(URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8));
builder.append('=');
builder.append(URLEncoder.encode(String.valueOf(entry.getValue()), StandardCharsets.UTF_8));
}
return URI.create(builder.toString());
}
private static String generateSignature(Map<String, Object> queryParams, Map<String, Object> bodyParams) {
TreeMap<String, Object> sorted = new TreeMap<>();
if (queryParams != null) {
queryParams.forEach((key, value) -> sorted.put(key, normalizeValue(value)));
}
if (bodyParams != null) {
bodyParams.forEach((key, value) -> sorted.put(key, normalizeValue(value)));
}
sorted.put(ZT_APP_ID, APP_ID);
sorted.put(ZT_TIMESTAMP, TIMESTAMP);
StringBuilder canonical = new StringBuilder();
sorted.forEach((key, value) -> {
if (value == null) {
return;
}
if (canonical.length() > 0) {
canonical.append('&');
}
canonical.append(key).append('=').append(value);
});
return md5Hex(canonical.toString());
}
private static Object normalizeValue(Object value) {
if (value == null) {
return null;
}
if (value instanceof Map || value instanceof Iterable) {
try {
return OBJECT_MAPPER.writeValueAsString(value);
} catch (JsonProcessingException ignored) {
return value.toString();
}
}
return value;
}
private static String md5Hex(String input) {
try {
MessageDigest digest = MessageDigest.getInstance("MD5");
byte[] bytes = digest.digest(input.getBytes(StandardCharsets.UTF_8));
StringBuilder hex = new StringBuilder(bytes.length * 2);
for (byte b : bytes) {
String segment = Integer.toHexString(b & 0xFF);
if (segment.length() == 1) {
hex.append('0');
}
hex.append(segment);
}
return hex.toString();
} catch (NoSuchAlgorithmException ex) {
throw new IllegalStateException("MD5 algorithm not available", ex);
}
}
private static String tryDecrypt(String cipherText) {
if (cipherText == null || cipherText.isBlank()) {
return cipherText;
}
try {
// Databus 会在凭证开启加密时返回密文,这里做一次解密展示真实响应。
return CryptoSignatureUtils.decrypt(cipherText, APP_SECRET, ENCRYPTION_TYPE);
} catch (Exception ex) {
return "<unable to decrypt> " + ex.getMessage();
}
}
// 解决控制台打印 乱码问题
private static String normalizePotentialMojibake(String value) {
if (value == null || value.isEmpty()) {
return value;
}
long suspectCount = value.chars().filter(ch -> ch >= 0x80 && ch <= 0xFF).count();
long highCount = value.chars().filter(ch -> ch > 0xFF).count();
if (suspectCount > 0 && highCount == 0) {
try {
byte[] decoded = value.getBytes(StandardCharsets.ISO_8859_1);
String converted = new String(decoded, StandardCharsets.UTF_8);
if (converted.chars().anyMatch(ch -> ch > 0xFF)) {
return converted;
}
} catch (Exception ignored) {
return value;
}
}
return value;
}
/**
* 输出流编码与当前控制台保持一致,避免中文字符再次出现编码差异。
*/
private static PrintStream buildConsolePrintStream() {
try {
String consoleEncoding = System.getProperty("sun.stdout.encoding");
if (consoleEncoding != null && !consoleEncoding.isBlank()) {
return new PrintStream(System.out, true, Charset.forName(consoleEncoding));
}
return new PrintStream(System.out, true, Charset.defaultCharset());
} catch (Exception ignored) {
return System.out;
}
}
}

View File

@@ -1,24 +1,34 @@
package com.zt.plat.module.databus.framework.integration.gateway.security;
import cn.hutool.crypto.SecureUtil;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.zt.plat.framework.common.util.security.CryptoSignatureUtils;
import com.zt.plat.framework.security.core.LoginUser;
import com.zt.plat.framework.security.core.util.SecurityFrameworkUtils;
import com.zt.plat.framework.web.core.util.WebFrameworkUtils;
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiClientCredentialDO;
import com.zt.plat.module.databus.framework.integration.config.ApiGatewayProperties;
import com.zt.plat.module.databus.service.gateway.ApiAnonymousUserService;
import com.zt.plat.module.databus.service.gateway.ApiClientCredentialService;
import com.zt.plat.framework.common.util.security.CryptoSignatureUtils;
import jakarta.servlet.http.HttpServletRequest;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.mock.web.MockFilterChain;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.context.SecurityContextHolder;
import java.time.Duration;
import java.util.Collections;
import java.util.Optional;
import java.util.UUID;
import static com.zt.plat.module.databus.framework.integration.config.ApiGatewayProperties.*;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
@@ -27,13 +37,21 @@ import static org.mockito.Mockito.when;
class GatewaySecurityFilterTest {
@AfterEach
void clearSecurityContext() {
SecurityContextHolder.clearContext();
}
@Test
void shouldAllowRequestWhenIpPermitted() throws Exception {
ApiGatewayProperties properties = createProperties();
properties.getSecurity().setEnabled(false);
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
ApiClientCredentialService credentialService = mock(ApiClientCredentialService.class);
GatewaySecurityFilter filter = new GatewaySecurityFilter(properties, redisTemplate, credentialService, new ObjectMapper());
ApiClientCredentialService credentialService = mock(ApiClientCredentialService.class);
ApiAnonymousUserService anonymousUserService = mock(ApiAnonymousUserService.class);
when(anonymousUserService.issueAccessToken(any())).thenReturn(Optional.empty());
when(anonymousUserService.issueAccessToken(any())).thenReturn(Optional.empty());
GatewaySecurityFilter filter = new GatewaySecurityFilter(properties, redisTemplate, credentialService, anonymousUserService, new ObjectMapper());
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/admin-api/databus/api/portal/demo/v1");
request.setRemoteAddr("127.0.0.1");
@@ -54,7 +72,9 @@ class GatewaySecurityFilterTest {
properties.getSecurity().setEnabled(false);
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
ApiClientCredentialService credentialService = mock(ApiClientCredentialService.class);
GatewaySecurityFilter filter = new GatewaySecurityFilter(properties, redisTemplate, credentialService, new ObjectMapper());
ApiAnonymousUserService anonymousUserService = mock(ApiAnonymousUserService.class);
when(anonymousUserService.issueAccessToken(any())).thenReturn(Optional.empty());
GatewaySecurityFilter filter = new GatewaySecurityFilter(properties, redisTemplate, credentialService, anonymousUserService, new ObjectMapper());
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/admin-api/databus/api/portal/demo/v1");
request.setRemoteAddr("10.0.0.1");
@@ -76,6 +96,8 @@ class GatewaySecurityFilterTest {
when(valueOperations.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(Boolean.TRUE);
ApiClientCredentialService credentialService = mock(ApiClientCredentialService.class);
ApiAnonymousUserService anonymousUserService = mock(ApiAnonymousUserService.class);
when(anonymousUserService.issueAccessToken(any())).thenReturn(Optional.empty());
ApiClientCredentialDO credential = new ApiClientCredentialDO();
credential.setAppId("demo-app");
credential.setSignatureType(null);
@@ -86,17 +108,17 @@ class GatewaySecurityFilterTest {
properties.getSecurity().setRequireBodyEncryption(false);
properties.getSecurity().setEncryptResponse(false);
GatewaySecurityFilter filter = new GatewaySecurityFilter(properties, redisTemplate, credentialService, new ObjectMapper());
GatewaySecurityFilter filter = new GatewaySecurityFilter(properties, redisTemplate, credentialService, anonymousUserService, new ObjectMapper());
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/admin-api/databus/api/portal/demo/v1");
request.setRemoteAddr("127.0.0.1");
long timestamp = System.currentTimeMillis();
String nonce = UUID.randomUUID().toString().replaceAll("-", "");
String signature = "d41d8cd98f00b204e9800998ecf8427e";
request.addHeader(properties.getSecurity().getAppIdHeader(), "demo-app");
request.addHeader(properties.getSecurity().getTimestampHeader(), String.valueOf(timestamp));
request.addHeader(properties.getSecurity().getNonceHeader(), nonce);
request.addHeader(properties.getSecurity().getSignatureHeader(), signature);
String signature = signatureForApp("demo-app");
request.addHeader(APP_ID_HEADER, "demo-app");
request.addHeader(TIMESTAMP_HEADER, String.valueOf(timestamp));
request.addHeader(NONCE_HEADER, nonce);
request.addHeader(SIGNATURE_HEADER, signature);
MockHttpServletResponse response = new MockHttpServletResponse();
MockFilterChain chain = new MockFilterChain();
@@ -119,22 +141,23 @@ class GatewaySecurityFilterTest {
when(valueOperations.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(Boolean.TRUE);
ApiClientCredentialService credentialService = mock(ApiClientCredentialService.class);
ApiAnonymousUserService anonymousUserService = mock(ApiAnonymousUserService.class);
ApiClientCredentialDO credential = new ApiClientCredentialDO();
credential.setAppId("demo-app");
credential.setEncryptionKey("demo-secret-key");
credential.setEncryptionType(CryptoSignatureUtils.ENCRYPT_TYPE_AES);
when(credentialService.findActiveCredential("demo-app")).thenReturn(Optional.of(credential));
GatewaySecurityFilter filter = new GatewaySecurityFilter(properties, redisTemplate, credentialService, new ObjectMapper());
GatewaySecurityFilter filter = new GatewaySecurityFilter(properties, redisTemplate, credentialService, anonymousUserService, new ObjectMapper());
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/admin-api/databus/api/portal/demo/v1");
request.setRemoteAddr("127.0.0.1");
long timestamp = System.currentTimeMillis();
String nonce = UUID.randomUUID().toString().replaceAll("-", "");
request.addHeader(properties.getSecurity().getAppIdHeader(), "demo-app");
request.addHeader(properties.getSecurity().getTimestampHeader(), String.valueOf(timestamp));
request.addHeader(properties.getSecurity().getNonceHeader(), nonce);
request.addHeader(properties.getSecurity().getSignatureHeader(), "invalid-signature");
request.addHeader(APP_ID_HEADER, "demo-app");
request.addHeader(TIMESTAMP_HEADER, String.valueOf(timestamp));
request.addHeader(NONCE_HEADER, nonce);
request.addHeader(SIGNATURE_HEADER, "invalid-signature");
MockHttpServletResponse response = new MockHttpServletResponse();
filter.doFilter(request, response, new MockFilterChain());
@@ -150,10 +173,65 @@ class GatewaySecurityFilterTest {
assertThat(node.get("message").asText()).isEqualTo("签名校验失败");
}
@Test
void shouldAuthenticateWithAnonymousUserWhenConfigured() throws Exception {
ApiGatewayProperties properties = createProperties();
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
ValueOperations<String, String> valueOperations = mock(ValueOperations.class);
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
when(valueOperations.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(Boolean.TRUE);
ApiClientCredentialService credentialService = mock(ApiClientCredentialService.class);
ApiAnonymousUserService anonymousUserService = mock(ApiAnonymousUserService.class);
ApiClientCredentialDO credential = new ApiClientCredentialDO();
credential.setAppId("demo-app");
credential.setSignatureType(null);
credential.setEncryptionKey(null);
credential.setEncryptionType(null);
credential.setAllowAnonymous(Boolean.TRUE);
credential.setAnonymousUserId(99L);
when(credentialService.findActiveCredential("demo-app")).thenReturn(Optional.of(credential));
LoginUser loginUser = new LoginUser();
loginUser.setId(999L);
loginUser.setUserType(2);
loginUser.setTenantId(123L);
ApiAnonymousUserService.AnonymousUserDetails details = new ApiAnonymousUserService.AnonymousUserDetails(99L, "匿名", loginUser);
when(anonymousUserService.find(99L)).thenReturn(Optional.of(details));
when(anonymousUserService.issueAccessToken(details)).thenReturn(Optional.of("mock-token"));
GatewaySecurityFilter filter = new GatewaySecurityFilter(properties, redisTemplate, credentialService, anonymousUserService, new ObjectMapper());
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/admin-api/databus/api/portal/demo/v1");
request.setRemoteAddr("127.0.0.1");
long timestamp = System.currentTimeMillis();
String nonce = UUID.randomUUID().toString().replaceAll("-", "");
request.addHeader(APP_ID_HEADER, "demo-app");
request.addHeader(TIMESTAMP_HEADER, String.valueOf(timestamp));
request.addHeader(NONCE_HEADER, nonce);
request.addHeader(SIGNATURE_HEADER, signatureForApp("demo-app"));
MockHttpServletResponse response = new MockHttpServletResponse();
MockFilterChain chain = new MockFilterChain();
filter.doFilter(request, response, chain);
assertThat(response.getStatus()).isEqualTo(200);
assertThat(SecurityFrameworkUtils.getLoginUser()).isNotNull();
assertThat(SecurityFrameworkUtils.getLoginUser().getId()).isEqualTo(999L);
assertThat(((HttpServletRequest) chain.getRequest()).getHeader(WebFrameworkUtils.HEADER_TENANT_ID)).isEqualTo("123");
assertThat(((HttpServletRequest) chain.getRequest()).getHeader(GatewayJwtResolver.HEADER_ZT_AUTH_TOKEN)).isEqualTo("mock-token");
assertThat(((HttpServletRequest) chain.getRequest()).getHeader(HttpHeaders.AUTHORIZATION)).isEqualTo("Bearer mock-token");
}
private ApiGatewayProperties createProperties() {
ApiGatewayProperties properties = new ApiGatewayProperties();
properties.setBasePath("/admin-api/databus/api/portal");
properties.setAllowedIps(Collections.singletonList("127.0.0.1"));
return properties;
}
private String signatureForApp(String appId) {
return SecureUtil.md5("appId=" + appId);
}
}