diff --git a/README.md b/README.md index e5568cb..53599cd 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,5 @@ # Spring Safe HTTP - -Spring Boot 웹 서버 애플리케이션의 HTTP 바디 데이터를 암호화하여 응답하고 암호화 된 데이터를 복호화해서 받을 수 있는 라이브러리 입니다. +Spring Boot 웹 서버 애플리케이션의 HTTP 바디 데이터를 암호화하여 응답하고 암호화 된 데이터를 복호화해서 받을 수 있는 라이브러리 입니다. ## How to @@ -14,7 +13,7 @@ Spring Boot 웹 서버 애플리케이션의 HTTP 바디 데이터를 암호화 2. Add the dependency ``` dependencies { - implementation 'com.github.youabledev:spring-safe-http:Tag' + implementation 'com.github.youabledev:spring-safe-http:v0.2.0' } ``` ### maven @@ -36,5 +35,30 @@ Spring Boot 웹 서버 애플리케이션의 HTTP 바디 데이터를 암호화 ``` +## Example +```java + @EncryptResponse + @DecryptRequest + @PostMapping("/regist") + public ResponseEntity regist( + @RequestBody BookRequest request + ) { + return ResponseEntity.ok().body(request.toString()); + } +``` +- request body를 복호화 하고자 할 때 @DecryptRequest를 사용합니다. +- response body를 암호화 하고자 할 때 @EncryptResponse를 사용합니다. +``` +// request body +/PJ/p778x9sf8G0YXUUxGwn5NRhrPu4gWqwxKxDdQMxwq+wxJAxOucVvZSmzEnjNu/Z41THSQKzaQn8IVEZxfg== +``` +- 암호화된 request body는 복호화되어 Controller의 parameter 객체로 바인딩 됩니다. +``` +// response body +MTBIpe9JkN7EI9tFA6Fi8RuT1+mRUsHGn75VJLuJDfyQWib05UV7dHFOyiFUQrav +``` +- Controller에서 return되는 ResponseEntity의 response body는 암호화 됩니다. + + ## LICENSE spring-safe-http 라이브러리의 LICENSE는 [LICENSE](https://github.com/youabledev/spring-safe-http/blob/main/LICENSE)를 확인하세요 diff --git a/build.gradle b/build.gradle index 94fc272..f0e19bf 100644 --- a/build.gradle +++ b/build.gradle @@ -10,7 +10,7 @@ java { } group = 'com.youable' -version = '0.1.4' +version = '0.2.0' repositories { mavenCentral() @@ -28,6 +28,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-aop' implementation 'org.aspectj:aspectjweaver:1.9.22.1' testImplementation 'org.junit.jupiter:junit-jupiter:5.11.3' + testImplementation 'org.mockito:mockito-core:4.8.0' } publishing { @@ -42,4 +43,8 @@ publishing { url = uri("https://jitpack.io") } } +} + +tasks.test { + useJUnitPlatform() // Ensure JUnit 5 is used for tests } \ No newline at end of file diff --git a/src/main/java/com/youable/safehttp/cipher/AESCipher.java b/src/main/java/com/youable/safehttp/cipher/AESCipher.java new file mode 100644 index 0000000..54ff1e5 --- /dev/null +++ b/src/main/java/com/youable/safehttp/cipher/AESCipher.java @@ -0,0 +1,50 @@ +package com.youable.safehttp.cipher; + +import javax.crypto.Cipher; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.util.Base64; + +public class AESCipher implements HttpCipher { + private final String privateKey; + private final String ALGORITHM = "AES"; + private final int BEGIN_INDEX = 0; + private final int END_INDEX = 16; + private final String TRANSFORMATION = "AES/CBC/PKCS5Padding"; + + @Override + public String encrypt(String data) throws Exception { + SecretKeySpec secretKey = new SecretKeySpec(Base64.getDecoder().decode(privateKey), ALGORITHM); + IvParameterSpec IV = new IvParameterSpec(privateKey.substring(BEGIN_INDEX, END_INDEX).getBytes()); + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + cipher.init(Cipher.ENCRYPT_MODE, secretKey, IV); + byte[] encryptByte = cipher.doFinal(data.getBytes("UTF-8")); + return Base64.getEncoder().encodeToString(encryptByte); + } + + @Override + public String decrypt(String data) throws Exception { + SecretKeySpec secretKey = new SecretKeySpec(Base64.getDecoder().decode(privateKey), ALGORITHM); + IvParameterSpec IV = new IvParameterSpec(privateKey.substring(BEGIN_INDEX, END_INDEX).getBytes()); + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + cipher.init(Cipher.DECRYPT_MODE, secretKey, IV); + byte[] decodeByte = cipher.doFinal(Base64.getDecoder().decode(data)); + return new String(decodeByte); + } + + private AESCipher(AESCipherBuilder builder) { + this.privateKey = builder.privateKey; + } + + public static class AESCipherBuilder { + private String privateKey; + + public AESCipherBuilder(String PRIVATE_KEY) { + this.privateKey = PRIVATE_KEY; + } + + public AESCipher build() { + return new AESCipher(this); + } + } +} diff --git a/src/main/java/com/youable/safehttp/cipher/HttpCipher.java b/src/main/java/com/youable/safehttp/cipher/HttpCipher.java index cb5a94e..28efaf8 100644 --- a/src/main/java/com/youable/safehttp/cipher/HttpCipher.java +++ b/src/main/java/com/youable/safehttp/cipher/HttpCipher.java @@ -3,6 +3,16 @@ /** *

