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
@@ -0,0 +1,126 @@
package cn.novalon.gym.manage.common.util;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.PBEKeySpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.security.spec.KeySpec;
import java.util.Base64;
/**
* AES-256-GCM 加解密服务
* <p>
* 用于前后端通信的应用层加密 —— 在 HTTPS 基础上额外加密 JSON 载荷。
* 格式: Base64( 12-byte-IV || AES-GCM-ciphertext )
* 密钥通过 PBKDF2-HMAC-SHA256 从密码短语派生,与前端 crypto.ts 保持一致。
* </p>
*
* @author 张翔
* @date 2026-08-02
*/
public class CryptoService {
private static final Logger log = LoggerFactory.getLogger(CryptoService.class);
private static final int GCM_IV_LENGTH = 12;
private static final int GCM_TAG_LENGTH = 128;
private static final String ALGORITHM = "AES/GCM/NoPadding";
private static final String KEY_DERIVATION_ALGORITHM = "PBKDF2WithHmacSHA256";
private static final int KEY_LENGTH_BITS = 256;
private static final int PBKDF2_ITERATIONS = 100_000;
private static final byte[] PBKDF2_SALT =
"novalon-gym-manage-aes-salt-v1".getBytes(StandardCharsets.UTF_8);
private final SecretKey secretKey;
/**
* @param passphrase 用于派生 AES-256 密钥的密码短语,长度不少于 12 个字符
*/
public CryptoService(String passphrase) {
if (passphrase == null || passphrase.isBlank()) {
throw new IllegalArgumentException("encryption passphrase must not be empty");
}
if (passphrase.length() < 12) {
throw new IllegalArgumentException(
"encryption passphrase must be at least 12 characters");
}
try {
SecretKeyFactory factory = SecretKeyFactory.getInstance(KEY_DERIVATION_ALGORITHM);
KeySpec spec = new PBEKeySpec(
passphrase.toCharArray(), PBKDF2_SALT, PBKDF2_ITERATIONS, KEY_LENGTH_BITS);
byte[] keyBytes = factory.generateSecret(spec).getEncoded();
this.secretKey = new SecretKeySpec(keyBytes, "AES");
log.info("CryptoService initialized (AES-256-GCM, PBKDF2)");
} catch (Exception e) {
throw new RuntimeException("Failed to initialize CryptoService", e);
}
}
/**
* 加密字节数组并返回 Base64 编码的密文(IV 被前置)
*/
public String encrypt(byte[] plaintext) {
try {
Cipher cipher = Cipher.getInstance(ALGORITHM);
byte[] iv = new byte[GCM_IV_LENGTH];
SecureRandom.getInstanceStrong().nextBytes(iv);
GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
cipher.init(Cipher.ENCRYPT_MODE, secretKey, spec);
byte[] ciphertext = cipher.doFinal(plaintext);
byte[] combined = ByteBuffer.allocate(GCM_IV_LENGTH + ciphertext.length)
.put(iv)
.put(ciphertext)
.array();
return Base64.getEncoder().encodeToString(combined);
} catch (Exception e) {
throw new RuntimeException("Encryption failed", e);
}
}
/**
* 解密 Base64 编码的密文(IV 被前置)
*/
public byte[] decrypt(String encryptedBase64) {
try {
byte[] combined = Base64.getDecoder().decode(encryptedBase64);
ByteBuffer buffer = ByteBuffer.wrap(combined);
byte[] iv = new byte[GCM_IV_LENGTH];
buffer.get(iv);
byte[] ciphertext = new byte[buffer.remaining()];
buffer.get(ciphertext);
Cipher cipher = Cipher.getInstance(ALGORITHM);
GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
cipher.init(Cipher.DECRYPT_MODE, secretKey, spec);
return cipher.doFinal(ciphertext);
} catch (Exception e) {
throw new RuntimeException("Decryption failed", e);
}
}
/**
* 加密字符串
*/
public String encryptString(String plaintext) {
return encrypt(plaintext.getBytes(StandardCharsets.UTF_8));
}
/**
* 解密字符串
*/
public String decryptToString(String encryptedBase64) {
return new String(decrypt(encryptedBase64), StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,191 @@
package cn.novalon.gym.manage.common.util;
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 java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@DisplayName("CryptoService 单元测试")
class CryptoServiceTest {
private static final String TEST_PASSPHRASE = "TestEncryptionKey2026!";
private CryptoService cryptoService;
@BeforeEach
void setUp() {
cryptoService = new CryptoService(TEST_PASSPHRASE);
}
@Nested
@DisplayName("构造方法测试")
class ConstructorTest {
@Test
@DisplayName("密码短语为空时应抛出异常")
void shouldThrowWhenPassphraseIsNull() {
assertThatThrownBy(() -> new CryptoService(null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("must not be empty");
}
@Test
@DisplayName("密码短语为空字符串时应抛出异常")
void shouldThrowWhenPassphraseIsBlank() {
assertThatThrownBy(() -> new CryptoService(" "))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("must not be empty");
}
@Test
@DisplayName("密码短语少于12个字符时应抛出异常")
void shouldThrowWhenPassphraseTooShort() {
assertThatThrownBy(() -> new CryptoService("short"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("at least 12 characters");
}
}
@Nested
@DisplayName("加解密测试")
class EncryptDecryptTest {
@Test
@DisplayName("应对字符串进行加密并成功解密")
void shouldEncryptAndDecryptString() {
String plaintext = "Hello, CryptoService!";
String encrypted = cryptoService.encryptString(plaintext);
String decrypted = cryptoService.decryptToString(encrypted);
assertThat(encrypted).isNotEqualTo(plaintext);
assertThat(decrypted).isEqualTo(plaintext);
}
@Test
@DisplayName("应对JSON字符串进行加密并成功解密")
void shouldEncryptAndDecryptJson() {
String json = "{\"username\":\"admin\",\"password\":\"secret123\"}";
String encrypted = cryptoService.encryptString(json);
String decrypted = cryptoService.decryptToString(encrypted);
assertThat(encrypted).isNotEqualTo(json);
assertThat(decrypted).isEqualTo(json);
}
@Test
@DisplayName("应对空字符串进行加密并成功解密")
void shouldEncryptAndDecryptEmptyString() {
String plaintext = "";
String encrypted = cryptoService.encryptString(plaintext);
String decrypted = cryptoService.decryptToString(encrypted);
assertThat(encrypted).isNotBlank();
assertThat(decrypted).isEqualTo(plaintext);
}
@Test
@DisplayName("加密结果应为Base64编码")
void shouldProduceBase64Output() {
String encrypted = cryptoService.encryptString("test");
// Base64 pattern: alphanumeric, +, /, =
assertThat(encrypted).matches("^[A-Za-z0-9+/=]+$");
}
@Test
@DisplayName("每次加密应产生不同结果(不同IV")
void shouldProduceDifferentCiphertextEachTime() {
String plaintext = "same text";
String encrypted1 = cryptoService.encryptString(plaintext);
String encrypted2 = cryptoService.encryptString(plaintext);
assertThat(encrypted1).isNotEqualTo(encrypted2);
}
@Test
@DisplayName("应处理中文字符")
void shouldHandleChineseCharacters() {
String chinese = "你好,世界!加密测试";
String encrypted = cryptoService.encryptString(chinese);
String decrypted = cryptoService.decryptToString(encrypted);
assertThat(decrypted).isEqualTo(chinese);
}
@Test
@DisplayName("应处理长文本")
void shouldHandleLongText() {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 1000; i++) {
sb.append("Long text content for testing. ");
}
String longText = sb.toString();
String encrypted = cryptoService.encryptString(longText);
String decrypted = cryptoService.decryptToString(encrypted);
assertThat(decrypted).isEqualTo(longText);
}
@Test
@DisplayName("加密字节数组与解密后应一致")
void shouldEncryptAndDecryptByteArray() {
byte[] plaintext = "byte array test".getBytes(StandardCharsets.UTF_8);
String encrypted = cryptoService.encrypt(plaintext);
byte[] decrypted = cryptoService.decrypt(encrypted);
assertThat(decrypted).isEqualTo(plaintext);
}
}
@Nested
@DisplayName("异常处理测试")
class ExceptionHandlingTest {
@Test
@DisplayName("解密无效的Base64时应抛出异常")
void shouldThrowOnInvalidBase64() {
assertThatThrownBy(() -> cryptoService.decrypt("invalid-base64!!!"))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("Decryption failed");
}
@Test
@DisplayName("解密非法的密文时应抛出异常")
void shouldThrowOnInvalidCiphertext() {
// 合法的Base64,但不是有效的AES-GCM密文
String fakeCiphertext = "dGhpcyBpcyBpbnZhbGlkIGNpcGhlcnRleHQ=";
assertThatThrownBy(() -> cryptoService.decrypt(fakeCiphertext))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("Decryption failed");
}
}
@Nested
@DisplayName("不同密钥实例兼容性测试")
class KeyCompatibilityTest {
@Test
@DisplayName("相同密码短语生成的实例应能互相解密")
void shouldBeInteroperableWithSamePassphrase() {
CryptoService another = new CryptoService(TEST_PASSPHRASE);
String plaintext = "interoperability test";
String encrypted = cryptoService.encryptString(plaintext);
String decrypted = another.decryptToString(encrypted);
assertThat(decrypted).isEqualTo(plaintext);
}
@Test
@DisplayName("不同密码短语生成的实例不应能互相解密")
void shouldNotBeInteroperableWithDifferentPassphrase() {
CryptoService another = new CryptoService("DifferentPassphraseKey!@#");
String encrypted = cryptoService.encryptString("secret data");
assertThatThrownBy(() -> another.decrypt(encrypted))
.isInstanceOf(RuntimeException.class);
}
}
}
+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);
}
}
}