From eb33755f23f96bbf0f26c0abbbe0e99d26f60e34 Mon Sep 17 00:00:00 2001 From: zhangxiang Date: Sun, 2 Aug 2026 08:51:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(encryption):=20=E5=AE=9E=E7=8E=B0=E5=89=8D?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=20AES-256-GCM=20=E5=8A=A0=E5=AF=86=E9=80=9A?= =?UTF-8?q?=E4=BF=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 参考同级项目 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 个前端测试 + 后端全量测试全部通过,无回归 --- .../gym/manage/common/util/CryptoService.java | 126 ++++++++++ .../manage/common/util/CryptoServiceTest.java | 191 ++++++++++++++ gym-manage-api/manage-gateway/pom.xml | 5 + .../manage/gateway/config/CryptoConfig.java | 21 ++ .../manage/gateway/filter/CryptoFilter.java | 181 +++++++++++++ .../src/main/resources/application-local.yml | 4 + .../src/main/resources/application-prod.yml | 4 + .../src/main/resources/application.yml | 6 + .../gateway/filter/CryptoFilterTest.java | 238 ++++++++++++++++++ gym-manage-web/.env.example | 3 + gym-manage-web/.env.test | 3 + .../e2e/smoke/encryption-smoke.spec.ts | 133 ++++++++++ .../src/__tests__/utils/crypto.test.ts | 90 +++++++ gym-manage-web/src/utils/crypto.ts | 110 ++++++++ gym-manage-web/src/utils/request.ts | 72 +++++- gym-manage-web/src/vite-env.d.ts | 1 + 16 files changed, 1178 insertions(+), 10 deletions(-) create mode 100644 gym-manage-api/manage-common/src/main/java/cn/novalon/gym/manage/common/util/CryptoService.java create mode 100644 gym-manage-api/manage-common/src/test/java/cn/novalon/gym/manage/common/util/CryptoServiceTest.java create mode 100644 gym-manage-api/manage-gateway/src/main/java/cn/novalon/gym/manage/gateway/config/CryptoConfig.java create mode 100644 gym-manage-api/manage-gateway/src/main/java/cn/novalon/gym/manage/gateway/filter/CryptoFilter.java create mode 100644 gym-manage-api/manage-gateway/src/test/java/cn/novalon/gym/manage/gateway/filter/CryptoFilterTest.java create mode 100644 gym-manage-web/e2e/smoke/encryption-smoke.spec.ts create mode 100644 gym-manage-web/src/__tests__/utils/crypto.test.ts create mode 100644 gym-manage-web/src/utils/crypto.ts diff --git a/gym-manage-api/manage-common/src/main/java/cn/novalon/gym/manage/common/util/CryptoService.java b/gym-manage-api/manage-common/src/main/java/cn/novalon/gym/manage/common/util/CryptoService.java new file mode 100644 index 0000000..82a77a8 --- /dev/null +++ b/gym-manage-api/manage-common/src/main/java/cn/novalon/gym/manage/common/util/CryptoService.java @@ -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 加解密服务 + *

+ * 用于前后端通信的应用层加密 —— 在 HTTPS 基础上额外加密 JSON 载荷。 + * 格式: Base64( 12-byte-IV || AES-GCM-ciphertext ) + * 密钥通过 PBKDF2-HMAC-SHA256 从密码短语派生,与前端 crypto.ts 保持一致。 + *

