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:
2026-08-02 08:51:21 +08:00
parent 015cb0dc78
commit eb33755f23
16 changed files with 1178 additions and 10 deletions
+5
View File
@@ -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>
@@ -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);
}
}
@@ -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}
@@ -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);
}
}
}