feat(encryption): 实现前后端 AES-256-GCM 加密通信 #55

Merged
zhangxiang merged 1 commits from feat/api-encryption into dev 2026-08-02 08:52:10 +08:00
16 changed files with 1178 additions and 10 deletions
@@ -0,0 +1,126 @@
package cn.novalon.gym.manage.common.util;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.PBEKeySpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.security.spec.KeySpec;
import java.util.Base64;
/**
* AES-256-GCM 加解密服务
* <p>
* 用于前后端通信的应用层加密 —— 在 HTTPS 基础上额外加密 JSON 载荷。
* 格式: Base64( 12-byte-IV || AES-GCM-ciphertext )
* 密钥通过 PBKDF2-HMAC-SHA256 从密码短语派生,与前端 crypto.ts 保持一致。
* </p>
*
* @author 张翔
* @date 2026-08-02
*/
public class CryptoService {
private static final Logger log = LoggerFactory.getLogger(CryptoService.class);
private static final int GCM_IV_LENGTH = 12;
private static final int GCM_TAG_LENGTH = 128;
private static final String ALGORITHM = "AES/GCM/NoPadding";
private static final String KEY_DERIVATION_ALGORITHM = "PBKDF2WithHmacSHA256";
private static final int KEY_LENGTH_BITS = 256;
private static final int PBKDF2_ITERATIONS = 100_000;
private static final byte[] PBKDF2_SALT =
"novalon-gym-manage-aes-salt-v1".getBytes(StandardCharsets.UTF_8);
private final SecretKey secretKey;
/**
* @param passphrase 用于派生 AES-256 密钥的密码短语,长度不少于 12 个字符
*/
public CryptoService(String passphrase) {
if (passphrase == null || passphrase.isBlank()) {
throw new IllegalArgumentException("encryption passphrase must not be empty");
}
if (passphrase.length() < 12) {
throw new IllegalArgumentException(
"encryption passphrase must be at least 12 characters");
}
try {
SecretKeyFactory factory = SecretKeyFactory.getInstance(KEY_DERIVATION_ALGORITHM);
KeySpec spec = new PBEKeySpec(
passphrase.toCharArray(), PBKDF2_SALT, PBKDF2_ITERATIONS, KEY_LENGTH_BITS);
byte[] keyBytes = factory.generateSecret(spec).getEncoded();
this.secretKey = new SecretKeySpec(keyBytes, "AES");
log.info("CryptoService initialized (AES-256-GCM, PBKDF2)");
} catch (Exception e) {
throw new RuntimeException("Failed to initialize CryptoService", e);
}
}
/**
* 加密字节数组并返回 Base64 编码的密文(IV 被前置)
*/
public String encrypt(byte[] plaintext) {
try {
Cipher cipher = Cipher.getInstance(ALGORITHM);
byte[] iv = new byte[GCM_IV_LENGTH];
SecureRandom.getInstanceStrong().nextBytes(iv);
GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
cipher.init(Cipher.ENCRYPT_MODE, secretKey, spec);
byte[] ciphertext = cipher.doFinal(plaintext);
byte[] combined = ByteBuffer.allocate(GCM_IV_LENGTH + ciphertext.length)
.put(iv)
.put(ciphertext)
.array();
return Base64.getEncoder().encodeToString(combined);
} catch (Exception e) {
throw new RuntimeException("Encryption failed", e);
}
}
/**
* 解密 Base64 编码的密文(IV 被前置)
*/
public byte[] decrypt(String encryptedBase64) {
try {
byte[] combined = Base64.getDecoder().decode(encryptedBase64);
ByteBuffer buffer = ByteBuffer.wrap(combined);
byte[] iv = new byte[GCM_IV_LENGTH];
buffer.get(iv);
byte[] ciphertext = new byte[buffer.remaining()];
buffer.get(ciphertext);
Cipher cipher = Cipher.getInstance(ALGORITHM);
GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
cipher.init(Cipher.DECRYPT_MODE, secretKey, spec);
return cipher.doFinal(ciphertext);
} catch (Exception e) {
throw new RuntimeException("Decryption failed", e);
}
}
/**
* 加密字符串
*/
public String encryptString(String plaintext) {
return encrypt(plaintext.getBytes(StandardCharsets.UTF_8));
}
/**
* 解密字符串
*/
public String decryptToString(String encryptedBase64) {
return new String(decrypt(encryptedBase64), StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,191 @@
package cn.novalon.gym.manage.common.util;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@DisplayName("CryptoService 单元测试")
class CryptoServiceTest {
private static final String TEST_PASSPHRASE = "TestEncryptionKey2026!";
private CryptoService cryptoService;
@BeforeEach
void setUp() {
cryptoService = new CryptoService(TEST_PASSPHRASE);
}
@Nested
@DisplayName("构造方法测试")
class ConstructorTest {
@Test
@DisplayName("密码短语为空时应抛出异常")
void shouldThrowWhenPassphraseIsNull() {
assertThatThrownBy(() -> new CryptoService(null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("must not be empty");
}
@Test
@DisplayName("密码短语为空字符串时应抛出异常")
void shouldThrowWhenPassphraseIsBlank() {
assertThatThrownBy(() -> new CryptoService(" "))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("must not be empty");
}
@Test
@DisplayName("密码短语少于12个字符时应抛出异常")
void shouldThrowWhenPassphraseTooShort() {
assertThatThrownBy(() -> new CryptoService("short"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("at least 12 characters");
}
}
@Nested
@DisplayName("加解密测试")
class EncryptDecryptTest {
@Test
@DisplayName("应对字符串进行加密并成功解密")
void shouldEncryptAndDecryptString() {
String plaintext = "Hello, CryptoService!";
String encrypted = cryptoService.encryptString(plaintext);
String decrypted = cryptoService.decryptToString(encrypted);
assertThat(encrypted).isNotEqualTo(plaintext);
assertThat(decrypted).isEqualTo(plaintext);
}
@Test
@DisplayName("应对JSON字符串进行加密并成功解密")
void shouldEncryptAndDecryptJson() {
String json = "{\"username\":\"admin\",\"password\":\"secret123\"}";
String encrypted = cryptoService.encryptString(json);
String decrypted = cryptoService.decryptToString(encrypted);
assertThat(encrypted).isNotEqualTo(json);
assertThat(decrypted).isEqualTo(json);
}
@Test
@DisplayName("应对空字符串进行加密并成功解密")
void shouldEncryptAndDecryptEmptyString() {
String plaintext = "";
String encrypted = cryptoService.encryptString(plaintext);
String decrypted = cryptoService.decryptToString(encrypted);
assertThat(encrypted).isNotBlank();
assertThat(decrypted).isEqualTo(plaintext);
}
@Test
@DisplayName("加密结果应为Base64编码")
void shouldProduceBase64Output() {
String encrypted = cryptoService.encryptString("test");
// Base64 pattern: alphanumeric, +, /, =
assertThat(encrypted).matches("^[A-Za-z0-9+/=]+$");
}
@Test
@DisplayName("每次加密应产生不同结果(不同IV")
void shouldProduceDifferentCiphertextEachTime() {
String plaintext = "same text";
String encrypted1 = cryptoService.encryptString(plaintext);
String encrypted2 = cryptoService.encryptString(plaintext);
assertThat(encrypted1).isNotEqualTo(encrypted2);
}
@Test
@DisplayName("应处理中文字符")
void shouldHandleChineseCharacters() {
String chinese = "你好,世界!加密测试";
String encrypted = cryptoService.encryptString(chinese);
String decrypted = cryptoService.decryptToString(encrypted);
assertThat(decrypted).isEqualTo(chinese);
}
@Test
@DisplayName("应处理长文本")
void shouldHandleLongText() {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 1000; i++) {
sb.append("Long text content for testing. ");
}
String longText = sb.toString();
String encrypted = cryptoService.encryptString(longText);
String decrypted = cryptoService.decryptToString(encrypted);
assertThat(decrypted).isEqualTo(longText);
}
@Test
@DisplayName("加密字节数组与解密后应一致")
void shouldEncryptAndDecryptByteArray() {
byte[] plaintext = "byte array test".getBytes(StandardCharsets.UTF_8);
String encrypted = cryptoService.encrypt(plaintext);
byte[] decrypted = cryptoService.decrypt(encrypted);
assertThat(decrypted).isEqualTo(plaintext);
}
}
@Nested
@DisplayName("异常处理测试")
class ExceptionHandlingTest {
@Test
@DisplayName("解密无效的Base64时应抛出异常")
void shouldThrowOnInvalidBase64() {
assertThatThrownBy(() -> cryptoService.decrypt("invalid-base64!!!"))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("Decryption failed");
}
@Test
@DisplayName("解密非法的密文时应抛出异常")
void shouldThrowOnInvalidCiphertext() {
// 合法的Base64,但不是有效的AES-GCM密文
String fakeCiphertext = "dGhpcyBpcyBpbnZhbGlkIGNpcGhlcnRleHQ=";
assertThatThrownBy(() -> cryptoService.decrypt(fakeCiphertext))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("Decryption failed");
}
}
@Nested
@DisplayName("不同密钥实例兼容性测试")
class KeyCompatibilityTest {
@Test
@DisplayName("相同密码短语生成的实例应能互相解密")
void shouldBeInteroperableWithSamePassphrase() {
CryptoService another = new CryptoService(TEST_PASSPHRASE);
String plaintext = "interoperability test";
String encrypted = cryptoService.encryptString(plaintext);
String decrypted = another.decryptToString(encrypted);
assertThat(decrypted).isEqualTo(plaintext);
}
@Test
@DisplayName("不同密码短语生成的实例不应能互相解密")
void shouldNotBeInteroperableWithDifferentPassphrase() {
CryptoService another = new CryptoService("DifferentPassphraseKey!@#");
String encrypted = cryptoService.encryptString("secret data");
assertThatThrownBy(() -> another.decrypt(encrypted))
.isInstanceOf(RuntimeException.class);
}
}
}
+5
View File
@@ -17,6 +17,11 @@
<description>Gateway module for Novalon Manage API</description> <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>
@@ -0,0 +1,21 @@
package cn.novalon.gym.manage.gateway.config;
import cn.novalon.gym.manage.common.util.CryptoService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* CryptoService Bean 配置 —— 使用 {@code app.encryption.secret} 初始化 AES-256-GCM 加解密引擎。
*
* @author 张翔
* @date 2026-08-02
*/
@Configuration
public class CryptoConfig {
@Bean
public CryptoService cryptoService(@Value("${app.encryption.secret}") String secret) {
return new CryptoService(secret);
}
}
@@ -0,0 +1,181 @@
package cn.novalon.gym.manage.gateway.filter;
import cn.novalon.gym.manage.common.util.CryptoService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpRequestDecorator;
import org.springframework.http.server.reactive.ServerHttpResponseDecorator;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.nio.charset.StandardCharsets;
/**
* 应用层加密/解密全局过滤器。
* <p>
* 当请求携带 {@code X-Encrypted: true} 头时:
* <ul>
* <li>请求体:在 filter 层主动解密为 JSON 明文,再交给下游 handler</li>
* <li>响应体:将 handler 返回的 JSON 明文加密为 Base64(AES-GCM IV‖密文),并标记 {@code X-Encrypted: true}</li>
* </ul>
* 不携带该头的请求直接透传(兼容 curl/Postman 等调试工具)。
* </p>
* <p>
* 安全约束:携带 {@code X-Encrypted: true} 但解密失败的请求必须返回 400 Bad Request
* 禁止静默降级为明文传输,防止中间人/客户端误用导致的数据暴露。
* </p>
* <p>
* 优先级:在 {@link SignatureFilter} 之后执行,确保已通过签名验证的请求再进行加解密处理。
* </p>
*
* @author 张翔
* @date 2026-08-02
*/
@Component
public class CryptoFilter implements GlobalFilter, Ordered {
private static final Logger log = LoggerFactory.getLogger(CryptoFilter.class);
private static final String ENCRYPTED_HEADER = "X-Encrypted";
private final CryptoService cryptoService;
public CryptoFilter(CryptoService cryptoService) {
this.cryptoService = cryptoService;
}
@Override
public int getOrder() {
// 在 SignatureFilter (HIGHEST_PRECEDENCE + 150) 之后执行
return Ordered.HIGHEST_PRECEDENCE + 200;
}
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String path = exchange.getRequest().getURI().getPath();
// 健康检查 & 非 API 路径透传
if (path.startsWith("/actuator/") || path.equals("/favicon.ico")) {
return chain.filter(exchange);
}
boolean isEncrypted = "true".equalsIgnoreCase(
exchange.getRequest().getHeaders().getFirst(ENCRYPTED_HEADER));
if (!isEncrypted) {
return chain.filter(exchange);
}
// 构建加密响应装饰器
ServerHttpResponseDecorator encryptedResponse = buildEncryptedResponse(exchange);
// 读取并解密请求体
return DataBufferUtils.join(exchange.getRequest().getBody())
.switchIfEmpty(Mono.defer(() -> {
// 无请求体(如 GET/DELETE)仍需加密响应
return chain.filter(exchange.mutate().response(encryptedResponse).build())
.then(Mono.empty());
}))
.flatMap(buffer -> {
byte[] encryptedBytes = new byte[buffer.readableByteCount()];
buffer.read(encryptedBytes);
DataBufferUtils.release(buffer);
String bodyStr = new String(encryptedBytes, StandardCharsets.UTF_8);
if (bodyStr.isEmpty()) {
// 空 body 透传,但响应仍需加密
return chain.filter(exchange.mutate().response(encryptedResponse).build());
}
byte[] decryptedBytes;
try {
decryptedBytes = cryptoService.decrypt(bodyStr);
} catch (Exception e) {
log.warn("Encrypted request rejected for {}: {}", path, e.getMessage());
exchange.getResponse().setStatusCode(HttpStatus.BAD_REQUEST);
return exchange.getResponse().setComplete();
}
ServerHttpRequest decryptedRequest = buildDecryptedRequest(exchange, decryptedBytes);
return chain.filter(
exchange.mutate()
.request(decryptedRequest)
.response(encryptedResponse)
.build()
);
});
}
// ========== 请求体解密 ==========
private ServerHttpRequest buildDecryptedRequest(ServerWebExchange exchange, byte[] decryptedBytes) {
return new ServerHttpRequestDecorator(exchange.getRequest()) {
@Override
public HttpHeaders getHeaders() {
HttpHeaders headers = new HttpHeaders();
headers.putAll(super.getHeaders());
headers.set(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
// 移除加密标记,避免下游服务重复解密
headers.remove(ENCRYPTED_HEADER);
return HttpHeaders.readOnlyHttpHeaders(headers);
}
@Override
public Flux<DataBuffer> getBody() {
return Flux.just(exchange.getResponse().bufferFactory().wrap(decryptedBytes));
}
};
}
// ========== 响应体加密 ==========
private ServerHttpResponseDecorator buildEncryptedResponse(ServerWebExchange exchange) {
return new ServerHttpResponseDecorator(exchange.getResponse()) {
@Override
public Mono<Void> writeWith(org.reactivestreams.Publisher<? extends DataBuffer> body) {
return DataBufferUtils.join(body)
.flatMap(buffer -> {
try {
byte[] plainBytes = new byte[buffer.readableByteCount()];
buffer.read(plainBytes);
DataBufferUtils.release(buffer);
String encrypted = cryptoService.encrypt(plainBytes);
byte[] encryptedBytes = encrypted.getBytes(StandardCharsets.UTF_8);
getDelegate().getHeaders().set(ENCRYPTED_HEADER, "true");
getDelegate().getHeaders().set(HttpHeaders.CONTENT_LENGTH,
String.valueOf(encryptedBytes.length));
return getDelegate()
.writeWith(Mono.just(
getDelegate().bufferFactory().wrap(encryptedBytes)));
} catch (Exception e) {
log.error("Response body encryption failed for {}: {}",
exchange.getRequest().getURI().getPath(), e.getMessage());
return Mono.error(e);
}
});
}
@Override
public Mono<Void> writeAndFlushWith(
org.reactivestreams.Publisher<? extends org.reactivestreams.Publisher<? extends DataBuffer>> body) {
return writeWith(Flux.from(body).flatMapSequential(p -> p));
}
};
}
}
@@ -32,6 +32,10 @@ jwt:
secret: U2FsdGVkX1+vZ5Y9QmKxL8nN3rP7tW2jH4fG6dA8sB1cE5yN0zX3qV7wM4 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}
@@ -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);
}
}
}
+3
View File
@@ -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
+3
View File
@@ -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()
})
})
})
+110
View File
@@ -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 }
+62 -10
View File
@@ -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
// 不覆盖 transformRequestAxios 将正常 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')) {
+1
View File
@@ -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
} }