+ * + * @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); + } +} \ No newline at end of file diff --git a/gym-manage-api/manage-common/src/test/java/cn/novalon/gym/manage/common/util/CryptoServiceTest.java b/gym-manage-api/manage-common/src/test/java/cn/novalon/gym/manage/common/util/CryptoServiceTest.java new file mode 100644 index 0000000..f67c4a0 --- /dev/null +++ b/gym-manage-api/manage-common/src/test/java/cn/novalon/gym/manage/common/util/CryptoServiceTest.java @@ -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); + } + } +} \ No newline at end of file diff --git a/gym-manage-api/manage-gateway/pom.xml b/gym-manage-api/manage-gateway/pom.xml index 1c25330..974c668 100644 --- a/gym-manage-api/manage-gateway/pom.xml +++ b/gym-manage-api/manage-gateway/pom.xml @@ -17,6 +17,11 @@ Gateway module for Novalon Manage API + + cn.novalon.gym.manage + manage-common + ${project.version} + org.springframework.boot spring-boot-starter-webflux diff --git a/gym-manage-api/manage-gateway/src/main/java/cn/novalon/gym/manage/gateway/config/CryptoConfig.java b/gym-manage-api/manage-gateway/src/main/java/cn/novalon/gym/manage/gateway/config/CryptoConfig.java new file mode 100644 index 0000000..5d932c6 --- /dev/null +++ b/gym-manage-api/manage-gateway/src/main/java/cn/novalon/gym/manage/gateway/config/CryptoConfig.java @@ -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); + } +} \ No newline at end of file diff --git a/gym-manage-api/manage-gateway/src/main/java/cn/novalon/gym/manage/gateway/filter/CryptoFilter.java b/gym-manage-api/manage-gateway/src/main/java/cn/novalon/gym/manage/gateway/filter/CryptoFilter.java new file mode 100644 index 0000000..aca43ac --- /dev/null +++ b/gym-manage-api/manage-gateway/src/main/java/cn/novalon/gym/manage/gateway/filter/CryptoFilter.java @@ -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; + +/** + * 应用层加密/解密全局过滤器。 + *

+ * 当请求携带 {@code X-Encrypted: true} 头时: + *

    + *
  • 请求体:在 filter 层主动解密为 JSON 明文,再交给下游 handler
  • + *
  • 响应体:将 handler 返回的 JSON 明文加密为 Base64(AES-GCM IV‖密文),并标记 {@code X-Encrypted: true}
  • + *
+ * 不携带该头的请求直接透传(兼容 curl/Postman 等调试工具)。 + *

+ *

+ * 安全约束:携带 {@code X-Encrypted: true} 但解密失败的请求必须返回 400 Bad Request, + * 禁止静默降级为明文传输,防止中间人/客户端误用导致的数据暴露。 + *

+ *

+ * 优先级:在 {@link SignatureFilter} 之后执行,确保已通过签名验证的请求再进行加解密处理。 + *

+ * + * @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 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 getBody() { + return Flux.just(exchange.getResponse().bufferFactory().wrap(decryptedBytes)); + } + }; + } + + // ========== 响应体加密 ========== + + private ServerHttpResponseDecorator buildEncryptedResponse(ServerWebExchange exchange) { + return new ServerHttpResponseDecorator(exchange.getResponse()) { + + @Override + public Mono writeWith(org.reactivestreams.Publisher 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 writeAndFlushWith( + org.reactivestreams.Publisher> body) { + return writeWith(Flux.from(body).flatMapSequential(p -> p)); + } + }; + } +} \ No newline at end of file diff --git a/gym-manage-api/manage-gateway/src/main/resources/application-local.yml b/gym-manage-api/manage-gateway/src/main/resources/application-local.yml index c7b015a..b60582d 100644 --- a/gym-manage-api/manage-gateway/src/main/resources/application-local.yml +++ b/gym-manage-api/manage-gateway/src/main/resources/application-local.yml @@ -32,6 +32,10 @@ jwt: secret: U2FsdGVkX1+vZ5Y9QmKxL8nN3rP7tW2jH4fG6dA8sB1cE5yN0zX3qV7wM4 expiration: 86400000 +app: + encryption: + secret: GymManageEncryptionSecretKey2026! + logging: level: cn.novalon.manage.gateway: DEBUG diff --git a/gym-manage-api/manage-gateway/src/main/resources/application-prod.yml b/gym-manage-api/manage-gateway/src/main/resources/application-prod.yml index 76086cf..147dea3 100644 --- a/gym-manage-api/manage-gateway/src/main/resources/application-prod.yml +++ b/gym-manage-api/manage-gateway/src/main/resources/application-prod.yml @@ -7,6 +7,10 @@ spring: predicates: - Path=/api/** +app: + encryption: + secret: ${APP_ENCRYPTION_SECRET} + logging: level: cn.novalon.manage: INFO diff --git a/gym-manage-api/manage-gateway/src/main/resources/application.yml b/gym-manage-api/manage-gateway/src/main/resources/application.yml index d9819fd..50aee34 100644 --- a/gym-manage-api/manage-gateway/src/main/resources/application.yml +++ b/gym-manage-api/manage-gateway/src/main/resources/application.yml @@ -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} diff --git a/gym-manage-api/manage-gateway/src/test/java/cn/novalon/gym/manage/gateway/filter/CryptoFilterTest.java b/gym-manage-api/manage-gateway/src/test/java/cn/novalon/gym/manage/gateway/filter/CryptoFilterTest.java new file mode 100644 index 0000000..b6daf83 --- /dev/null +++ b/gym-manage-api/manage-gateway/src/test/java/cn/novalon/gym/manage/gateway/filter/CryptoFilterTest.java @@ -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 writeWith(org.reactivestreams.Publisher 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); + } + } +} \ No newline at end of file diff --git a/gym-manage-web/.env.example b/gym-manage-web/.env.example index ff92041..41b57f2 100644 --- a/gym-manage-web/.env.example +++ b/gym-manage-web/.env.example @@ -37,3 +37,6 @@ TEST_RESULTS_FOLDER=test-results # API签名密钥配置 VITE_SIGNATURE_SECRET=your-secret-key-here + +# AES-256-GCM 加密密钥(与后端 APP_ENCRYPTION_SECRET 保持一致,长度不少于12个字符) +VITE_ENCRYPTION_SECRET=your-encryption-secret-key-here diff --git a/gym-manage-web/.env.test b/gym-manage-web/.env.test index 3026a06..b621e8b 100644 --- a/gym-manage-web/.env.test +++ b/gym-manage-web/.env.test @@ -5,6 +5,9 @@ VITE_APP_TITLE=Novalon管理系统 - 测试环境 # 测试用户配置 TEST_USER_PASSWORD=Test@123 +# AES-256-GCM 加密密钥(与后端 APP_ENCRYPTION_SECRET 保持一致) +VITE_ENCRYPTION_SECRET=GymManageEncryptionSecretKey2026! + # Playwright配置 HEADLESS=true SLOW_MO=0 diff --git a/gym-manage-web/e2e/smoke/encryption-smoke.spec.ts b/gym-manage-web/e2e/smoke/encryption-smoke.spec.ts new file mode 100644 index 0000000..5097a1b --- /dev/null +++ b/gym-manage-web/e2e/smoke/encryption-smoke.spec.ts @@ -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 = {}; + let capturedResponseStatus: number | null = null; + let capturedResponseBody: string | null = null; + let capturedResponseHeaders: Record = {}; + + // 监听 API 请求——在请求发送前捕获 + page.on('request', request => { + if (request.url().includes('/api/auth/login') && request.method() === 'POST') { + capturedRequestUrl = request.url(); + capturedRequestHeaders = request.headers() as Record; + // 对于 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; + 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"'); + } + }); + }); +}); \ No newline at end of file diff --git a/gym-manage-web/src/__tests__/utils/crypto.test.ts b/gym-manage-web/src/__tests__/utils/crypto.test.ts new file mode 100644 index 0000000..b8f3407 --- /dev/null +++ b/gym-manage-web/src/__tests__/utils/crypto.test.ts @@ -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() + }) + }) +}) \ No newline at end of file diff --git a/gym-manage-web/src/utils/crypto.ts b/gym-manage-web/src/utils/crypto.ts new file mode 100644 index 0000000..36f1038 --- /dev/null +++ b/gym-manage-web/src/utils/crypto.ts @@ -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 { + 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 { + 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 | null = null + +function getOrInitApiKey(): Promise { + if (!cachedApiKey) { + cachedApiKey = getApiKey() + } + return cachedApiKey +} + +/** 加密函数基座 */ +async function encryptWithKey(key: Promise, plaintext: string): Promise { + 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, encryptedBase64: string): Promise { + 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 { + return encryptWithKey(getOrInitApiKey(), plaintext) +} + +/** + * API 响应解密——使用 PBKDF2 派生密钥,与后端 CryptoService 保持一致。 + */ +export async function decrypt(encryptedBase64: string): Promise { + return decryptWithKey(getOrInitApiKey(), encryptedBase64) +} + +export default { encrypt, decrypt } \ No newline at end of file diff --git a/gym-manage-web/src/utils/request.ts b/gym-manage-web/src/utils/request.ts index 9d14ac4..a6ec15b 100644 --- a/gym-manage-web/src/utils/request.ts +++ b/gym-manage-web/src/utils/request.ts @@ -1,6 +1,7 @@ -import axios, { InternalAxiosRequestConfig } from 'axios' +import axios, { InternalAxiosRequestConfig, AxiosResponse } from 'axios' import { generateSignatureHeaders } from './signature' import { checkApiPermission } from './permission' +import { encrypt, decrypt } from './crypto' const request = axios.create({ baseURL: '/api', @@ -8,17 +9,17 @@ const request = axios.create({ }) request.interceptors.request.use( - (config: InternalAxiosRequestConfig) => { + async (config: InternalAxiosRequestConfig) => { const token = localStorage.getItem('token') if (token) { config.headers = config.headers || {} config.headers.Authorization = `Bearer ${token}` } - + const method = config.method?.toUpperCase() || 'GET' let url = config.url || '' const body = config.data - + if (config.params && Object.keys(config.params).length > 0) { const queryParams = new URLSearchParams() Object.entries(config.params).forEach(([key, value]) => { @@ -31,13 +32,13 @@ request.interceptors.request.use( url += (url.includes('?') ? '&' : '?') + queryString } } - + const fullPath = `/api${url.startsWith('/') ? url : '/' + url}` const signatureHeaders = generateSignatureHeaders(method, fullPath, body) - + config.headers = config.headers || {} Object.assign(config.headers, signatureHeaders) - + if (!checkApiPermission(method, url)) { const error = new Error('无权限访问此接口') ;(error as any).response = { @@ -46,15 +47,66 @@ request.interceptors.request.use( } 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 }, (error) => Promise.reject(error) ) request.interceptors.response.use( - (response) => response.data, - (error) => { + async (response: AxiosResponse) => { + // 解密加密的响应体 + 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) { localStorage.removeItem('token') if (!window.location.pathname.includes('/login')) { diff --git a/gym-manage-web/src/vite-env.d.ts b/gym-manage-web/src/vite-env.d.ts index 9a6804d..0119d0d 100644 --- a/gym-manage-web/src/vite-env.d.ts +++ b/gym-manage-web/src/vite-env.d.ts @@ -2,6 +2,7 @@ interface ImportMetaEnv { readonly VITE_SIGNATURE_SECRET: string + readonly VITE_ENCRYPTION_SECRET: string readonly VITE_API_BASE_URL?: string }