use the httpCipher interface to implement the encryption/decryption algorithm. * The object you implement must be registered as a bean. + *

{@code
+ * @Configuration
+ * public class CipherConfig {
+ *     @Bean
+ *     @Primary
+ *     public HttpCipher aesCipher() {
+ *         return new AESCipher.AESCipherBuilder("3mlng8uGI+k2CGVDKR/EAwI+uN5pzoITSkREjzxZc7M=").build();
+ *     }
+ * }
+ *

Create Configuration to enroll HttpCipher

* @author youabledev * @since 0.0.1 * @see com.youable.safehttp.advice.DecryptRequestBodyAdvice diff --git a/src/main/java/com/youable/safehttp/cipher/RSACipher.java b/src/main/java/com/youable/safehttp/cipher/RSACipher.java new file mode 100644 index 0000000..4eaed27 --- /dev/null +++ b/src/main/java/com/youable/safehttp/cipher/RSACipher.java @@ -0,0 +1,46 @@ +package com.youable.safehttp.cipher; + +import javax.crypto.Cipher; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.util.Base64; + +public class RSACipher implements HttpCipher { + private PublicKey publicKey; + private PrivateKey privateKey; + + @Override + public String encrypt(String data) throws Exception { + Cipher cipher = Cipher.getInstance("RSA"); + cipher.init(Cipher.ENCRYPT_MODE, publicKey); + byte[] encryptedBytes = cipher.doFinal(data.getBytes()); + return Base64.getEncoder().encodeToString(encryptedBytes); + } + + @Override + public String decrypt(String data) throws Exception { + Cipher cipher = Cipher.getInstance("RSA"); + cipher.init(Cipher.DECRYPT_MODE, privateKey); + byte[] decryptedBytes = cipher.doFinal(Base64.getDecoder().decode(data)); + return new String(decryptedBytes); + } + + private RSACipher(RSACipherBuilder builder) { + this.publicKey = builder.publicKey; + this.privateKey = builder.privateKey; + } + + public static class RSACipherBuilder { + private PublicKey publicKey; + private PrivateKey privateKey; + + public RSACipherBuilder(PublicKey publicKey, PrivateKey privateKey) { + this.publicKey = publicKey; + this.privateKey = privateKey; + } + + public RSACipher build() { + return new RSACipher(this); + } + } +} \ No newline at end of file diff --git a/src/test/java/com/youable/safehttp/advice/DecryptRequestBodyAdviceTest.java b/src/test/java/com/youable/safehttp/advice/DecryptRequestBodyAdviceTest.java new file mode 100644 index 0000000..f0caa0c --- /dev/null +++ b/src/test/java/com/youable/safehttp/advice/DecryptRequestBodyAdviceTest.java @@ -0,0 +1,79 @@ +package com.youable.safehttp.advice; + +import com.youable.safehttp.annotation.DecryptRequest; +import com.youable.safehttp.cipher.HttpCipher; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.core.MethodParameter; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpInputMessage; + +import java.io.ByteArrayInputStream; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class DecryptRequestBodyAdviceTest { + @DisplayName("DecryptRequestBodyAdvice should support methods annotated with @DecryptRequest") + @Test + void testDecryptRequestAnnotation() { + // given + HttpCipher mockHttpCiper = mock(HttpCipher.class); + DecryptRequestBodyAdvice advice = new DecryptRequestBodyAdvice(mockHttpCiper); + MethodParameter methodParameter = mock(MethodParameter.class); + + // when + when(methodParameter.hasMethodAnnotation(DecryptRequest.class)).thenReturn(true); + boolean supports = advice.supports(methodParameter, String.class, null); + + // then + assert(supports); + } + + @DisplayName("Decrypted request body should match expected JSON") + @Test + void testBeforeBodyRead() throws Exception { + // given + String encryptedBody = "SXuMtzsL8X05TCRuyOHEGA=="; + String decryptedBody = "{\"key\":\"value\"}"; + HttpCipher mockHttpCipher = mock(HttpCipher.class); + when(mockHttpCipher.decrypt(encryptedBody)).thenReturn(decryptedBody); + + DecryptRequestBodyAdvice advice = new DecryptRequestBodyAdvice(mockHttpCipher); + + HttpHeaders headers = new HttpHeaders(); + DecryptedHttpInputMessage inputMessage = new DecryptedHttpInputMessage(encryptedBody, headers); + + MethodParameter parameter = mock(MethodParameter.class); + + // when + HttpInputMessage result = advice.beforeBodyRead(inputMessage, parameter, String.class, null); + + // then + assertNotNull(result); + assertEquals(decryptedBody, new String(result.getBody().readAllBytes())); + } + + @DisplayName("Decryption failure should throw RuntimeException") + @Test + void testDecryptFailure() throws Exception { + // given + String encryptedBody = "SXuMtzsL8X05TCRuyOHEGA=="; + HttpCipher mockHttpCipher = mock(HttpCipher.class); + when(mockHttpCipher.decrypt(encryptedBody)).thenThrow(new RuntimeException("Decryption failed")); + + DecryptRequestBodyAdvice advice = new DecryptRequestBodyAdvice(mockHttpCipher); + + HttpHeaders headers = new HttpHeaders(); + DecryptedHttpInputMessage inputMessage = new DecryptedHttpInputMessage(encryptedBody, headers); + + MethodParameter parameter = mock(MethodParameter.class); + + // when & then + RuntimeException exception = assertThrows(RuntimeException.class, () -> { + advice.beforeBodyRead(inputMessage, parameter, String.class, null); + }); + + assertEquals("Decryption failed", exception.getCause().getMessage()); + } +} \ No newline at end of file diff --git a/src/test/java/com/youable/safehttp/aop/EncryptResponseAspectTest.java b/src/test/java/com/youable/safehttp/aop/EncryptResponseAspectTest.java new file mode 100644 index 0000000..cf44e91 --- /dev/null +++ b/src/test/java/com/youable/safehttp/aop/EncryptResponseAspectTest.java @@ -0,0 +1,53 @@ +package com.youable.safehttp.aop; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.youable.safehttp.cipher.HttpCipher; +import org.aspectj.lang.ProceedingJoinPoint; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class EncryptResponseAspectTest { + @DisplayName("EncryptResponseAspect should match expected Response Body") + @Test + void testEncryptResponse() throws Throwable { + // given + ObjectMapper objectMapper = new ObjectMapper(); + HttpCipher mockHttpCipher = mock(HttpCipher.class); + EncryptResponseAspect aspect = new EncryptResponseAspect(mockHttpCipher, objectMapper); + + ProceedingJoinPoint mockJoinPoint = mock(ProceedingJoinPoint.class); + + Person response = new Person("hongkildong", 12); + String responseJson = objectMapper.writeValueAsString(response); + String encryptedData = "o3Y8Yiuffa+LNkTftpgSGmBasze4poDDbuQ3GVIwmiY="; + HttpHeaders headers = new HttpHeaders(); + headers.add("Content-Type", "application/json"); + ResponseEntity mockResponseEntity = + new ResponseEntity<>(response, headers, HttpStatus.OK); + + // when + when(mockJoinPoint.proceed()).thenReturn(mockResponseEntity); + when(mockHttpCipher.encrypt(responseJson)).thenReturn(encryptedData); + + // Act + Object result = aspect.encryptResponse(mockJoinPoint, null); + + // then + assertTrue(result instanceof ResponseEntity); + ResponseEntity responseEntity = (ResponseEntity) result; + assertEquals(encryptedData, responseEntity.getBody()); + assertEquals(HttpStatus.OK, responseEntity.getStatusCode()); + assertEquals(headers, responseEntity.getHeaders()); + + verify(mockJoinPoint, times(1)).proceed(); + verify(mockHttpCipher, times(1)).encrypt(responseJson); + } + + private record Person(String name, Integer age) {} +} \ No newline at end of file diff --git a/src/test/java/com/youable/safehttp/cipher/AESCipherTest.java b/src/test/java/com/youable/safehttp/cipher/AESCipherTest.java new file mode 100644 index 0000000..9f455bb --- /dev/null +++ b/src/test/java/com/youable/safehttp/cipher/AESCipherTest.java @@ -0,0 +1,34 @@ +package com.youable.safehttp.cipher; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.security.SecureRandom; +import java.util.Base64; + +import static org.junit.jupiter.api.Assertions.*; + +public class AESCipherTest { + @DisplayName("AES encrypt/decrypt") + @Test + void testEncryptAndDecrypt() throws Exception { + // given + byte[] key = new byte[32]; + new SecureRandom().nextBytes(key); + String privateKey = Base64.getEncoder().encodeToString(key); + + HttpCipher cipher = new AESCipher.AESCipherBuilder(privateKey).build(); + + String plainText = "{\"key\":\"value\"}"; + + // when + String encryptedText = cipher.encrypt(plainText); + String decryptedText = cipher.decrypt(encryptedText); + + // then + assertNotNull(encryptedText); + assertNotEquals(plainText, encryptedText); + assertEquals(plainText, decryptedText); + } + +} \ No newline at end of file diff --git a/src/test/java/com/youable/safehttp/cipher/RSACipherTest.java b/src/test/java/com/youable/safehttp/cipher/RSACipherTest.java new file mode 100644 index 0000000..ab5c52f --- /dev/null +++ b/src/test/java/com/youable/safehttp/cipher/RSACipherTest.java @@ -0,0 +1,33 @@ +package com.youable.safehttp.cipher; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.SecureRandom; + +import static org.junit.jupiter.api.Assertions.*; + +class RSACipherTest { + @DisplayName("RSA encrypt/decrypt") + @Test + void test() throws Exception { + // given + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048, new SecureRandom()); + KeyPair keyPair = keyPairGenerator.generateKeyPair(); + + HttpCipher cipher = new RSACipher.RSACipherBuilder(keyPair.getPublic(), keyPair.getPrivate()).build(); + String plainText = "{\"key\":\"value\"}"; + + // when + String encryptedText = cipher.encrypt(plainText); + String decryptedText = cipher.decrypt(encryptedText); + + // then + assertNotNull(encryptedText); + assertNotEquals(plainText, encryptedText); + assertEquals(plainText, decryptedText); + } +} \ No newline at end of file