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

参考同级项目 novavis-authority 实现,在 HTTPS 基础上增加应用层加密。

后端:
- CryptoService: AES-256-GCM + PBKDF2 密钥派生(manage-common)
- CryptoFilter: Gateway GlobalFilter,检测 X-Encrypted 头后加解密请求/响应体
- 配置:app.encryption.secret 通过环境变量注入

前端:
- crypto.ts: Web Crypto API 实现 AES-256-GCM + PBKDF2
- request.ts: 拦截器自动加密请求体、解密响应体
- 环境变量 VITE_ENCRYPTION_SECRET

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