개요
평화로운 사이드 프로젝트 개발 중 팀원이 요런 코멘트를 남겨주셨다.
/api/v1/users/1 < 같은 느낌으로 특정 데이터의 PK가 path variable 등 외부에 노출되는 것이 권장되지 않는다는 요지의 코멘트였다.
이때 당시에는 JWT를 이용하는 방식으로 바꿀 예정 & PK ID외에 데이터를 특정할 수 있는 방법이 마땅찮음 & 정확히 보안상의 어떤 원인이 있는지 파악 불가 등의 이유로 나중에 고민해보자! 하고 넘어갔었다.
나중에 다시 의논해보자고 했던 것이 어언 3월.. 잊고 있었던 요 코멘트가 불현득 떠올라 이번 포스팅에서 파헤쳐 보려 한다.
외부에 노출되는 PK
우리는 위와 같이 생긴 REST API를 숱하게 만들고, 사용하고, 주변에서 찾아볼 수 있다. REST의 개념 자체가 URI로 자원을 표현한다는 규칙에서 기인한 것이라 URI를 보면 자원이 특정되어야 하는 것처럼 느껴지기에, 위 endpoint 설계는 그렇게 이상하지 않아보인다.
따라서 우리는 특정 도메인에서 리소스를 식별하기 위해, 그 리소스와 1:1로 맵핑되는 어떤 수단이 필요하다. 위의 예시에서는 categoryId가 category를 식별하는 수단으로 사용되었으므로, PUT /api/categories/1을 보면 1번 카테고리를 수정하는 API구나 하고 파악할 수 있다.
이 때, 고유 ID 생성기 등을 이용해 중복되지 않는 unique value를 생성할 수도 있지만, 직접 알고리즘을 대입해 ID 생성기를 만들기엔 리소스가 꽤 필요하기에 보통 작은 규모의 프로젝트에선 DB의 PK를 사용한다. PK는 중복을 허용하지 않기에, DB에 저장되어 있다는 것 만으로도 중복되지 않는 값임을 증명할 수 있기 때문이다.
이런 과정을 거치면 REST API 환경에서 리소스 식별자로 PK를 사용하는 것이 어떻게 보면 당연한 수순처럼 보인다.
PK가 노출되면 안되는 이유?
하지만 무분별하게 PK나 UK를 외부에 노출하는 것은 몇 가지 의도하지 않은 사이드 이펙트를 불러일으킬 수 있다.
먼저 리소스의 주소가 노출된다는 문제가 있을 것 같다. endpoint에 노출된 PK를 이용한다면 공격에 훨씬 취약해진다.
예를 들어 DELETE /api/categories/1 와 같은 요청을 통해 누구나 1번 카테고리를 삭제할 수 있다. 만약 서버에서 해당 API에 권한 (SuperUser 등)을 부여했거나, 혹은 자신이 생성한 데이터에 대해서만 삭제할 수 있도록 로직에서 한 번 더 체크해주었다면 안전하겠지만 인증과 인가 단계가 허술하다면.. 🫠
다음은 Auto Increment 패턴이 노출될 수 있다는 문제도 고려해보아야 할 것 같다. 보통 Int형 PK는 Auto Increment 기능을 이용해 레코드가 insert될 때마다 자동으로 카운팅되도록 설정이 되어 있다.
하지만 PK가 자동 증가된다는 사실이 악의를 가진 유저에게 노출되면, 현재 내 DB에 데이터는 얼마나 있는지, 다음으로 생성될 데이터는 어떻게 식별 가능할 수 있는지 등의 유추가 가능하며, 나아가 크롤러를 만드는 데에도 활용될 수 있다.
위 케이스들은 어떻게 보면 너무 오버 스펙으로 보일 수도, 너무 과도한 걱정으로 보일 수도 있다. 하지만 내가 내 웹 페이지에서 새로 쓰는 게시글마다 악성 스팸 댓글이 달리는 상황을 가정해본다면, 음.. 나는 그냥 조금만 더 일하고 안전한 서비스를 유지하고 싶다 😇
Int PK값 암복호화
그럼 위 문제들을 고유 ID 생성기처럼 너무 딥해지지 않는 선에서 가볍게 해결할 수 있는 방법은 무엇이 있을까? 하고 고민하다가, JWT를 떠올리게 되었다. JWT처럼 secret key 등을 서버에 저장해두고, 그를 활용해 PK값을 암호화-복호화 할 수 있다면 딱 우리가 원하는 기능일 듯 하다 🤔
생각한 구조는 아래와 같다.
- 클라이언트단에서 전체 데이터 조회 API를 호출한다.
- 서버는 응답으로 보낼 데이터를 DB에서 조회하고, 정해진 응답 포맷에 맞게 가공한다.
- 위 과정에서 저장된 secret key와 적당한 암호화 알고리즘을 이용해 Integer PK 값을 암호화한다.
- 가공된 PK값을 포함한 response를 클라이언트에게 반환한다.
위 구조를 구현하기 위한 방법을 찾아보니, Java 환경에서는 javax.crypto 패키지를 활용할 수 있을 것 같다. Cipher 클래스를 이용한 AES 대칭키 암호화 방법이 딱 무난해보이니 한번 적용해보자.
AES 알고리즘이 뭔지 & 대칭키 암호화가 무엇인지 궁금하다면? 더보기를 참고하세요 🙌 ((( 0628 작성중 )))
구현하기
private static final String ALGORITHM = "AES";
private static final String SECRET = "0123456789ABCDEF";
// 대칭키 생성
private static SecretKey generateKey() throws NoSuchAlgorithmException {
byte[] keyBytes = SECRET.getBytes();
return new SecretKeySpec(keyBytes, ALGORITHM);
}
먼저 대칭키를 생성하는 메서드를 작성해보자. SECRET의 경우, application.yml이나 환경 변수 등으로 빼는 것이 좋겠지만, 일단 구현하고 잘 동작하는지 확인하는 것에 초점을 맞추는 것에 초점을 맞추어 대충 전역 변수로 작성했다.
지정한 SECRET 문자열과 AES 알고리즘을 이용해 SecretKey를 생성해주는 간단한 로직이다.
// 암호화
public static String encrypt(final Long value) throws Exception {
Cipher cipher = Cipher.getInstance(ALGORITHM);
cipher.init(Cipher.ENCRYPT_MODE, generateKey());
byte[] valueBytes = ByteBuffer.allocate(Long.BYTES).putLong(value).array();
byte[] encrypted = cipher.doFinal(valueBytes);
return Base64.getEncoder().encodeToString(encrypted);
}
주어진 Long 값을 암호화하는 메서드이다. AES Cipher 클래스를 생성한 후, 대칭키를 이용해 초기화해준다. input 파라미터로 주어진 Long 값 - 즉 PK를 Byte 배열로 변환해준 후 암호화된 값을 Base64으로 인코딩해서 리턴한다.
// 복호화
public static Long decrypt(final String encryptedValue) throws Exception {
Cipher cipher = Cipher.getInstance(ALGORITHM);
cipher.init(Cipher.DECRYPT_MODE, generateKey());
byte[] encryptedBytes = Base64.getDecoder().decode(encryptedValue);
byte[] decryptedBytes = cipher.doFinal(encryptedBytes);
return ByteBuffer.wrap(decryptedBytes).getLong();
}
이번엔 암호화된 값을 복호화하는 메서드이다. 마찬가지로 AES Cipher 클래스를 대칭키를 이용해 복호화 모드로 초기화한다. Base64로 인코딩된 암호값을 디코딩한 후, Cipher 클래스로 복호화한 후, Byte 값을 다시 Long 값으로 변환한다.
한번 실행해보자.
원래 category의 PK 인 categoryId는 1이지만, 응답에는 암호화된 응답이 돌아왔다. 이제 해당 API도 암호화된 PK를 이용하도록 categoryId 스펙을 String으로 바꿔주자.
암호화된 PK 값을 보내줘도, 서버에서 복호화 후 잘 DB에서 값을 조회하는 것을 확인할 수 있다.
마무리
오늘은 평소 무의식적으로 사용하는 PK 기반의 REST 리소스 식별 방식의 문제점을 알아보고, 해결 방법도 알아봤다.
사실 암복호화는 그냥 이렇게 하면 되지 않을까? 하는 생각으로 시도해본건데, 실제로 잘 동작하는 걸 보니 기분이 째진다 😎
실제 규모가 큰 서비스에서도 이런 방법을 쓰는 것이 맞을까? 아니면 또 다른 좋은 해결 방법이 있는 걸까? 어쨌든 재밌는 경험이었다!
'Develop > Spring' 카테고리의 다른 글
[Spring Boot] 외부 API 호출 도구 OpenFeign : RestTemplate, WebClient와 비교까지 (0) | 2023.09.20 |
---|---|
[JPA] 영속성 전이 (CASCADE)와 고아 객체에 대하여 (0) | 2023.09.16 |
[Spring Boot] 자바 스프링에서 처리율 제한 기능을 구현하는 4가지 방법 (2) | 2023.06.21 |
[Spring Boot] Jsoup으로 OG태그 메타 데이터 크롤링하기 (1) | 2023.06.14 |
[Spring Boot] count를 구현하는 5가지 방법 (2) | 2023.05.24 |