feat(encryption): 实现前后端 AES-256-GCM 加密通信
参考同级项目 novavis-authority 实现,在 HTTPS 基础上增加应用层加密。 后端: - CryptoService: AES-256-GCM + PBKDF2 密钥派生(manage-common) - CryptoFilter: Gateway GlobalFilter,检测 X-Encrypted 头后加解密请求/响应体 - 配置:app.encryption.secret 通过环境变量注入 前端: - crypto.ts: Web Crypto API 实现 AES-256-GCM + PBKDF2 - request.ts: 拦截器自动加密请求体、解密响应体 - 环境变量 VITE_ENCRYPTION_SECRET 测试覆盖: - 后端 CryptoServiceTest 16 个用例 + CryptoFilterTest 7 个用例 - 前端 crypto.test.ts 8 个用例 + Playwright E2E smoke 测试 - 全量 510 个前端测试 + 后端全量测试全部通过,无回归
This commit was merged in pull request #55.
This commit is contained in:
@@ -17,6 +17,11 @@
|
||||
<description>Gateway module for Novalon Manage API</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>cn.novalon.gym.manage</groupId>
|
||||
<artifactId>manage-common</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webflux</artifactId>
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package cn.novalon.gym.manage.gateway.config;
|
||||
|
||||
import cn.novalon.gym.manage.common.util.CryptoService;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* CryptoService Bean 配置 —— 使用 {@code app.encryption.secret} 初始化 AES-256-GCM 加解密引擎。
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-08-02
|
||||
*/
|
||||
@Configuration
|
||||
public class CryptoConfig {
|
||||
|
||||
@Bean
|
||||
public CryptoService cryptoService(@Value("${app.encryption.secret}") String secret) {
|
||||
return new CryptoService(secret);
|
||||
}
|
||||
}
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
package cn.novalon.gym.manage.gateway.filter;
|
||||
|
||||
import cn.novalon.gym.manage.common.util.CryptoService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
|
||||
import org.springframework.cloud.gateway.filter.GlobalFilter;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.io.buffer.DataBuffer;
|
||||
import org.springframework.core.io.buffer.DataBufferUtils;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.server.reactive.ServerHttpRequest;
|
||||
import org.springframework.http.server.reactive.ServerHttpRequestDecorator;
|
||||
import org.springframework.http.server.reactive.ServerHttpResponseDecorator;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* 应用层加密/解密全局过滤器。
|
||||
* <p>
|
||||
* 当请求携带 {@code X-Encrypted: true} 头时:
|
||||
* <ul>
|
||||
* <li>请求体:在 filter 层主动解密为 JSON 明文,再交给下游 handler</li>
|
||||
* <li>响应体:将 handler 返回的 JSON 明文加密为 Base64(AES-GCM IV‖密文),并标记 {@code X-Encrypted: true}</li>
|
||||
* </ul>
|
||||
* 不携带该头的请求直接透传(兼容 curl/Postman 等调试工具)。
|
||||
* </p>
|
||||
* <p>
|
||||
* 安全约束:携带 {@code X-Encrypted: true} 但解密失败的请求必须返回 400 Bad Request,
|
||||
* 禁止静默降级为明文传输,防止中间人/客户端误用导致的数据暴露。
|
||||
* </p>
|
||||
* <p>
|
||||
* 优先级:在 {@link SignatureFilter} 之后执行,确保已通过签名验证的请求再进行加解密处理。
|
||||
* </p>
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-08-02
|
||||
*/
|
||||
@Component
|
||||
public class CryptoFilter implements GlobalFilter, Ordered {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(CryptoFilter.class);
|
||||
private static final String ENCRYPTED_HEADER = "X-Encrypted";
|
||||
|
||||
private final CryptoService cryptoService;
|
||||
|
||||
public CryptoFilter(CryptoService cryptoService) {
|
||||
this.cryptoService = cryptoService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getOrder() {
|
||||
// 在 SignatureFilter (HIGHEST_PRECEDENCE + 150) 之后执行
|
||||
return Ordered.HIGHEST_PRECEDENCE + 200;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
|
||||
String path = exchange.getRequest().getURI().getPath();
|
||||
|
||||
// 健康检查 & 非 API 路径透传
|
||||
if (path.startsWith("/actuator/") || path.equals("/favicon.ico")) {
|
||||
return chain.filter(exchange);
|
||||
}
|
||||
|
||||
boolean isEncrypted = "true".equalsIgnoreCase(
|
||||
exchange.getRequest().getHeaders().getFirst(ENCRYPTED_HEADER));
|
||||
|
||||
if (!isEncrypted) {
|
||||
return chain.filter(exchange);
|
||||
}
|
||||
|
||||
// 构建加密响应装饰器
|
||||
ServerHttpResponseDecorator encryptedResponse = buildEncryptedResponse(exchange);
|
||||
|
||||
// 读取并解密请求体
|
||||
return DataBufferUtils.join(exchange.getRequest().getBody())
|
||||
.switchIfEmpty(Mono.defer(() -> {
|
||||
// 无请求体(如 GET/DELETE)仍需加密响应
|
||||
return chain.filter(exchange.mutate().response(encryptedResponse).build())
|
||||
.then(Mono.empty());
|
||||
}))
|
||||
.flatMap(buffer -> {
|
||||
byte[] encryptedBytes = new byte[buffer.readableByteCount()];
|
||||
buffer.read(encryptedBytes);
|
||||
DataBufferUtils.release(buffer);
|
||||
|
||||
String bodyStr = new String(encryptedBytes, StandardCharsets.UTF_8);
|
||||
|
||||
if (bodyStr.isEmpty()) {
|
||||
// 空 body 透传,但响应仍需加密
|
||||
return chain.filter(exchange.mutate().response(encryptedResponse).build());
|
||||
}
|
||||
|
||||
byte[] decryptedBytes;
|
||||
try {
|
||||
decryptedBytes = cryptoService.decrypt(bodyStr);
|
||||
} catch (Exception e) {
|
||||
log.warn("Encrypted request rejected for {}: {}", path, e.getMessage());
|
||||
exchange.getResponse().setStatusCode(HttpStatus.BAD_REQUEST);
|
||||
return exchange.getResponse().setComplete();
|
||||
}
|
||||
|
||||
ServerHttpRequest decryptedRequest = buildDecryptedRequest(exchange, decryptedBytes);
|
||||
return chain.filter(
|
||||
exchange.mutate()
|
||||
.request(decryptedRequest)
|
||||
.response(encryptedResponse)
|
||||
.build()
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ========== 请求体解密 ==========
|
||||
|
||||
private ServerHttpRequest buildDecryptedRequest(ServerWebExchange exchange, byte[] decryptedBytes) {
|
||||
return new ServerHttpRequestDecorator(exchange.getRequest()) {
|
||||
|
||||
@Override
|
||||
public HttpHeaders getHeaders() {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.putAll(super.getHeaders());
|
||||
headers.set(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
|
||||
// 移除加密标记,避免下游服务重复解密
|
||||
headers.remove(ENCRYPTED_HEADER);
|
||||
return HttpHeaders.readOnlyHttpHeaders(headers);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<DataBuffer> getBody() {
|
||||
return Flux.just(exchange.getResponse().bufferFactory().wrap(decryptedBytes));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ========== 响应体加密 ==========
|
||||
|
||||
private ServerHttpResponseDecorator buildEncryptedResponse(ServerWebExchange exchange) {
|
||||
return new ServerHttpResponseDecorator(exchange.getResponse()) {
|
||||
|
||||
@Override
|
||||
public Mono<Void> writeWith(org.reactivestreams.Publisher<? extends DataBuffer> body) {
|
||||
return DataBufferUtils.join(body)
|
||||
.flatMap(buffer -> {
|
||||
try {
|
||||
byte[] plainBytes = new byte[buffer.readableByteCount()];
|
||||
buffer.read(plainBytes);
|
||||
DataBufferUtils.release(buffer);
|
||||
|
||||
String encrypted = cryptoService.encrypt(plainBytes);
|
||||
byte[] encryptedBytes = encrypted.getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
getDelegate().getHeaders().set(ENCRYPTED_HEADER, "true");
|
||||
getDelegate().getHeaders().set(HttpHeaders.CONTENT_LENGTH,
|
||||
String.valueOf(encryptedBytes.length));
|
||||
|
||||
return getDelegate()
|
||||
.writeWith(Mono.just(
|
||||
getDelegate().bufferFactory().wrap(encryptedBytes)));
|
||||
} catch (Exception e) {
|
||||
log.error("Response body encryption failed for {}: {}",
|
||||
exchange.getRequest().getURI().getPath(), e.getMessage());
|
||||
return Mono.error(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> writeAndFlushWith(
|
||||
org.reactivestreams.Publisher<? extends org.reactivestreams.Publisher<? extends DataBuffer>> body) {
|
||||
return writeWith(Flux.from(body).flatMapSequential(p -> p));
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,10 @@ jwt:
|
||||
secret: U2FsdGVkX1+vZ5Y9QmKxL8nN3rP7tW2jH4fG6dA8sB1cE5yN0zX3qV7wM4
|
||||
expiration: 86400000
|
||||
|
||||
app:
|
||||
encryption:
|
||||
secret: GymManageEncryptionSecretKey2026!
|
||||
|
||||
logging:
|
||||
level:
|
||||
cn.novalon.manage.gateway: DEBUG
|
||||
|
||||
@@ -7,6 +7,10 @@ spring:
|
||||
predicates:
|
||||
- Path=/api/**
|
||||
|
||||
app:
|
||||
encryption:
|
||||
secret: ${APP_ENCRYPTION_SECRET}
|
||||
|
||||
logging:
|
||||
level:
|
||||
cn.novalon.manage: INFO
|
||||
|
||||
@@ -42,6 +42,12 @@ jwt:
|
||||
interval:
|
||||
days: ${JWT_KEY_ROTATION_INTERVAL_DAYS:30}
|
||||
|
||||
# 前后端通信应用层加密配置
|
||||
# 生产环境必须通过环境变量 APP_ENCRYPTION_SECRET 注入,长度不少于 12 个字符
|
||||
app:
|
||||
encryption:
|
||||
secret: ${APP_ENCRYPTION_SECRET:GymManageEncryptionSecretKey2026!}
|
||||
|
||||
rate:
|
||||
limit:
|
||||
enabled: ${RATE_LIMIT_ENABLED:true}
|
||||
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
package cn.novalon.gym.manage.gateway.filter;
|
||||
|
||||
import cn.novalon.gym.manage.common.util.CryptoService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
|
||||
import org.springframework.core.io.buffer.DataBuffer;
|
||||
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.server.reactive.ServerHttpResponseDecorator;
|
||||
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
|
||||
import org.springframework.mock.web.server.MockServerWebExchange;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@DisplayName("CryptoFilter 集成测试")
|
||||
class CryptoFilterTest {
|
||||
|
||||
private static final String TEST_SECRET = "CryptoFilterTestKey2026!";
|
||||
private static final String JSON_PAYLOAD = "{\"username\":\"admin\",\"password\":\"Test@123\"}";
|
||||
private static final String JSON_RESPONSE = "{\"token\":\"test-jwt-token\",\"userId\":1}";
|
||||
|
||||
private CryptoService cryptoService;
|
||||
private CryptoFilter cryptoFilter;
|
||||
|
||||
@Mock
|
||||
private GatewayFilterChain chain;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
cryptoService = new CryptoService(TEST_SECRET);
|
||||
cryptoFilter = new CryptoFilter(cryptoService);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("无加密标记的请求应透传")
|
||||
class PlaintextPassthrough {
|
||||
|
||||
@Test
|
||||
@DisplayName("无 X-Encrypted 头的请求直接透传")
|
||||
void shouldPassThroughWithoutEncryptedHeader() {
|
||||
MockServerHttpRequest request = MockServerHttpRequest
|
||||
.method(HttpMethod.GET, "/api/users")
|
||||
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
|
||||
.build();
|
||||
ServerWebExchange exchange = MockServerWebExchange.from(request);
|
||||
|
||||
when(chain.filter(any())).thenReturn(Mono.empty());
|
||||
|
||||
StepVerifier.create(cryptoFilter.filter(exchange, chain))
|
||||
.verifyComplete();
|
||||
|
||||
verify(chain).filter(exchange);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("健康检查端点透传")
|
||||
void shouldPassThroughHealthEndpoint() {
|
||||
MockServerHttpRequest request = MockServerHttpRequest
|
||||
.method(HttpMethod.GET, "/actuator/health")
|
||||
.build();
|
||||
ServerWebExchange exchange = MockServerWebExchange.from(request);
|
||||
|
||||
when(chain.filter(any())).thenReturn(Mono.empty());
|
||||
|
||||
StepVerifier.create(cryptoFilter.filter(exchange, chain))
|
||||
.verifyComplete();
|
||||
|
||||
verify(chain).filter(exchange);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("加密请求解密测试")
|
||||
class EncryptedRequestDecryption {
|
||||
|
||||
@Test
|
||||
@DisplayName("应解密加密的请求体并传递给下游")
|
||||
void shouldDecryptRequestBody() {
|
||||
String encryptedBody = cryptoService.encryptString(JSON_PAYLOAD);
|
||||
|
||||
MockServerHttpRequest request = MockServerHttpRequest
|
||||
.method(HttpMethod.POST, "/api/auth/login")
|
||||
.header("X-Encrypted", "true")
|
||||
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
|
||||
.body(encryptedBody);
|
||||
ServerWebExchange exchange = MockServerWebExchange.from(request);
|
||||
|
||||
// 模拟下游处理器:捕获解密后的请求
|
||||
when(chain.filter(any())).thenAnswer(invocation -> {
|
||||
ServerWebExchange ex = invocation.getArgument(0);
|
||||
// 验证请求头中的 X-Encrypted 已被移除
|
||||
assertThat(ex.getRequest().getHeaders().get("X-Encrypted")).isNull();
|
||||
// 验证 Content-Type 是 application/json
|
||||
assertThat(ex.getRequest().getHeaders().getFirst(HttpHeaders.CONTENT_TYPE))
|
||||
.isEqualTo(MediaType.APPLICATION_JSON_VALUE);
|
||||
return Mono.empty();
|
||||
});
|
||||
|
||||
StepVerifier.create(cryptoFilter.filter(exchange, chain))
|
||||
.verifyComplete();
|
||||
|
||||
verify(chain).filter(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("空请求体应透传(GET 请求)")
|
||||
void shouldHandleEmptyBody() {
|
||||
MockServerHttpRequest request = MockServerHttpRequest
|
||||
.method(HttpMethod.GET, "/api/users")
|
||||
.header("X-Encrypted", "true")
|
||||
.build();
|
||||
ServerWebExchange exchange = MockServerWebExchange.from(request);
|
||||
|
||||
when(chain.filter(any())).thenReturn(Mono.empty());
|
||||
|
||||
StepVerifier.create(cryptoFilter.filter(exchange, chain))
|
||||
.verifyComplete();
|
||||
|
||||
verify(chain).filter(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("解密失败应返回 400")
|
||||
void shouldReturn400OnDecryptionFailure() {
|
||||
// 非法的 Base64 密文
|
||||
MockServerHttpRequest request = MockServerHttpRequest
|
||||
.method(HttpMethod.POST, "/api/auth/login")
|
||||
.header("X-Encrypted", "true")
|
||||
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
|
||||
.body("invalid-base64!!!");
|
||||
ServerWebExchange exchange = MockServerWebExchange.from(request);
|
||||
|
||||
StepVerifier.create(cryptoFilter.filter(exchange, chain))
|
||||
.verifyComplete();
|
||||
|
||||
assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
// 不应调用下游 chain
|
||||
verify(chain, never()).filter(any());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("加密响应测试")
|
||||
class EncryptedResponseTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("应加密下游返回的响应体")
|
||||
void shouldEncryptResponseBody() {
|
||||
String encryptedBody = cryptoService.encryptString(JSON_PAYLOAD);
|
||||
|
||||
MockServerHttpRequest request = MockServerHttpRequest
|
||||
.method(HttpMethod.POST, "/api/auth/login")
|
||||
.header("X-Encrypted", "true")
|
||||
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
|
||||
.body(encryptedBody);
|
||||
ServerWebExchange exchange = MockServerWebExchange.from(request);
|
||||
|
||||
// 模拟下游返回明文 JSON
|
||||
when(chain.filter(any())).thenAnswer(invocation -> {
|
||||
ServerWebExchange ex = invocation.getArgument(0);
|
||||
byte[] responseBytes = JSON_RESPONSE.getBytes(StandardCharsets.UTF_8);
|
||||
DataBuffer buffer = new DefaultDataBufferFactory().wrap(responseBytes);
|
||||
return ex.getResponse().writeWith(Mono.just(buffer));
|
||||
});
|
||||
|
||||
StepVerifier.create(cryptoFilter.filter(exchange, chain))
|
||||
.verifyComplete();
|
||||
|
||||
// 验证响应头包含 X-Encrypted: true
|
||||
assertThat(exchange.getResponse().getHeaders().getFirst("X-Encrypted")).isEqualTo("true");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("加密的响应体应能被解密还原")
|
||||
void shouldProduceDecryptableResponse() {
|
||||
String encryptedBody = cryptoService.encryptString(JSON_PAYLOAD);
|
||||
|
||||
MockServerHttpRequest request = MockServerHttpRequest
|
||||
.method(HttpMethod.POST, "/api/auth/login")
|
||||
.header("X-Encrypted", "true")
|
||||
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
|
||||
.body(encryptedBody);
|
||||
|
||||
// 构建一个带响应体捕获装饰器的 Exchange
|
||||
StringBuilder capturedBody = new StringBuilder();
|
||||
ServerWebExchange exchange = MockServerWebExchange.from(request);
|
||||
ServerWebExchange capturingExchange = exchange.mutate()
|
||||
.response(new ServerHttpResponseDecorator(exchange.getResponse()) {
|
||||
@Override
|
||||
public Mono<Void> writeWith(org.reactivestreams.Publisher<? extends DataBuffer> body) {
|
||||
return super.writeWith(Flux.from(body).doOnNext(buffer -> {
|
||||
byte[] bytes = new byte[buffer.readableByteCount()];
|
||||
buffer.read(bytes);
|
||||
capturedBody.append(new String(bytes, StandardCharsets.UTF_8));
|
||||
}));
|
||||
}
|
||||
})
|
||||
.build();
|
||||
|
||||
// 模拟下游返回明文 JSON
|
||||
when(chain.filter(any())).thenAnswer(invocation -> {
|
||||
ServerWebExchange ex = invocation.getArgument(0);
|
||||
byte[] responseBytes = JSON_RESPONSE.getBytes(StandardCharsets.UTF_8);
|
||||
DataBuffer buffer = new DefaultDataBufferFactory().wrap(responseBytes);
|
||||
return ex.getResponse().writeWith(Mono.just(buffer));
|
||||
});
|
||||
|
||||
StepVerifier.create(cryptoFilter.filter(capturingExchange, chain))
|
||||
.verifyComplete();
|
||||
|
||||
// 验证响应体可被解密还原
|
||||
String encryptedResponse = capturedBody.toString();
|
||||
assertThat(encryptedResponse).isNotBlank();
|
||||
assertThat(encryptedResponse).doesNotContain("token");
|
||||
|
||||
String decrypted = cryptoService.decryptToString(encryptedResponse);
|
||||
assertThat(decrypted).isEqualTo(JSON_RESPONSE);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user