개요
RefreshToken을 쿠키로 전달할 때 보안을 강화하기 위해 AES128 양방향 암호화를 적용했다.
RefreshToken은 사용자가 재인증할 필요 없이 새로운 AcessToken을 발급받도록 해준다. 만약 해커가 RefreshToken을 가로채면 암호를 사용자의 계정에 접근하는데 사용할 수 있다. 이 때, AES128 암호화로 RefreshToken을 암호화하면 RefreshToken을 가로채더라도 사용하기 어려워진다. (물론 이 방법 또한 완벽하지 않다.)
AES(Advanced Encryption Standard) 암호화
- 저장중이거나 전송하려는 데이터를 보호하는 데 사용되는 대칭 암호화 알고리즘. 고정된 크기의 데이터 블록에 데이터를 암호화하는 블록 암호 암호화 알고리즘이다.
- AES 암호화는 데이터의 발신자와 수신자 간에 공유되는 Secret Key를 사용한다. Secret Key는 일반 텍스트를 암호화하거나 암호를 일반 텍스트로 복호화하는 데 사용된다. 그렇기 때문에 Secret Key는 절대 외부에 노출되서는 안된다.
- AES 암호화는 Secret Key의 길이에 따라 종류가 다르다. 128bit, 192bit, 256bit를 지원한다. 나는 128bit를 사용했다.
- AES128 암호화는 자바의 기본 라이브러리를 사용한다. 아래 변수와 함께 알아보자
- SecretKeySpec : 비밀키를 만드는 데 사용된다.
- IvParameterSpec : CBC 모드의 IV를 만들기 위해 사용한다.
- Cipher : AES 및 다양한 암호화 알고리즘을 사용하여 데이터를 암호화하고 해독하는 메서드를 제공한다.
구현
다음은 AES128 암호화 및 복호화 기능을 하는 AES128Config 클래스의 코드이다. 순서대로 살펴보자
@Component
public class AES128Config {
private static final Charset ENCODING_TYPE = StandardCharsets.UTF_8;
private static final String INSTANCE_TYPE = "AES/CBC/PKCS5Padding";
@Value("${aes.secret-key}")
private String secretKey;
private IvParameterSpec ivParameterSpec;
private SecretKeySpec secretKeySpec;
private Cipher cipher;
@PostConstruct
public void init() throws NoSuchPaddingException, NoSuchAlgorithmException {
SecureRandom secureRandom = new SecureRandom();
byte[] iv = new byte[16]; // 16bytes = 128bits
secureRandom.nextBytes(iv);
ivParameterSpec = new IvParameterSpec(iv);
secretKeySpec = new SecretKeySpec(secretKey.getBytes(ENCODING_TYPE), "AES");
cipher = Cipher.getInstance(INSTANCE_TYPE);
}
// AES 암호화
public String encryptAes(String plaintext) {
try {
cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, ivParameterSpec);
byte[] encryted = cipher.doFinal(plaintext.getBytes(ENCODING_TYPE));
return new String(Base64.getEncoder().encode(encryted), ENCODING_TYPE);
} catch (Exception e) {
throw new BusinessLogicException(ExceptionCode.ENCRYPTION_FAILED);
}
}
// AES 복호화
public String decryptAes(String plaintext) {
try {
cipher.init(Cipher.DECRYPT_MODE, secretKeySpec, ivParameterSpec);
byte[] decoded = Base64.getDecoder().decode(plaintext.getBytes(ENCODING_TYPE));
return new String(cipher.doFinal(decoded), ENCODING_TYPE);
} catch (Exception e) {
throw new BusinessLogicException(ExceptionCode.DECRYPTION_FAILED);
}
}
}
1. IvParameterSpec 생성
SecureRandom 인스턴스를 사용해서 16바이트 크기의 난수 바이트 배열을 생성하고 바이트 배열을 사용해서 IvParameterSpec 객체를 생성한다. SecurityRnadom를 사용한 이유는 SecretKeySpec과 IvParameterSpec에 동일한 키를 사용하게 되면 보안에 취약하기 때문이다. 이 때 보안을 강화하는 방법으로 SecureRandom과 같은 난수 생성기를 사용하는 것이다.
@PostConstruct
public void init() throws NoSuchPaddingException, NoSuchAlgorithmException {
SecureRandom secureRandom = new SecureRandom();
byte[] iv = new byte[16]; // 16bytes = 128bits
secureRandom.nextBytes(iv);
ivParameterSpec = new IvParameterSpec(iv);
...
}
2. SecretKeySpec, Cipher 초기화
ENCODING_TYPE(UTF-8)을 사용하여 Secret Key 문자열을 바이트로 변환한 키 값과 문자열 “AES”를 사용해서 SecretKeySpec을 생성한다.
알고리즘 타입을 지정하여 Cipher를 생성한다. 나는 AES/CBC/PKCS5Padding 를 사용했다.
@PostConstruct
public void init() throws NoSuchPaddingException, NoSuchAlgorithmException {
...
secretKeySpec = new SecretKeySpec(secretKey.getBytes(ENCODING_TYPE), "AES");
cipher = Cipher.getInstance(INSTANCE_TYPE);
}
3. 데이터 암호화
secretKeySpec과 ivParameterSpec를 사용하여 암호화 모드(ENCRYPT_MODE)에서 Cipher를 초기화하고 doFinal() 메서드로 데이터를 암호화한다. 암호화한 데이터는 편의를 위해 문자열로 인코딩하여 반환했다.
public String encryptAes(String plaintext) {
try {
cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, ivParameterSpec);
byte[] encryted = cipher.doFinal(plaintext.getBytes(ENCODING_TYPE));
return new String(Base64.getEncoder().encode(encryted), ENCODING_TYPE);
} catch (Exception e) {
throw new BusinessLogicException(ExceptionCode.ENCRYPTION_FAILED);
}
}
4. 데이터 복호화
암호화와는 반대로 복호화 모드(DECRYPT_MODE)에서 secretKeySpec과 ivParameterSpec를 사용하여 Cipher를 초기화하고 doFinal() 메서드로 데이터를 복호화 한다. 복호화한 데이터도 문자열로 인코딩하여 반환했다.
public String decryptAes(String plaintext) {
try {
cipher.init(Cipher.DECRYPT_MODE, secretKeySpec, ivParameterSpec);
byte[] decoded = Base64.getDecoder().decode(plaintext.getBytes(ENCODING_TYPE));
return new String(cipher.doFinal(decoded), ENCODING_TYPE);
} catch (Exception e) {
throw new BusinessLogicException(ExceptionCode.DECRYPTION_FAILED);
}
}
5. Test
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import static org.assertj.core.api.Assertions.assertThat;
@Slf4j
@SpringBootTest
class AES128ConfigTest {
@Autowired
private AES128Config aes128Config;
@Test
@DisplayName("Aes128 암호화가 잘 이루어지는지 테스트")
void aes128Test() {
String text = "this is test";
String enc = aes128Config.encryptAes(text);
String dec = aes128Config.decryptAes(enc);
log.info("enc = {}", enc);
log.info("dec = {}", dec);
assertThat(dec).isEqualTo(text);
}
}
'Java' 카테고리의 다른 글
Java - @JasonCreator로 DTO에서 유연하게 Enum Type 받기 (0) | 2023.04.26 |
---|---|
Java - 커스텀 애너테이션으로 유효성 검사하기 (0) | 2023.04.22 |
Java - 제네릭(Generic)과 함께하는 리팩토링 (0) | 2023.04.17 |
Java - @NotNull, @NotEmptty, @Notblank 차이점 알고 쓰시나요? (0) | 2023.04.04 |
Java - String 메소드 총정리! (0) | 2022.11.30 |