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