본문 바로가기

spring

kotlin 마스킹

해당 글은 Meet-Coder 에서 블로그 포스팅 스터디를 하면서 쓴 글입니다.
MarkDown 으로 쓴 글이기에, 해당 tstroy 보다는 아래 github repository 에서 보는 것이 깔끔합니다.

https://github.com/cmg1411/posting-review/blob/master/kimmingeor/2021-11-13-masking.md

 

GitHub - cmg1411/posting-review: 📝 블로그 포스팅 스터디 리뷰 저장소

📝 블로그 포스팅 스터디 리뷰 저장소. Contribute to cmg1411/posting-review development by creating an account on GitHub.

github.com

 

 

 

 

 

 

이전에 스터디에서 Interceptor 에서 공통 헤더, 공통 바디 처리하는 부분을 발표했다.

발표때의 질문 중, Interceptor 에서 노출되는 정보를 관리하는 방법이 있었다. 마스킹 이외의 노하우를 물어 보셨는데, 사실 마스킹이라는 작업도 해본 적이 없어 대답을 잘 못했다 ㅠ..

그래서 일단 마스킹을 한번 해보자!

 

에너테이션 기반의 마스킹

에너테이션에 어떤 방식으로 마스킹을 진행할 지 정보를 받아서,

직렬화시 에너테이션의 정보를 읽어와서 마스킹을 진행한다.



마스킹 에너테이션

