Mash-Up 스프링팀에서는 팀원들이 돌아가면서 본인의 Problem-Solving 경험이나 Deep Dive의 내용을 발표하는 자리를 갖습니다. 이번 14기 활동 때 발표한 내용중 하나인 PK 숨기기
에 대해 이번 포스팅에서 다뤄보겠습니다. 발표자는 프로젝트 개발 중 Front 개발자가 우리의 PK를 과연 외부에 노출해도 괜찮을까?
라는 질문에 대해 논의를 하다가 이 문제를 해결했다는데요. 문제를 해결하기 위해 어떤 고민을 했고 어떤 해결방법을 도출해냈는지 함께 알아봅시다.
PK 숨기기
개요
프로젝트 개발 중 무분별하게 노출되는 PK에 대해서 프론트 개발자와 PK 과연 외부에 노출해도 괜찮은것일까?
에 대해 논의를 하다가 나중에 구현해보자! 라고 생각하다 문득 생각이 나서 이 문제를 해결해보게 되었다.
PK 과연 외부에 노출해도 괜찮을까?
REST API 에서 pk를 외부에 노출하는 경우
요즘 개발을 한다고 하면 대부분이 REST 방식의 API를 구현하는 경우가 대부분이다. 그리고 리소스에 접근하는 방식이 대개 다음과 같은 방식으로 이루어진다.
GET /api/v1/posts/{postId}
, GET /api/v1/comments/{commentId]
등 PK를 통해서 직접적으로 접근하는 경우가 대부분이다. 이 경우 PK가 AutoIncrement 형식의 Long 형 값이라면 공격자 입장에서 무분별하게 특정 리소스에 접근이 가능할 것이다. 만약 해당 리소스에 대해 특정 사용자만 접근이 가능하다고 할 때 서비스 내부적으로 특정 사용자가 아닌 경우에 대한 권한 처리를 제대로 하고 있다면 문제가 없을테지만 그렇지 않은 경우 원치 않은 사용자에게 리소스 정보가 넘어갈 수 있다.
심지어 그것이 AutoIncrement 형식의 PK 라면 리소스가 만들어진 순서에 따라 순차적으로 리소스에 접근할 수 있게 된다. 즉, 추측이나 인젝션 공격의 대상이 될 수 있다. 예를 들어 userId라는 식별자를 생각해봤을 때 GET /users/{userId}
요청으로 의도치 않게 타인의 마이페이지 접속 루트가 열리는 문제가 생길 수 있을 것이고. 특정 연속되는 값을 넣는다면 해당 서비스의 회원 수를 유추해 볼 수도 있다.
실제로 2020년 5월 특정 서비스에서 유저 아이디를 URL PATH로 사용해 유저의 거래 내역을 노출하는 GET API에 대해 URL 숫자 조작만으로 다른 이의 거래 내역을 손쉽게 드러낸 경우도 있다.
그렇다면 PK를 UUID로 변경하면 될까?
PK를 AutoIncrement가 아닌 UUID를 사용하는 경우
UUID를 사용하면 확실히 AutoIncrement PK에 비해 리소스에 무분별하게 접근할 수는 없을 것이다. 하지만 Auto Increment 보다 더 많은 공간을 차지하게 돼서 데이터베이스 인덱싱 및 스토리지에 오버헤드가 발생할 수 있다. 심지어 PK를 외부에 노출하지 않겠다고 모든 테이블 key 값을 UUID로 변경하는 것은 너무 과한 조치인 것 같다.
공통적인 문제
AutoIncrement PK, UUID PK 등 뭐든간에 PK를 외부에 노출하면 결국 데이터베이스의 구조와 공격자에게 도움이 될 만한 정보가 외부에 들어난다는 것이다. 그러면 UUID를 사용하지 않는 방법으로 이 문제를 해결해보자
요구사항
구현하기 앞서 다음과 같은 요구사항을 생각해보았다.
- 이 문제를 해결하기 위해 다른 의존성이 생기지 않았으면 한다.
- 최대한 핵심 로직을 건들지 않는 선에서 해결했으면 좋겠다.
해결 방법
PK 매핑
내부적으로는 AutoIncrement PK를 사용하고 외부로 노출되는 PK는 UUID를 사용해 원본 PK(AutoIncrement)와 UUID를 매핑하는 테이블을 만드는 것이다. 하지만 RDB가 아닌 다른 NoSQL 저장소를 쓴다고 해도 Persistence 부분에 의존성이 생기기 때문에 이 해결방법은 제외했다.
PK 암호화
서비스에 들어오고 나가는 PK에 대해 모두 암호화를 시켜주는 방식이다. Controller 부분에서 요청을 받고 응답을 줄 때 암호화를 해주면 되기에 핵심로직(Service)에 무리가 가지 않으며 의존성이 추가되진 않기에 이 해결방법을 채택했다.
아이디어
위의 요구사항 2번에 핵심 로직을 건들지 않는 선에서 해결하기 원했기에 극한으로 핵심 로직을 건들지 않게 실제로 요청을 받고 처리하는 부분에서는 내부적으로 사용하는 PK값을 온전히 전달받을 수 있게 구현하고자 했다. 이를 위해 사용한 기술은 다음과 같다.
- ArgumentResolver
- ArgumentResolver를 사용해 Parameter로 해당 pk가 들어온 경우 이를 추출해 Long 형 타입으로 바꿔주기 위해 사용했다
- PathVariableArgumentResolver를 사용해 path에 pk를 담은 경우 이를 추출해 Long 형 타입으로 바꿔주기 위해 사용했다
- AOP
- Response 조차 Long형 타입에 간단히 어노테이션을 붙이는 것 만으로 자동으로 String으로 변경해서 외부로 내보낼 수 있게 하기 위해 사용하였다.
- ByteBuddy
- 클래스를 동적으로 생성하기 위한 라이브러리이다. AOP를 통해 Response를 외부로 내보낼 때 암호화 할 PK의 타입을 변경해 새로운 클래스를 만들기 위해 사용하였다.
구현
이 프로젝트는 Long 형 타입의 AutoIncrement PK를 사용하였다.
기본 서비스
SampleController
@RequestMapping("/api/v1/sample")
@RestController
@SecretTarget
class SampleController(private val sampleService: SampleService) {
@PostMapping
fun create(@RequestBody request: SampleRequest): Response<Any> {
return Response.success(sampleService.create(request.email, request.nickname))
}
@GetMapping("/get")
fun get1(@SecretPk id: Long): Response<Any> {
return Response.success(data = sampleService.getById(id))
}
@GetMapping("/get/{id}")
fun get2(@SecretPkPathVariable("id") id: Long): Response<Any> {
return Response.success(data = sampleService.getById(id))
}
}
SampleService
@Service
class SampleService {
private val userMap = ConcurrentHashMap<Long, SampleResponse>()
fun create(email: String, nickname: String): SampleResponse {
val nextId = (userMap.map { it.key }.maxOrNull() ?: -1) + 1
userMap[nextId] = SampleResponse(nextId, email, nickname)
return userMap[nextId]!!
}
fun getById(id: Long): SampleResponse {
return userMap[id] ?: throw IllegalArgumentException("cannot find user, userId: $id")
}
}
Inmemeory 캐싱을 통해 SampleResponse를 주고 받는 간단한 서비스다.
Encryptor
실제로 PK를 암/복호화 해주는 Encryptor를 만들어보자
class SecretEncryptor(private val property: SecretPkProperty) {
private val secretKey: SecretKey
init {
val keyBytes = property.secretKey.toByteArray()
secretKey = SecretKeySpec(keyBytes, property.algorithm)
}
fun encryptLongToString(value: Long): String {
val cipher = Cipher.getInstance(property.algorithm)
cipher.init(Cipher.ENCRYPT_MODE, secretKey)
val byteBuffer = ByteBuffer.allocate(8)
byteBuffer.putLong(value)
val longBytes = byteBuffer.array()
val encryptedBytes = cipher.doFinal(longBytes)
return Base64.getEncoder().encodeToString(encryptedBytes)
}
fun decryptStringToLong(encryptedString: String): Long {
val cipher = Cipher.getInstance(property.algorithm)
cipher.init(Cipher.DECRYPT_MODE, secretKey)
val encryptedBytes = Base64.getDecoder().decode(encryptedString)
val decryptedBytes = cipher.doFinal(encryptedBytes)
return ByteBuffer.wrap(decryptedBytes).getLong()
}
}
javax.crypto 를 사용해서 SecretPkProperty(algorithm, key) 를 입력받아 SecretKey를 생성한다.
SecretPkConfig
@Configuration
@EnableConfigurationProperties(SecretPkProperty::class)
class SecretPkConfig {
@Bean
fun secretEncryptor(property: SecretPkProperty): SecretEncryptor {
return SecretEncryptor(property)
}
}
@ConfigurationProperties(prefix = "secret.pk")
data class SecretPkProperty(val algorithm: String, val secretKey: String)
ArgumentResolver
이제 실제로 path나 parameter로 들어온 id 값을 추출해 복호화 한 후 Controller에 던져주자.
기본적으로 들어오는 형식은 다음과 같을 것이다.
/api/v1/sample/{sampleId}
or /api/v1/sample?id={sampleId}
Parameter로 들어온 경우
SecretPkArgumentResolver
class SecretPkArgumentResolver(private val encryptor: SecretEncryptor) : HandlerMethodArgumentResolver {
private val log: Logger = LoggerFactory.getLogger(SecretPkArgumentResolver::class.java)
override fun supportsParameter(parameter: MethodParameter): Boolean {
return parameter.hasParameterAnnotation(SecretPk::class.java)
&& parameter.parameter.type == Long::class.java
}
override fun resolveArgument(
parameter: MethodParameter,
mavContainer: ModelAndViewContainer?,
webRequest: NativeWebRequest,
binderFactory: WebDataBinderFactory?
): Any {
val param = webRequest.parameterMap[parameter.parameterName]?.firstOrNull()
log.info("parameter.parameter: ${parameter.parameter}, parameter: $parameter, param: $param")
param ?: throw IllegalStateException("param must not be null")
return encryptor.decryptStringToLong(param) // param을 입력받아 decrypt 작업 수행
}
}
Path로 들어온 경우
SecretPkPathVariableArgumentResolver
class SecretPkPathVariableArgumentResolver(private val encryptor: SecretEncryptor) : HandlerMethodArgumentResolver {
override fun supportsParameter(parameter: MethodParameter): Boolean {
return parameter.hasParameterAnnotation(SecretPkPathVariable::class.java)
}
override fun resolveArgument(
parameter: MethodParameter,
mavContainer: ModelAndViewContainer?,
webRequest: NativeWebRequest,
binderFactory: WebDataBinderFactory?
): Any {
val pathVariables = webRequest.getAttribute(
HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE,
NativeWebRequest.SCOPE_REQUEST
) as Map<*, *>
val paramName = parameter.parameterName ?: throw IllegalArgumentException("Parameter name is null")
val param = pathVariables[paramName] as? String ?: throw IllegalArgumentException("Parameter name is null")
return encryptor.decryptStringToLong(param)
}
}
실제 사용법
@GetMapping("/get")
fun get1(@SecretPk id: Long): Response<Any> {
return Response.success(data = sampleService.getById(id))
}
@GetMapping("/get/{id}")
fun get2(@SecretPkPathVariable("id") id: Long): Response<Any> {
return Response.success(data = sampleService.getById(id))
}
@SecretPk
어노테이션을 사용하면 SecretPkArgumentResolver를 통해 파라미터에 담긴 id를 decryp 해서 Long 형 타입으로 PK를 바로 받아올 수 있다.
@SecretPkPathVariable
어노테이션을 사용해 SecretPkPathArgumentResolver를 통해 path에 담긴 id를 복호화해서 바로 받아올 수 있다.
이를 통해 사용자는 id가 String이었는지 암호화가 어떻게 수행되는지 전혀 알 필요 없이 핵심 로직에만 집중할 수 있다.
SecretPkAspect
그러면 암호화는 어떻게 진행할까? 사실 가장 심플한 방법은 DTO에 String 타입의 id를 적어주면 된다. 다음과 같다.
data class SampleResponse(
val id: String,
val email: String,
val nickname: String
)
하지만 이 또한 Response에 정보를 담으면서 id를 암호화 해줘야 하는 필요성이 있다. 핵심 로직에 집중하기 위해 id를 다음과 같이 변경해보자.
data class SampleResponse(
@field:SecretPk val id: Long,
val email: String,
val nickname: String
)
이렇게 SecretPk가 달린 필드의 경우 AOP에서 알아서 String 형태로 변환한 후 암호화 해서 내보냈으면 하는 요구사항이 있었다. 어떻게 해결할 수 있을까?
Filter를 사용해보자
Filter는 Interceptor와는 다르게 Response 조작이 가능하다. 하지만 이는 Spring MVC 외부에 있기에 클래스 원형에 접근하기 어렵다. 왜냐하면 우리는 @SecretPk
가 붙은 함수 원형을 참조해야 하기 때문에 반환 타입의 정보가 필요하기 때문이다!
Spring AOP를 사용해보자
이 또한 작은 문제점이 존재한다. Controller 단의 반환 값을 변경하는 것이기 때문에 반환값은 결국 동일해야 한다. id를 Long → String으로 변경한다면 상속도 불가능하니 새로운 클래스가 필요한데 클래스를 정적이든 동적이든 변경한다면 반환 타입의 불일치의 문제가 생긴다. 이를 Wrapper를 둬서 어느정도 합의를 보았다.
fun get1(@SecretPk id: Long): Response<Any> {
return Response.success(data = sampleService.getById(id))
}
Response
반환값을 단순히 String으로 변환해서 내려줄까? (objectMapper 이용해서) 아니면 동적으로 클래스를 새로 생성해서 반환할까? 고민을 많이 하다가 후자의 방식을 선택해 reflection 기술과 ByteBuddy 라는 라이브러리를 사용해 구현했다.
SecretPkAspect
var dynamicClassBuilder = byteBuddy
.subclass(Any::class.java)
.name(resultDataClassName + SUFFIX_DYNAMIC_CLASS_NAME)
우선 ByteBuddy를 통해 빈 껍데기 클래스를 만들어주자. 클래스 이름은 원본 클래스명 + _S(Secret)을 붙였다.
memberProperties.forEach { property ->
val hasSecretPkAnnotation =
property.javaField?.annotations?.firstOrNull()?.annotationClass == SecretPk::class
dynamicClassBuilder = if (hasSecretPkAnnotation) {
dynamicClassBuilder.defineField(property.name, String::class.java, Visibility.PUBLIC)
} else {
dynamicClassBuilder.defineField(
property.name,
property.returnType.jvmErasure.java,
Visibility.PUBLIC
)
}
}
property를 돌면서 SecretPk 어노테이션이 붙은 필드의 경우 String으로 나머지는 본래 자신의 클래스를 따라가게 변경했다.
property.returnType.jvmErasure.java
를 통해서 자바 클래스에 접근해야 한다. property.javaClass
는 자바 클래스가 아니다 KPropery1을 포함한 자바 클래스다.
val newClass = dynamicClassBuilder
.make()
.load(SecretPkAspect::class.java.classLoader)
.loaded
클래스 정의를 다 하고 나면 부모 객체의 ClassLoader를 통해 클래스를 로드 시켜줘야한다.
make
함수를 통해 바이트 코드를 생성한다.
val newClassInstance = newClass.getDeclaredConstructor().newInstance()
만들어진 클래스를 바탕으로 새로운 인스턴스를 만든 후
memberProperties.forEach { property ->
val hasSecretPkAnnotation =
property.javaField?.annotations?.firstOrNull()?.annotationClass == SecretPk::class
if (hasSecretPkAnnotation) {
val encrypted = (getPropertyValue(data, property.name) as? Long)
?.let { encryptor.encryptLongToString(it) }
?: throw IllegalStateException("@SecretPk fields must be Long")
newClass.getField(property.name).set(newClassInstance, encrypted)
} else {
val value = getPropertyValue(data, property.name)
newClass.getField(property.name).set(newClassInstance, value)
}
}
다시 프로퍼티를 돌면서 실제 값을 넣어주자! SecretPk
어노테이션이 붙어있는 경우는 encryptLongToString
을 통해 Long형 PK를 암호화 해서 넣어주고 아닌 경우는 실제 값을 그대로 넣어준다.
SecretPkAspect 사용법
@RequestMapping("/api/v1/sample")
@RestController
@SecretTarget
class SampleController(private val sampleService: SampleService) {
@PostMapping
fun create(@RequestBody request: SampleRequest): Response<Any> {
return Response.success(sampleService.create(request.email, request.nickname))
}
@GetMapping("/get")
fun get1(@SecretPk id: Long): Response<Any> {
return Response.success(data = sampleService.getById(id))
}
@GetMapping("/get/{id}")
fun get2(@SecretPkPathVariable("id") id: Long): Response<Any> {
return Response.success(data = sampleService.getById(id))
}
}
@SecretTarget
을 통해 JoinPoint를 설정해주고 암호화할 요청은 @SecretPk
or @SecretPkPathVariable
를 통해 설정해주고 응답의 경우
data class SampleResponse(
@field:SecretPk val id: Long,
val email: String,
val nickname: String
)
data class field 단에 SecretPk
어노테이션을 달아주면 알아서 AOP가 동작한다!
결과
create
get(Path)
get(Parameter)
'팀 이야기' 카테고리의 다른 글
[스프링팀] Coroutine 찍어먹기 (0) | 2024.10.18 |
---|---|
[스프링] SpringBoot이 ObjectMapper를 구성하는 방법 (0) | 2024.10.16 |
[스프링팀] gRPC (1) | 2024.10.15 |
[스프링팀] Druid (0) | 2023.08.31 |
[스프링팀] 검색 기술의 원리 발표 세션 (1) | 2022.09.14 |