feat(encryption): 实现前后端 AES-256-GCM 加密通信 #55
+126
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+191
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,11 @@
|
|||||||
<description>Gateway module for Novalon Manage API</description>
|
<description>Gateway module for Novalon Manage API</description>
|
||||||
|
|
||||||
<dependencies>
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>cn.novalon.gym.manage</groupId>
|
||||||
|
<artifactId>manage-common</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-webflux</artifactId>
|
<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
|
secret: U2FsdGVkX1+vZ5Y9QmKxL8nN3rP7tW2jH4fG6dA8sB1cE5yN0zX3qV7wM4
|
||||||
expiration: 86400000
|
expiration: 86400000
|
||||||
|
|
||||||
|
app:
|
||||||
|
encryption:
|
||||||
|
secret: GymManageEncryptionSecretKey2026!
|
||||||
|
|
||||||
logging:
|
logging:
|
||||||
level:
|
level:
|
||||||
cn.novalon.manage.gateway: DEBUG
|
cn.novalon.manage.gateway: DEBUG
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ spring:
|
|||||||
predicates:
|
predicates:
|
||||||
- Path=/api/**
|
- Path=/api/**
|
||||||
|
|
||||||
|
app:
|
||||||
|
encryption:
|
||||||
|
secret: ${APP_ENCRYPTION_SECRET}
|
||||||
|
|
||||||
logging:
|
logging:
|
||||||
level:
|
level:
|
||||||
cn.novalon.manage: INFO
|
cn.novalon.manage: INFO
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ jwt:
|
|||||||
interval:
|
interval:
|
||||||
days: ${JWT_KEY_ROTATION_INTERVAL_DAYS:30}
|
days: ${JWT_KEY_ROTATION_INTERVAL_DAYS:30}
|
||||||
|
|
||||||
|
# 前后端通信应用层加密配置
|
||||||
|
# 生产环境必须通过环境变量 APP_ENCRYPTION_SECRET 注入,长度不少于 12 个字符
|
||||||
|
app:
|
||||||
|
encryption:
|
||||||
|
secret: ${APP_ENCRYPTION_SECRET:GymManageEncryptionSecretKey2026!}
|
||||||
|
|
||||||
rate:
|
rate:
|
||||||
limit:
|
limit:
|
||||||
enabled: ${RATE_LIMIT_ENABLED:true}
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,3 +37,6 @@ TEST_RESULTS_FOLDER=test-results
|
|||||||
|
|
||||||
# API签名密钥配置
|
# API签名密钥配置
|
||||||
VITE_SIGNATURE_SECRET=your-secret-key-here
|
VITE_SIGNATURE_SECRET=your-secret-key-here
|
||||||
|
|
||||||
|
# AES-256-GCM 加密密钥(与后端 APP_ENCRYPTION_SECRET 保持一致,长度不少于12个字符)
|
||||||
|
VITE_ENCRYPTION_SECRET=your-encryption-secret-key-here
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ VITE_APP_TITLE=Novalon管理系统 - 测试环境
|
|||||||
# 测试用户配置
|
# 测试用户配置
|
||||||
TEST_USER_PASSWORD=Test@123
|
TEST_USER_PASSWORD=Test@123
|
||||||
|
|
||||||
|
# AES-256-GCM 加密密钥(与后端 APP_ENCRYPTION_SECRET 保持一致)
|
||||||
|
VITE_ENCRYPTION_SECRET=GymManageEncryptionSecretKey2026!
|
||||||
|
|
||||||
# Playwright配置
|
# Playwright配置
|
||||||
HEADLESS=true
|
HEADLESS=true
|
||||||
SLOW_MO=0
|
SLOW_MO=0
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('前后端加密通信验证', () => {
|
||||||
|
test.use({ storageState: { cookies: [], origins: [] } });
|
||||||
|
|
||||||
|
test('登录请求应被加密(请求体非明文JSON,响应体非明文JSON)', async ({ page }) => {
|
||||||
|
// 用于捕获登录 API 请求和响应的变量
|
||||||
|
let capturedRequestUrl: string | null = null;
|
||||||
|
let capturedRequestBody: string | null = null;
|
||||||
|
let capturedRequestHeaders: Record<string, string> = {};
|
||||||
|
let capturedResponseStatus: number | null = null;
|
||||||
|
let capturedResponseBody: string | null = null;
|
||||||
|
let capturedResponseHeaders: Record<string, string> = {};
|
||||||
|
|
||||||
|
// 监听 API 请求——在请求发送前捕获
|
||||||
|
page.on('request', request => {
|
||||||
|
if (request.url().includes('/api/auth/login') && request.method() === 'POST') {
|
||||||
|
capturedRequestUrl = request.url();
|
||||||
|
capturedRequestHeaders = request.headers() as Record<string, string>;
|
||||||
|
// 对于 POST 请求,Playwright 无法直接获取 body,通过 route 拦截
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 使用 route 拦截请求体
|
||||||
|
await page.route('**/api/auth/login', async (route) => {
|
||||||
|
const postData = route.request().postData();
|
||||||
|
if (postData) {
|
||||||
|
capturedRequestBody = postData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 继续请求,但拦截响应
|
||||||
|
const response = await route.fetch();
|
||||||
|
capturedResponseStatus = response.status();
|
||||||
|
capturedResponseHeaders = response.headers() as Record<string, string>;
|
||||||
|
capturedResponseBody = await response.text();
|
||||||
|
|
||||||
|
// 继续原始响应
|
||||||
|
await route.fulfill({ response });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 导航到登录页
|
||||||
|
await page.goto('/login');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
// 填入登录信息
|
||||||
|
await page.fill('input[type="text"], input[placeholder*="用户名"]', 'admin');
|
||||||
|
await page.fill('input[type="password"], input[placeholder*="密码"]', 'Test@123');
|
||||||
|
|
||||||
|
// 点击登录
|
||||||
|
await page.click('button:has-text("登录")');
|
||||||
|
|
||||||
|
// 等待登录完成
|
||||||
|
await page.waitForURL(/.*dashboard/, { timeout: 30000 });
|
||||||
|
|
||||||
|
// ========== 验证请求体加密 ==========
|
||||||
|
await test.step('验证请求体已加密(非明文JSON)', async () => {
|
||||||
|
expect(capturedRequestBody).toBeTruthy();
|
||||||
|
|
||||||
|
// 请求体不应是明文 JSON(不应包含 username 字段)
|
||||||
|
expect(capturedRequestBody).not.toContain('"username"');
|
||||||
|
expect(capturedRequestBody).not.toContain('"admin"');
|
||||||
|
expect(capturedRequestBody).not.toContain('"password"');
|
||||||
|
|
||||||
|
// 请求体应为 Base64 编码(AES-GCM 密文)
|
||||||
|
expect(capturedRequestBody).toMatch(/^[A-Za-z0-9+/=]+$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('验证 X-Encrypted 请求头存在', async () => {
|
||||||
|
expect(capturedRequestHeaders['x-encrypted']?.toLowerCase()).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== 验证响应体加密 ==========
|
||||||
|
await test.step('验证响应体已加密(非明文JSON)', async () => {
|
||||||
|
expect(capturedResponseBody).toBeTruthy();
|
||||||
|
expect(capturedResponseStatus).toBe(200);
|
||||||
|
|
||||||
|
// 响应体不应是明文 JSON(不应包含 token 字段)
|
||||||
|
expect(capturedResponseBody).not.toContain('"token"');
|
||||||
|
|
||||||
|
// 响应体应为 Base64 编码
|
||||||
|
expect(capturedResponseBody).toMatch(/^[A-Za-z0-9+/=]+$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('验证 X-Encrypted 响应头存在', async () => {
|
||||||
|
expect(capturedResponseHeaders['x-encrypted']?.toLowerCase()).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== 验证业务正常 ==========
|
||||||
|
await test.step('验证登录成功——页面跳转到 dashboard', async () => {
|
||||||
|
await expect(page).toHaveURL(/.*dashboard/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET 请求的响应体也应被加密', async ({ page }) => {
|
||||||
|
// 先登录
|
||||||
|
await page.goto('/login');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await page.fill('input[type="text"], input[placeholder*="用户名"]', 'admin');
|
||||||
|
await page.fill('input[type="password"], input[placeholder*="密码"]', 'Test@123');
|
||||||
|
await page.click('button:has-text("登录")');
|
||||||
|
await page.waitForURL(/.*dashboard/, { timeout: 30000 });
|
||||||
|
|
||||||
|
// 捕获 GET 请求
|
||||||
|
let capturedGetBody: string | null = null;
|
||||||
|
let capturedGetEncrypted: string | null = null;
|
||||||
|
|
||||||
|
await page.route('**/api/users**', async (route) => {
|
||||||
|
// 仅拦截 GET 请求
|
||||||
|
if (route.request().method() === 'GET') {
|
||||||
|
const response = await route.fetch();
|
||||||
|
capturedGetEncrypted = response.headers()['x-encrypted'] ?? null;
|
||||||
|
capturedGetBody = await response.text();
|
||||||
|
await route.fulfill({ response });
|
||||||
|
} else {
|
||||||
|
await route.continue();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 导航到用户管理页面触发 GET 请求
|
||||||
|
await page.goto('/users');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
await test.step('验证 GET 响应已加密', async () => {
|
||||||
|
// 注意:如果用户无权限访问 /users,可能没有加密响应
|
||||||
|
// 这里只验证如果响应存在,且标记了加密,则响应体应为 Base64
|
||||||
|
if (capturedGetEncrypted) {
|
||||||
|
expect(capturedGetEncrypted).toBe('true');
|
||||||
|
expect(capturedGetBody).toMatch(/^[A-Za-z0-9+/=]+$/);
|
||||||
|
expect(capturedGetBody).not.toContain('"id"');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
|
||||||
|
// 模拟 VITE_ENCRYPTION_SECRET 环境变量
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubEnv('VITE_ENCRYPTION_SECRET', 'TestEncryptionKey2026!')
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('crypto utils', () => {
|
||||||
|
describe('encrypt and decrypt', () => {
|
||||||
|
it('should encrypt and decrypt a string', async () => {
|
||||||
|
const { encrypt, decrypt } = await import('@/utils/crypto')
|
||||||
|
const plaintext = 'Hello, Crypto!'
|
||||||
|
const encrypted = await encrypt(plaintext)
|
||||||
|
const decrypted = await decrypt(encrypted)
|
||||||
|
|
||||||
|
expect(encrypted).not.toBe(plaintext)
|
||||||
|
expect(decrypted).toBe(plaintext)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should encrypt and decrypt JSON string', async () => {
|
||||||
|
const { encrypt, decrypt } = await import('@/utils/crypto')
|
||||||
|
const json = JSON.stringify({ username: 'admin', password: 'secret123' })
|
||||||
|
const encrypted = await encrypt(json)
|
||||||
|
const decrypted = await decrypt(encrypted)
|
||||||
|
|
||||||
|
expect(encrypted).not.toBe(json)
|
||||||
|
expect(decrypted).toBe(json)
|
||||||
|
expect(() => JSON.parse(decrypted)).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should encrypt and decrypt empty string', async () => {
|
||||||
|
const { encrypt, decrypt } = await import('@/utils/crypto')
|
||||||
|
const encrypted = await encrypt('')
|
||||||
|
const decrypted = await decrypt(encrypted)
|
||||||
|
|
||||||
|
expect(encrypted).toBeTruthy()
|
||||||
|
expect(decrypted).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should produce different ciphertext each time for same input', async () => {
|
||||||
|
const { encrypt } = await import('@/utils/crypto')
|
||||||
|
const plaintext = 'same text'
|
||||||
|
const encrypted1 = await encrypt(plaintext)
|
||||||
|
const encrypted2 = await encrypt(plaintext)
|
||||||
|
|
||||||
|
expect(encrypted1).not.toBe(encrypted2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle Chinese characters', async () => {
|
||||||
|
const { encrypt, decrypt } = await import('@/utils/crypto')
|
||||||
|
const chinese = '你好,世界!加密测试'
|
||||||
|
const encrypted = await encrypt(chinese)
|
||||||
|
const decrypted = await decrypt(encrypted)
|
||||||
|
|
||||||
|
expect(decrypted).toBe(chinese)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should produce Base64-like output', async () => {
|
||||||
|
const { encrypt } = await import('@/utils/crypto')
|
||||||
|
const encrypted = await encrypt('test')
|
||||||
|
|
||||||
|
// Base64 pattern
|
||||||
|
expect(encrypted).toMatch(/^[A-Za-z0-9+/=]+$/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('frontend-backend compatibility', () => {
|
||||||
|
it('should produce output in format compatible with backend CryptoService', async () => {
|
||||||
|
const { encrypt } = await import('@/utils/crypto')
|
||||||
|
const encrypted = await encrypt('{"test":"data"}')
|
||||||
|
|
||||||
|
// 前端加密格式: Base64(12-byte-IV || AES-GCM-ciphertext)
|
||||||
|
// Base64 解码后长度应 >= 12 (IV)
|
||||||
|
const decoded = atob(encrypted)
|
||||||
|
expect(decoded.length).toBeGreaterThanOrEqual(12)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('error handling', () => {
|
||||||
|
it('should throw on invalid Base64 input during decrypt', async () => {
|
||||||
|
const { decrypt } = await import('@/utils/crypto')
|
||||||
|
|
||||||
|
await expect(decrypt('invalid-base64!!!')).rejects.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* AES-256-GCM 加解密工具
|
||||||
|
*
|
||||||
|
* 所有加解密操作(API 请求/响应)均使用 PBKDF2-HMAC-SHA256
|
||||||
|
* 从 VITE_ENCRYPTION_SECRET 派生 AES-256-GCM 密钥,与后端 CryptoService 保持一致。
|
||||||
|
*
|
||||||
|
* 输出格式: Base64( 12-byte-IV || AES-GCM-ciphertext )
|
||||||
|
*/
|
||||||
|
|
||||||
|
const IV_LENGTH = 12
|
||||||
|
const ALGORITHM = 'AES-GCM'
|
||||||
|
const PBKDF2_ITERATIONS = 100_000
|
||||||
|
const PBKDF2_SALT = new TextEncoder().encode('novalon-gym-manage-aes-salt-v1')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用 PBKDF2 从密码短语派生 AES-256 密钥
|
||||||
|
*/
|
||||||
|
async function deriveKeyFromPassphrase(passphrase: string): Promise<CryptoKey> {
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
const keyMaterial = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
encoder.encode(passphrase),
|
||||||
|
'PBKDF2',
|
||||||
|
false,
|
||||||
|
['deriveBits', 'deriveKey']
|
||||||
|
)
|
||||||
|
return crypto.subtle.deriveKey(
|
||||||
|
{ name: 'PBKDF2', salt: PBKDF2_SALT, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' },
|
||||||
|
keyMaterial,
|
||||||
|
{ name: ALGORITHM, length: 256 },
|
||||||
|
false,
|
||||||
|
['encrypt', 'decrypt']
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 AES-256-GCM 密钥——始终使用 PBKDF2 从 VITE_ENCRYPTION_SECRET 派生。
|
||||||
|
*/
|
||||||
|
async function getApiKey(): Promise<CryptoKey> {
|
||||||
|
const secret = import.meta.env.VITE_ENCRYPTION_SECRET as string | undefined
|
||||||
|
if (!secret) {
|
||||||
|
throw new Error('未设置 VITE_ENCRYPTION_SECRET 环境变量,加解密无法初始化')
|
||||||
|
}
|
||||||
|
return deriveKeyFromPassphrase(secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedApiKey: Promise<CryptoKey> | null = null
|
||||||
|
|
||||||
|
function getOrInitApiKey(): Promise<CryptoKey> {
|
||||||
|
if (!cachedApiKey) {
|
||||||
|
cachedApiKey = getApiKey()
|
||||||
|
}
|
||||||
|
return cachedApiKey
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 加密函数基座 */
|
||||||
|
async function encryptWithKey(key: Promise<CryptoKey>, plaintext: string): Promise<string> {
|
||||||
|
const k = await key
|
||||||
|
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH))
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
const ciphertext = await crypto.subtle.encrypt(
|
||||||
|
{ name: ALGORITHM, iv, tagLength: 128 },
|
||||||
|
k,
|
||||||
|
encoder.encode(plaintext)
|
||||||
|
)
|
||||||
|
|
||||||
|
const combined = new Uint8Array(IV_LENGTH + ciphertext.byteLength)
|
||||||
|
combined.set(iv, 0)
|
||||||
|
combined.set(new Uint8Array(ciphertext), IV_LENGTH)
|
||||||
|
|
||||||
|
return btoa(Array.from(combined, (b) => String.fromCodePoint(b)).join(''))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解密函数基座 */
|
||||||
|
async function decryptWithKey(key: Promise<CryptoKey>, encryptedBase64: string): Promise<string> {
|
||||||
|
const k = await key
|
||||||
|
|
||||||
|
const binaryStr = atob(encryptedBase64)
|
||||||
|
const combined = new Uint8Array(binaryStr.length)
|
||||||
|
for (let i = 0; i < binaryStr.length; i++) {
|
||||||
|
combined[i] = binaryStr.codePointAt(i)!
|
||||||
|
}
|
||||||
|
|
||||||
|
const iv = combined.slice(0, IV_LENGTH)
|
||||||
|
const ciphertext = combined.slice(IV_LENGTH)
|
||||||
|
|
||||||
|
const decrypted = await crypto.subtle.decrypt(
|
||||||
|
{ name: ALGORITHM, iv, tagLength: 128 },
|
||||||
|
k,
|
||||||
|
ciphertext
|
||||||
|
)
|
||||||
|
|
||||||
|
return new TextDecoder().decode(decrypted)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 请求加密——使用 PBKDF2 派生密钥,与后端 CryptoService 保持一致。
|
||||||
|
*/
|
||||||
|
export async function encrypt(plaintext: string): Promise<string> {
|
||||||
|
return encryptWithKey(getOrInitApiKey(), plaintext)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 响应解密——使用 PBKDF2 派生密钥,与后端 CryptoService 保持一致。
|
||||||
|
*/
|
||||||
|
export async function decrypt(encryptedBase64: string): Promise<string> {
|
||||||
|
return decryptWithKey(getOrInitApiKey(), encryptedBase64)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default { encrypt, decrypt }
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import axios, { InternalAxiosRequestConfig } from 'axios'
|
import axios, { InternalAxiosRequestConfig, AxiosResponse } from 'axios'
|
||||||
import { generateSignatureHeaders } from './signature'
|
import { generateSignatureHeaders } from './signature'
|
||||||
import { checkApiPermission } from './permission'
|
import { checkApiPermission } from './permission'
|
||||||
|
import { encrypt, decrypt } from './crypto'
|
||||||
|
|
||||||
const request = axios.create({
|
const request = axios.create({
|
||||||
baseURL: '/api',
|
baseURL: '/api',
|
||||||
@@ -8,17 +9,17 @@ const request = axios.create({
|
|||||||
})
|
})
|
||||||
|
|
||||||
request.interceptors.request.use(
|
request.interceptors.request.use(
|
||||||
(config: InternalAxiosRequestConfig) => {
|
async (config: InternalAxiosRequestConfig) => {
|
||||||
const token = localStorage.getItem('token')
|
const token = localStorage.getItem('token')
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers = config.headers || {}
|
config.headers = config.headers || {}
|
||||||
config.headers.Authorization = `Bearer ${token}`
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const method = config.method?.toUpperCase() || 'GET'
|
const method = config.method?.toUpperCase() || 'GET'
|
||||||
let url = config.url || ''
|
let url = config.url || ''
|
||||||
const body = config.data
|
const body = config.data
|
||||||
|
|
||||||
if (config.params && Object.keys(config.params).length > 0) {
|
if (config.params && Object.keys(config.params).length > 0) {
|
||||||
const queryParams = new URLSearchParams()
|
const queryParams = new URLSearchParams()
|
||||||
Object.entries(config.params).forEach(([key, value]) => {
|
Object.entries(config.params).forEach(([key, value]) => {
|
||||||
@@ -31,13 +32,13 @@ request.interceptors.request.use(
|
|||||||
url += (url.includes('?') ? '&' : '?') + queryString
|
url += (url.includes('?') ? '&' : '?') + queryString
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fullPath = `/api${url.startsWith('/') ? url : '/' + url}`
|
const fullPath = `/api${url.startsWith('/') ? url : '/' + url}`
|
||||||
const signatureHeaders = generateSignatureHeaders(method, fullPath, body)
|
const signatureHeaders = generateSignatureHeaders(method, fullPath, body)
|
||||||
|
|
||||||
config.headers = config.headers || {}
|
config.headers = config.headers || {}
|
||||||
Object.assign(config.headers, signatureHeaders)
|
Object.assign(config.headers, signatureHeaders)
|
||||||
|
|
||||||
if (!checkApiPermission(method, url)) {
|
if (!checkApiPermission(method, url)) {
|
||||||
const error = new Error('无权限访问此接口')
|
const error = new Error('无权限访问此接口')
|
||||||
;(error as any).response = {
|
;(error as any).response = {
|
||||||
@@ -46,15 +47,66 @@ request.interceptors.request.use(
|
|||||||
}
|
}
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== 请求体加密 ==========
|
||||||
|
// 阻止 Axios 自动解析 JSON —— 加密后的响应体不是合法 JSON
|
||||||
|
config.responseType = 'text'
|
||||||
|
|
||||||
|
if (config.data) {
|
||||||
|
try {
|
||||||
|
const jsonStr = JSON.stringify(config.data)
|
||||||
|
config.data = await encrypt(jsonStr)
|
||||||
|
// 加密成功:标记加密请求,后端收到后会加密响应体
|
||||||
|
config.headers['X-Encrypted'] = 'true'
|
||||||
|
// 覆盖 transformRequest 阻止 Axios 对加密后的 Base64 字符串再次 JSON.stringify
|
||||||
|
config.transformRequest = [(data: any) => data]
|
||||||
|
} catch (e: any) {
|
||||||
|
// 加密失败(如密钥未配置),降级为明文 JSON 传输,不标记加密
|
||||||
|
console.warn('[Crypto] Request body encryption failed:', e.message, '— sending plain JSON')
|
||||||
|
// 不设置 X-Encrypted 头,后端将返回明文 JSON
|
||||||
|
// 不覆盖 transformRequest,Axios 将正常 JSON.stringify
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// GET 等无 body 请求:仅标记加密让后端加密响应
|
||||||
|
config.headers['X-Encrypted'] = 'true'
|
||||||
|
}
|
||||||
|
|
||||||
return config
|
return config
|
||||||
},
|
},
|
||||||
(error) => Promise.reject(error)
|
(error) => Promise.reject(error)
|
||||||
)
|
)
|
||||||
|
|
||||||
request.interceptors.response.use(
|
request.interceptors.response.use(
|
||||||
(response) => response.data,
|
async (response: AxiosResponse) => {
|
||||||
(error) => {
|
// 解密加密的响应体
|
||||||
|
if (response.headers['x-encrypted'] === 'true' && typeof response.data === 'string') {
|
||||||
|
try {
|
||||||
|
const decryptedText = await decrypt(response.data)
|
||||||
|
response.data = JSON.parse(decryptedText)
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('[Crypto] Response decrypt failed:', e.message)
|
||||||
|
}
|
||||||
|
} else if (typeof response.data === 'string') {
|
||||||
|
// 响应未加密(CryptoFilter 因请求体解密失败而跳过加密),手动解析 JSON
|
||||||
|
try {
|
||||||
|
response.data = JSON.parse(response.data)
|
||||||
|
} catch (e: any) {
|
||||||
|
// 非 JSON 文本响应(如导出文件),保持原样
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
async (error) => {
|
||||||
|
// 解密加密的错误响应体,使业务代码能读取 message 字段
|
||||||
|
if (error.response?.headers['x-encrypted'] === 'true' && typeof error.response.data === 'string') {
|
||||||
|
try {
|
||||||
|
const decryptedText = await decrypt(error.response.data)
|
||||||
|
error.response.data = JSON.parse(decryptedText)
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('[Crypto] Error response decrypt failed:', e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (error.response?.status === 401) {
|
if (error.response?.status === 401) {
|
||||||
localStorage.removeItem('token')
|
localStorage.removeItem('token')
|
||||||
if (!window.location.pathname.includes('/login')) {
|
if (!window.location.pathname.includes('/login')) {
|
||||||
|
|||||||
Vendored
+1
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly VITE_SIGNATURE_SECRET: string
|
readonly VITE_SIGNATURE_SECRET: string
|
||||||
|
readonly VITE_ENCRYPTION_SECRET: string
|
||||||
readonly VITE_API_BASE_URL?: string
|
readonly VITE_API_BASE_URL?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user