Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat:AES 암호화 방식 유틸리티 추가#15 #16

Open
wants to merge 5 commits into
base: feature/#13-login
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion src/main/java/shop/sendbox/sendbox/api/ApiResponse.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

import lombok.Getter;

// Getter 으로 Jackson 라이브러리가 JSON 형태로 변환할 수 있도록 한다
@Getter
public class ApiResponse<T> {

Expand Down
11 changes: 9 additions & 2 deletions src/main/java/shop/sendbox/sendbox/buyer/entity/Buyer.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
93 changes: 93 additions & 0 deletions src/main/java/shop/sendbox/sendbox/util/AesEncrypt.java
Original file line number Diff line number Diff line change
@@ -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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

해당 어노테이션의 동작방식에 대해서도 적어주시면 더 좋을 것 같습니다.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

네 추가했습니다 :)

public class AesEncrypt {

private static final String ALGORITHM = "AES/CBC/PKCS5Padding";
/**
* TODO
* 패스워드와 salt를 어떻게 보관할 것인지 고민입니다.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

password 와 salt 의 차이는 무엇인가요? application.yml 에 보관하는 것은 어떨까요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

password는 암호화 및 복호화를 위해 필요한 key입니다
동일한 key를 가지고 만든 암호화 데이터는 모두 같은 key를 통해 복호화가 가능하므로 보안상 위험합니다.
따라서 password에 salt의 임의의 값을 추가하여 암호화를 하는 경우에는 암호화 결과가 다르게 나오게 됩니다.

현재 로직에서는 고정된 salt를 사용하여 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);
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
}
Expand Down
10 changes: 6 additions & 4 deletions src/test/java/shop/sendbox/sendbox/buyer/ApiResponseTest.java
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -24,10 +26,10 @@ void create() {
ApiResponse<String> 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);
}

}
7 changes: 6 additions & 1 deletion src/test/java/shop/sendbox/sendbox/buyer/BuyerTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@
import shop.sendbox.sendbox.buyer.entity.Buyer;

class BuyerTest {

/*
@Test 애노테이션이 붙은 메서드는 테스트 메서드로 인식됩니다.
Junit 프레임워크는 @Test 붙은 메서드를 찾아서 실행하며,
코드의 특정 부분이 제대로 동작하는지 검증할 수 있습니다.
해당 애노테이션이 붙은 메서드는 private이나 static이 아니여야합니다.
*/
@Test
@DisplayName("구매자가 회원가입을 하면 활성화 상태이다.")
void create() {
Expand Down
23 changes: 15 additions & 8 deletions src/test/java/shop/sendbox/sendbox/login/LoginControllerTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
53 changes: 53 additions & 0 deletions src/test/java/shop/sendbox/sendbox/util/AesEncryptTest.java
Original file line number Diff line number Diff line change
@@ -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("암호화된 문자열 양식이 올바르지 않습니다.");
}
}
Loading