@Target(AnnotationTarget.FIELD, AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class NeedMasking(
    val masker: KClass<out (String) -> String>
)

 

KClass : 코틀린의 Class 타입. 여러가지 정보를 얻을 수 있다.

에너테이션의 필드의 타입으로 가능하다.

 

(String) -> String 과 같은 코틀린에서의 고차함수는, 컴파일 타임에 Function0 (인자가 없는 인터페이스), Function1<T, R> (인자가 하나인 인터페이스) 로 변환되는 타입 이다.




직렬화 방법 정의

이제 직렬화를 진행할 때, 위에서 정의한 @NeedMasking 이 있을 때, 에너테이션 정보를 읽어와서 해당 정보로 직렬화를 진행하는 로직을 만들어야 한다.

이 때 사용할 두 인터페이스가 있다. ContextualSerializer, StdSerializer(이건 추상 클래스이다.)



ContextualSerializer

프로퍼티를 핸들링하기 위해 문맥상 (로직상) 적절한 serializer 를 만드는 콜백 함수를 구현할 수 있는 JsonSerializer 의 Add-on interface 이다. 에너테이션을 이용한 serializer 설정 또는 프로퍼티의 종류에 따라 다른 직렬화 동작을 취하고 싶을 때 유용하다.

콜백 함수

  1. 다른 함수의 인자로써 이용되는 함수.
  2. 어떤 이벤트에 의해 호출되어지는 함수.

위 해석은

발 해석이지만

ContextualSerializer 에 달려있는 주석이다.

사용된 코드들을 살펴보면, JsonValueSerializer, MapSerializer, StringArraySerializer 등 여러 곳에서 사용되고 있으며, 각 Serializer 를 고르도록 하는 코드가 들어 있다.

우리도 마스킹 Serializer 에 implement 하여, @NeedMasking 에너테이션이 달려 있으면 우리의 Serializer 를 사용해라 라는 조건을 명시 할 것이다.



StdSerializer

커스텀한 Serializer 를 만드려면 StdSerializer 를 상속받아, serialize 메서드를 오버라이딩하면 된다.



직렬화 클래스 만들기

class MaskingSerializer(
        val masker: (String) -> String = { s -> s }
) : StdSerializer<String>(String::class.java), ContextualSerializer {

    override fun serialize(value: String, gen: JsonGenerator, provider: SerializerProvider) {
            // 직렬화 시 masker 를 적용한 값을 넘겨줌
        gen.writeString(masker(value))
    }


    override fun createContextual(provider: SerializerProvider, property: BeanProperty): JsonSerializer<*> {
        val ann = property.getAnnotation(NeedMasking::class.java)
        if (ann != null) {
                // KClass 가 Object 타입이면 인스턴스를 가져오고, 일반 클래스면 인스턴스 생성
            val masker = ann.masker.objectInstance ?: ann.masker.createInstance()
            // @NeedMasking 에너테이션이 있다면, 에너테이션의 masker (마스킹 방법) 정보를 포함한 Serializer 객체 반환.
            return MaskingSerializer(masker)
        }
        return provider.findKeySerializer(property.type, property)
    }
}

masker 는 (String) -> String 타입이고, masker(value) 와 같이 호출하였는데,

코틀린에서 람다는 Function0 과 같은 인터페이스로 컴파일타임에 바뀐다고 했다.

이 FunctionN 과 같은 인터페이스는 공통적으로 invoke() 를 가지고 있다.

코틀린에서 invoke operator 는 특별한 연산자로, 메서드 생략 가능한 메서드이다.




테스트 하기!

@SpringBootTest
class MaskingTest{

    @Autowired
    private lateinit var objectMapper: ObjectMapper

    object PhoneMasker : (String) -> String {
        override operator fun invoke(s: String): String = Regex("(?<=.{3}).").replace(s, "*")
    }

    data class User(
        @JsonSerialize(using = MaskingSerializer::class)
        @NeedMasking(masker = PhoneMasker::class)
        val phone = ""
    )

    @Test
    fun `전화번호_마스킹_테스트`() {
        val user = User("01012345678")
        val jsonStr = objectMapper.writeValueAsString(user)

        assertEquals("""{"phone":"010********"}""".trimIndent(), jsonStr)
    }
}




리펙토링 : JacksonAnnotationIntrospector 사용

introspect : 자기 분석 뭐 그런 뜻 인 듯.

우리는 위에서 마스킹해서 직렬화 해야하는 프로퍼티에 @JsonSerialize(using = MaskingSerializer::class) 를 선언하여, 직렬화시 사용할 Serializer 를 선언해줬다.

Jackson 에 미리 @NeedMasking 에너테이션이 붙은 곳에는 MaskingSerializer 를 사용하도록 알려준다면, @JsonSerialize(using = MaskingSerializer::class) 를 매번 명시하지 않아도 될 것이다.

 

AnnotationIntrospector github 설명

위 사이트의 설명에 따르면, 에너테이션이 붙어 있는 곳에서 직렬화/역직렬화를 해주는 SerializerFactory 를 적용해주는 역할을 한다고 한다. (

역시나 영어가 약하기에 들어가서 직접 보시길..

). 또한, 직접적으로 사용되지 않고 ObjectMapper 에 등록하면 사용할 수 있다고 한다.

 

AnnotationIntrospector 를 상속한 JacksonAnnotationIntrospector 은 Jackson 라이브러리가 직렬화/역직렬화시 annotation 정보를 이용하게 하는 라이브러리이다.

    JacksonAnnotationIntrospector

    private final static Class<? extends Annotation>[] ANNOTATIONS_TO_INFER_SER = (Class<? extends Annotation>[])
            new Class<?>[] {
        JsonSerialize.class,
        JsonView.class,
        JsonFormat.class,
        JsonTypeInfo.class,
        JsonRawValue.class,
        JsonUnwrapped.class,
        JsonBackReference.class,
        JsonManagedReference.class
    };


    @Override
    public JsonFormat.Value findFormat(Annotated ann) {
        JsonFormat f = _findAnnotation(ann, JsonFormat.class);
        // NOTE: could also just call `JsonFormat.Value.from()` with `null`
        // too, but that returns "empty" instance
        return (f == null)  ? null : JsonFormat.Value.from(f);
    }

// @JsonFormat 에너테이션을 가져와서 직렬화/역직렬화 시 해당 에너테이션의 정보를 이용할 수 있게 해준다.

 

우리가 하고 싶은 건, @JsonSerailize 가 붙은 에너테이션의 처리이다. 이는 findSerializer() 라는 메서드를 사용하면 된다.

재대로 한번 만들어 보자.



  1. custom Introspector 를 만든다.
class MaskingIntrospector: JacksonAnnotationIntrospector() {

    override fun findSerializer(a: Annotated): Any? {
        val ann = a.getAnnotation(NeedMasking::class.java)
        if (ann != null) {
            return MaskingSerializer(ann.masker.objectInstance ?: ann.masker.createInstance())
        }
        return super.findSerializer(a)
    }
}
  1. 이젠 custom Serializer 에 createContextual 는 없어도 된다.
class MaskingSerializer(
        val masker: (String) -> String = { s -> s }
) : StdSerializer<String>(String::class.java) {

    override fun serialize(value: String, gen: JsonGenerator, provider: SerializerProvider) {
            // 직렬화 시 masker 를 적용한 값을 넘겨줌
        gen.writeString(masker(value))
    }
}
  1. Introspector 를 ObjectMapper 에 등록 한 후, 테스트 코드에서 @JsonSerailze 부분을 뺀다.
@SpringBootTest
class UserTest{

    @Autowired
    private lateinit var objectMapper: ObjectMapper

    @BeforeEach
    fun `introspector_설정`() {
        objectMapper.setAnnotationIntrospector(MaskingIntrospector())
    }

    object NameMasker : (String) -> String {
        override operator fun invoke(s: String): String = Regex("(?<=.{3}).").replace(s, "*")
    }

    data class User(
        @NeedMasking(masker = NameMasker::class)
        val phone: String = ""
    )

    @Test
    fun `simple masking`() {
        val user = User("01045692804")
        val jsonStr = objectMapper.writeValueAsString(user)
        assertEquals("""{"phone":"010********"}""".trimIndent(), jsonStr)
    }
}



https://rutesun.github.io/development/annotation-driven-masking/


이슈사항 : KotlinAnnotationIntrospector

Kotlin + Spring boot 를 사용한다면, jackson.module.kotlin 를 많이들 사용할 것이다.

이를 사용하면, 기본 생성자 없이 @RequestBody 에서 Json 을 객체로 역직렬화 할 수 있다.

jackson-kotlin-module 에서 해당 역할을 해주는 것이 KotlinAnnotationIntrospector 이다.

 

위 마스킹 예외와 같이 새로운 AnnotationIntrospector 를 등록하면, KotlinAnnotationIntrospector 가 무시되어 기본생성자 없이는 @RequestBody 객체를 만들지 못하는 에러가 발생했다.

해결책으로, AnnotationIntrospector github 설명 에도 나와 있듯이, AnnotationIntrospector.pair() 를 이용하면 두 개의 Introspector 를 등록할 수 있다.

val obejctMapper = ObjectMapper()
val originAnnotationIntrospector = obejctMapper.serializationConfig.annotationIntrospector
mapper.setAnnotationIntrospector(
    AnnotationIntrospector.pair(MaskingIntrospector(), originAnnotationIntrospector)
)




이슈의 이슈사항 : version

위의 방법으로 해결을 하려고 했으나, 문제가 있었다.

is 로 시작하는 프로퍼티에서 직/역직렬화 문제 - version 2.10.1

예를 들어, isMale 이라는 json 을 역직렬화시 is 를 때버린 male 라고 들어오는 문제였다.

직렬화 시에도 마찬가지로, isFemale 라는 프로퍼티가 female 이라는 json 으로 직렬화가 된다.

 

@get:JsonProperty 를 명시적으로 붙여 주면 문제 자체야 해결된다.

또는 jackson-kotlin-module 의 버전을 올리면 문제는 해결 된다.

하지만 클라이언트와의 호환성 때문에, 지금 당장 바꾸긴 힘들어 보여, 천천히 바꾸기로 했다.

 

 

 

 

 

 

 

 

추가 : 비밀번호 마스킹

팀원의 리뷰로, 비밀번호 마스킹을 해보면 좋겠다는 말을 들었다.

비밀번호를 암호화해서 마스킹하기 위해 salt 값을 랜덤 추출해서 

키 스트레칭에 사용하고 싶었다.

 

또 키 스트레칭에 쓴 salt 값은 사용자 별로 저장하기 위해 값을 따로 빼 와야 했다.

아래 코드는 json 직렬화 시 salt 라는 키 값을 추가해서 값을 써버렸다.

그 쓴 값을 읽어와서 테스트를 다시 해볼 수 있었다.

 

물론 마스킹한 비밀번호를 DB 에 저장하지는 않아, 마스킹 시 필요한 salt 값을 가져올 필요는 없을 것 같다.

어쩌면 비밀번호 마스킹도 **** 이렇게 하고 로그만 찍고, 암호화는 인터셉터단이 아니라 뒷단에서 할 것 같기도 하다.

아니면 spring security 를 사용하는게 제일 맘 편하기도 할 것 같다.

 

import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.databind.SerializerProvider
import com.fasterxml.jackson.databind.introspect.Annotated
import com.fasterxml.jackson.databind.introspect.JacksonAnnotationIntrospector
import com.fasterxml.jackson.databind.ser.std.StdSerializer
import java.security.MessageDigest
import java.util.*
import kotlin.reflect.KClass
import kotlin.reflect.full.createInstance

@Target(AnnotationTarget.FIELD, AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class NeedMasking(
    val masker: KClass<out (String) -> String>
)

@Target(AnnotationTarget.FIELD, AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class PasswordMasking(
    val masker: KClass<out (String, String) -> String>
)

class MaskingSerializer(
    val masker: (String) -> String = { s -> s }
) : StdSerializer<String>(String::class.java) {

    override fun serialize(value: String, gen: JsonGenerator, provider: SerializerProvider) {
        gen.writeString(masker(value))
    }
}

class PasswordMaskingSerializer(
    val masker: (String, String) -> String = { s, _ -> s }
) : StdSerializer<String>(String::class.java) {

    override fun serialize(value: String, gen: JsonGenerator, provider: SerializerProvider) {

        val randomSalt = UUID.randomUUID().toString()
        gen.writeString(masker(value, randomSalt))

        gen.writeFieldName("salt")
        gen.writeObject(randomSalt)
    }
}

class MaskingIntrospector: JacksonAnnotationIntrospector() {

    override fun findSerializer(a: Annotated): Any? {
        val ann = a.getAnnotation(NeedMasking::class.java)
        val passAnn = a.getAnnotation(PasswordMasking::class.java)
        return when {
            ann != null -> MaskingSerializer(ann.masker.objectInstance ?: ann.masker.createInstance())
            passAnn != null -> PasswordMaskingSerializer(passAnn.masker.objectInstance ?: passAnn.masker.createInstance())
            else -> super.findSerializer(a)
        }
    }
}

object NameMasker : (String) -> String {
    override operator fun invoke(s: String): String = Regex("(?<=.{3}).").replace(s, "*")
}

object PasswordMasker : (String, String) -> String {

    override fun invoke(s: String, salt: String): String {
        val md = MessageDigest.getInstance("SHA-256")

        var pw = s.encodeToByteArray()

        for (x in 1..10000) {
            val temp = pw.toHex() + salt
            md.update(temp.encodeToByteArray())
            pw = md.digest()
        }

        return pw.toHex()
    }
}

fun ByteArray.toHex(): String = joinToString(separator = "") { "%02x".format(it) }
@SpringBootTest
class UserTest{

    @Autowired
    private lateinit var objectMapper: ObjectMapper

    @BeforeEach
    fun `introspector_설정`() {
        objectMapper.setAnnotationIntrospector(MaskingIntrospector())
    }

    data class User(
        @NeedMasking(masker = NameMasker::class)
        val phone: String = "",
        @PasswordMasking(masker = PasswordMasker::class)
        val password: String = "",
    )

    @Test
    fun `simple masking`() {
        // given
        val user = User("01045692804", "123123")

        // when
        val jsonStr = objectMapper.writeValueAsString(user)

        // then
        val salt = objectMapper.readValue(jsonStr, Map::class.java)["salt"] as String
        val pw = PasswordMasker("123123", salt = salt)
        assertEquals("""{"phone":"010********","password":"$pw","salt":"$salt"}""".trimIndent(), jsonStr)
    }
}

// https://d2.naver.com/helloworld/318732

'spring' 카테고리의 다른 글

@Async 예외처리  (1) 2022.03.02
spring interceptor  (0) 2021.10.30
빈 스코프  (0) 2021.03.21
스프링 빈의 생명주기와 초기화 분리  (0) 2021.03.20
자동 주입시 빈이 2개 이상일 때 문제 해결  (0) 2021.03.19