diff --git a/src/main/java/shop/sendbox/sendbox/api/ApiResponse.java b/src/main/java/shop/sendbox/sendbox/api/ApiResponse.java index a2c14e2..423bbb8 100644 --- a/src/main/java/shop/sendbox/sendbox/api/ApiResponse.java +++ b/src/main/java/shop/sendbox/sendbox/api/ApiResponse.java @@ -4,7 +4,6 @@ import lombok.Getter; -// Getter 으로 Jackson 라이브러리가 JSON 형태로 변환할 수 있도록 한다 @Getter public class ApiResponse { diff --git a/src/main/java/shop/sendbox/sendbox/buyer/entity/Buyer.java b/src/main/java/shop/sendbox/sendbox/buyer/entity/Buyer.java index 4a5a011..5d55efc 100644 --- a/src/main/java/shop/sendbox/sendbox/buyer/entity/Buyer.java +++ b/src/main/java/shop/sendbox/sendbox/buyer/entity/Buyer.java @@ -2,7 +2,7 @@ import static shop.sendbox.sendbox.buyer.entity.BuyerStatus.*; import static shop.sendbox.sendbox.buyer.entity.DeleteStatus.*; -import static shop.sendbox.sendbox.util.EncryptUtil.*; +import static shop.sendbox.sendbox.util.HashingEncrypt.*; import java.time.LocalDateTime; @@ -15,7 +15,14 @@ import lombok.Getter; import lombok.NoArgsConstructor; -// @Entity 어노테이션은 JPA가 관리하는 엔티티 클래스임을 나타냅니다. +/* +@Entity 을 추가하면 JPA 스캐너에 의해 JPA 엔티티로 인식되며, +데이터베이스 테이블과 매핑합니다. + +@Getter는 클래스내 모든 필드의 get 메소드를 자동으로 만들어줍니다. + +@NoArgsConstructor는 기본 생성자를 만들어주며 접근 권한을 access 값으로 설정할 수 있습니다. + */ @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) diff --git a/src/main/java/shop/sendbox/sendbox/buyer/service/BuyerResponse.java b/src/main/java/shop/sendbox/sendbox/buyer/service/BuyerResponse.java index 3607181..bf17f77 100644 --- a/src/main/java/shop/sendbox/sendbox/buyer/service/BuyerResponse.java +++ b/src/main/java/shop/sendbox/sendbox/buyer/service/BuyerResponse.java @@ -2,8 +2,8 @@ import lombok.AccessLevel; import lombok.Builder; -import shop.sendbox.sendbox.buyer.entity.BuyerStatus; import shop.sendbox.sendbox.buyer.entity.Buyer; +import shop.sendbox.sendbox.buyer.entity.BuyerStatus; // 빌더는 빌더 객체로 사용할 수 있도록 해주는 애노테이션입니다. @Builder(access = AccessLevel.PRIVATE) diff --git a/src/main/java/shop/sendbox/sendbox/buyer/service/BuyerService.java b/src/main/java/shop/sendbox/sendbox/buyer/service/BuyerService.java index f1c30da..f9e932f 100644 --- a/src/main/java/shop/sendbox/sendbox/buyer/service/BuyerService.java +++ b/src/main/java/shop/sendbox/sendbox/buyer/service/BuyerService.java @@ -22,8 +22,11 @@ public class BuyerService implements LoginHandler { private final BuyerRepository buyerRepository; - /** - * Transactional을 명시적으로 추가하여 해당 메서드는 트랜잭션 범위에서 실행됨을 명시했습니다. + /* + @Transactional 애노테이션은 메서드가 실행될 때 트랜잭션을 시작합니다. + 메서드가 정상 종료되면 트랜잭션을 커밋합니다. + 만약 예외가 발생하면 롤백합니다. + 예외는 런타임 예외를 지정하거나 rollbackFor 속성을 사용하여 롤백할 예외를 지정할 수 있습니다. */ @Transactional public BuyerResponse signUp(final BuyerRequest buyerRequest, final LocalDateTime createdAt) { diff --git a/src/main/java/shop/sendbox/sendbox/util/AesEncrypt.java b/src/main/java/shop/sendbox/sendbox/util/AesEncrypt.java new file mode 100644 index 0000000..2d61b28 --- /dev/null +++ b/src/main/java/shop/sendbox/sendbox/util/AesEncrypt.java @@ -0,0 +1,93 @@ +package shop.sendbox.sendbox.util; + +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.KeySpec; +import java.util.Base64; + +import javax.crypto.Cipher; +import javax.crypto.SecretKey; +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.PBEKeySpec; +import javax.crypto.spec.SecretKeySpec; + +import lombok.extern.slf4j.Slf4j; + +/* +애노테이션 프로세서는 해당 애노테이션을 확인하면 컴파일 시점에 Logger 클래스를 생성합니다. +따라서 별도의 Logger 객체를 생성할 필요 없이 log 객체를 사용할 수 있습니다. + */ +@Slf4j +public class AesEncrypt { + + private static final String ALGORITHM = "AES/CBC/PKCS5Padding"; + /** + * TODO + * 패스워드와 salt를 어떻게 보관할 것인지 고민입니다. + */ + private static final String DELIMITER = ":"; + private static final String PBKDF2_WITH_HMAC_SHA256 = "PBKDF2WithHmacSHA256"; + private static final Base64.Encoder ENCODER = Base64.getEncoder(); + private static final Base64.Decoder DECODER = Base64.getDecoder(); + public static final int IV_LENGTH = 16; + + private static SecretKey getKeyFromPassword(final String password, final String salt) { + KeySpec keySpec = new PBEKeySpec(password.toCharArray(), salt.getBytes(), 65536, 256); + try { + return new SecretKeySpec(generateSecret(keySpec), "AES"); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private static byte[] generateSecret(final KeySpec spec) throws InvalidKeySpecException, NoSuchAlgorithmException { + SecretKeyFactory factory = SecretKeyFactory.getInstance(PBKDF2_WITH_HMAC_SHA256); + return factory.generateSecret(spec).getEncoded(); + } + + private static IvParameterSpec generateIv() { + byte[] iv = new byte[IV_LENGTH]; + new SecureRandom().nextBytes(iv); + return new IvParameterSpec(iv); + } + + public static String encrypt(final String password, final String salt, String input) { + SecretKey key = getKeyFromPassword(password, salt); + final IvParameterSpec iv = generateIv(); + try { + Cipher cipher = Cipher.getInstance(ALGORITHM); + cipher.init(Cipher.ENCRYPT_MODE, key, iv); + byte[] cipherText = cipher.doFinal(input.getBytes()); + return ENCODER.encodeToString(cipherText) + DELIMITER + ENCODER.encodeToString(iv.getIV()); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + public static String decrypt(final String password, final String salt, String cipherText) { + if (cipherText == null) { + throw new IllegalArgumentException("암호화된 문자열에 값이 없습니다."); + } + final String[] encryptAndIv = cipherText.split(DELIMITER); + final SecretKey key = getKeyFromPassword(password, salt); + if (cipherText.isEmpty() || encryptAndIv.length != 2) { + throw new IllegalArgumentException("암호화된 문자열 양식이 올바르지 않습니다."); + } + final int encryptIndex = 0; + final int ivIndex = 1; + final byte[] encryptBytes = DECODER.decode(encryptAndIv[encryptIndex]); + final byte[] ivBytes = DECODER.decode(encryptAndIv[ivIndex]); + IvParameterSpec iv = new IvParameterSpec(ivBytes); + try { + Cipher cipher = Cipher.getInstance(ALGORITHM); + cipher.init(Cipher.DECRYPT_MODE, key, iv); + byte[] plainText = cipher.doFinal(encryptBytes); + return new String(plainText); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + +} diff --git a/src/main/java/shop/sendbox/sendbox/util/EncryptUtil.java b/src/main/java/shop/sendbox/sendbox/util/HashingEncrypt.java similarity index 87% rename from src/main/java/shop/sendbox/sendbox/util/EncryptUtil.java rename to src/main/java/shop/sendbox/sendbox/util/HashingEncrypt.java index 89a1f53..f83edfb 100644 --- a/src/main/java/shop/sendbox/sendbox/util/EncryptUtil.java +++ b/src/main/java/shop/sendbox/sendbox/util/HashingEncrypt.java @@ -6,8 +6,8 @@ import java.util.Arrays; import java.util.Base64; -public class EncryptUtil { - static final int saltLength = 16; +public class HashingEncrypt { + static final int SALT_LENGTH = 16; public static String encrypt(String input, String salt) { final MessageDigest digest; @@ -23,7 +23,7 @@ public static String encrypt(String input, String salt) { public static String generateSalt() { SecureRandom random = new SecureRandom(); - byte[] salt = new byte[saltLength]; + byte[] salt = new byte[SALT_LENGTH]; random.nextBytes(salt); return Base64.getEncoder().encodeToString(salt); } diff --git a/src/test/java/shop/sendbox/sendbox/buyer/ApiResponseTest.java b/src/test/java/shop/sendbox/sendbox/buyer/ApiResponseTest.java index abbc32d..0ffa9f7 100644 --- a/src/test/java/shop/sendbox/sendbox/buyer/ApiResponseTest.java +++ b/src/test/java/shop/sendbox/sendbox/buyer/ApiResponseTest.java @@ -1,5 +1,7 @@ package shop.sendbox.sendbox.buyer; +import static org.assertj.core.api.Assertions.*; + import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.http.HttpStatus; @@ -24,10 +26,10 @@ void create() { ApiResponse apiResponse = ApiResponse.of(statusCode, message, data); // then - // Assertions.assertThat(apiResponse.getStatusCode()).isEqualTo(statusCode.value()); - // Assertions.assertThat(apiResponse.getStatus()).isEqualTo(statusCode); - // Assertions.assertThat(apiResponse.getMessage()).isEqualTo(message); - // Assertions.assertThat(apiResponse.getData()).isEqualTo(data); + assertThat(apiResponse.getStatusCode()).isEqualTo(statusCode.value()); + assertThat(apiResponse.getStatus()).isEqualTo(statusCode); + assertThat(apiResponse.getMessage()).isEqualTo(message); + assertThat(apiResponse.getData()).isEqualTo(data); } } diff --git a/src/test/java/shop/sendbox/sendbox/buyer/BuyerTest.java b/src/test/java/shop/sendbox/sendbox/buyer/BuyerTest.java index 022a44c..202f7ba 100644 --- a/src/test/java/shop/sendbox/sendbox/buyer/BuyerTest.java +++ b/src/test/java/shop/sendbox/sendbox/buyer/BuyerTest.java @@ -11,7 +11,12 @@ import shop.sendbox.sendbox.buyer.entity.Buyer; class BuyerTest { - + /* + @Test 애노테이션이 붙은 메서드는 테스트 메서드로 인식됩니다. + Junit 프레임워크는 @Test 붙은 메서드를 찾아서 실행하며, + 코드의 특정 부분이 제대로 동작하는지 검증할 수 있습니다. + 해당 애노테이션이 붙은 메서드는 private이나 static이 아니여야합니다. + */ @Test @DisplayName("구매자가 회원가입을 하면 활성화 상태이다.") void create() { diff --git a/src/test/java/shop/sendbox/sendbox/login/LoginControllerTest.java b/src/test/java/shop/sendbox/sendbox/login/LoginControllerTest.java index 6830724..0fa8129 100644 --- a/src/test/java/shop/sendbox/sendbox/login/LoginControllerTest.java +++ b/src/test/java/shop/sendbox/sendbox/login/LoginControllerTest.java @@ -5,33 +5,40 @@ import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; -import org.assertj.core.api.Assertions; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.http.MediaType; import org.springframework.mock.web.MockHttpSession; import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.MockMvcBuilder; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; -import org.springframework.test.web.servlet.result.MockMvcResultHandlers; -import org.springframework.test.web.servlet.result.MockMvcResultMatchers; -import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +/* +MVC 테스트와 관련된 컴포넌트만 등록합니다. +@Controller,@ControllerAdvice,@JsonComponent,@Converter,@Filter,@WebMvcConfigure 빈이 포함되며 +@Component,@Service,@Repository 빈은 포함되지 않습니다. +컨트롤러 계층을 슬라이스 테스트 하고 싶을 때 사용합니다. + */ @WebMvcTest(controllers = {LoginController.class}) class LoginControllerTest { @Autowired ObjectMapper objectMapper; - + /* + @Autowired의 역할은 스프링 컨테이너에 등록된 빈을 주입하는 것입니다. + MockMvc는 @WebMvcTest을 사용하는 경우 제공되는 빈입니다. + */ @Autowired MockMvc mockMvc; - + /* + @WebMvcTest에 필요한 의존성 빈을 Mock으로 대체합니다. + Mock이란 실제 객체와 동일한 구조를 가지지만 실제 로직이나 기능을 수행하지 않는 객체를 말합니다. + 컨트롤러를 테스트 할때 필요한 결과를 반환하도록 설정할 수 있습니다. + */ @MockBean LoginService loginService; diff --git a/src/test/java/shop/sendbox/sendbox/util/AesEncryptTest.java b/src/test/java/shop/sendbox/sendbox/util/AesEncryptTest.java new file mode 100644 index 0000000..0b80930 --- /dev/null +++ b/src/test/java/shop/sendbox/sendbox/util/AesEncryptTest.java @@ -0,0 +1,53 @@ +package shop.sendbox.sendbox.util; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + + +class AesEncryptTest { + + @Test + @DisplayName("올바른 값을 입력시 암호화 복호화가 정상동작 합니다.") + void encryptTest() { + // given + String input = "test"; + final String password = "AesEncrypt.PASSWORD"; + final String salt = "AesEncrypt.SALT"; + String encrypted = AesEncrypt.encrypt(password, salt, input); + + // when + String decrypted = AesEncrypt.decrypt(password, salt, encrypted); + + // then + Assertions.assertThat(decrypted).isEqualTo(input); + } + + @Test + @DisplayName("복호화시 값이 없으면 예외가 발생합니다.") + void decryptTestWithNull() { + // given + String encrypted = null; + final String password = "AesEncrypt.PASSWORD"; + final String salt = "AesEncrypt.SALT"; + + // when & then + Assertions.assertThatThrownBy(() -> AesEncrypt.decrypt(password, salt, encrypted)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("암호화된 문자열에 값이 없습니다."); + } + + @Test + @DisplayName("복호화시 암호문 양식이 올바르지 않으면 예외가 발생합니다.") + void decryptTestWithNot() { + // given + String encrypted = "test"; + final String password = "AesEncrypt.PASSWORD"; + final String salt = "AesEncrypt.SALT"; + + // when & then + Assertions.assertThatThrownBy(() -> AesEncrypt.decrypt(password, salt, encrypted)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("암호화된 문자열 양식이 올바르지 않습니다."); + } +}