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(snackgame.biz): 게임 결과를 서명한다 #179

Merged
merged 6 commits into from
Oct 10, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.snackgame.server.game.sign.config

import com.snackgame.server.game.sign.domain.PKCS8PEMKey
import com.snackgame.server.game.sign.domain.Signer
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration

@Configuration
class SignerConfig(
@Value("\${game.signing.private-key-pem}")
privateKeyPem: String
) {
private val privateKey = PKCS8PEMKey(privateKeyPem).toPrivateKey()

@Bean
fun signer() = Signer(privateKey)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.snackgame.server.game.sign.service

import com.fasterxml.jackson.databind.ObjectMapper
import com.snackgame.server.game.sign.domain.Signer
import com.snackgame.server.game.sign.service.dto.SignedResponse
import org.springframework.core.MethodParameter
import org.springframework.http.MediaType
import org.springframework.http.converter.HttpMessageConverter
import org.springframework.http.server.ServerHttpRequest
import org.springframework.http.server.ServerHttpResponse
import org.springframework.web.bind.annotation.RestControllerAdvice
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice

@RestControllerAdvice
class ResponseSigner(
val signer: Signer,
val objectMapper: ObjectMapper
) : ResponseBodyAdvice<Any> {
Copy link
Collaborator

Choose a reason for hiding this comment

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

ResponseBodyAdvice가 공통된 응답을 위해 쓰인다고 하는데 지금처럼 DTO를 만들어서 하던 방법과 차이점이나 ResponseBodyAdvice를 쓰게 된 이유가 있을까요??

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

선요약: 최소한의 접점만으로 문제를 가장 직접적으로 해결하는 방법이다

이것도 풀자면 긴 스토리긴 한데요.
우선 목적은 최대한 기존 코드를 건드리지 않고 반환값을 서명하는 것입니다. (서명에 대한 관심사 분리)
물론 멋있다는 점도 한 몫 합니다 ㅋ.ㅋ

원래 HandlerMethodReturnValueHandler 혹은 HandlerInterceptor를 사용하려고 했습니다. (둘 다 실패)

전자는 @ResponseBody에 대한 ReturnValueHandler가 이미 있기 때문에 제가 새로 추가한 핸들러가 실행되지 않아서 실패했구용

후자는 HttpServletResponse의 body를 조작해야 하는데, 이 body는 이미 String이 된 상태이므로 조작이 조금 복잡해지며 부작용의 우려가 있습니다.
이것보다 더 직접적인 방법(ResponseBodyAdvice)가 있어 이걸 선택했습니다.

ResponseBodyAdvice 인터페이스는 이름처럼 @ResponseBody를 처리하는데 참고자료의 역할을 할 수 있습니다. (Spring API 문서)
말씀해주신 목적으로도 사용하긴 하는 것 같은데,
특별히 '공통된 응답을 만드는데 사용하라' 같은 의도는 없는 것으로 보입니다.
그래서 앞서 말씀드린 목적으로 사용할 수 있다고 생각했구요,

이 친구의 작동방식을 고려해보면 - 실제로 MessageConverter가 바디를 변환하기 직전!에 작동하므로, 변환하고자 하는 값을 직접적으로(타입도 살아있는 상태로) 받고 제어할 수 있게 됩니다.
이 틈에 기존 객체에 서명하고 감쌈으로써 최소한의 접점만으로 원하는 목적을 달성하는 AOP를 할 수 있게 됩니다.

Copy link
Collaborator

Choose a reason for hiding this comment

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

역시 땡칠... 어노테이션만으로 처리할 수 있게 된게 인상적입니다..
오늘도 하나배워갑니다 ☺️


override fun supports(returnType: MethodParameter, converterType: Class<out HttpMessageConverter<*>>): Boolean {
return returnType.hasMethodAnnotation(Signed::class.java)
}

override fun beforeBodyWrite(
body: Any?,
returnType: MethodParameter,
selectedContentType: MediaType,
selectedConverterType: Class<out HttpMessageConverter<*>>,
request: ServerHttpRequest,
response: ServerHttpResponse
): Any? {
return body?.let {
val jsonContent = objectMapper.writeValueAsString(it)
SignedResponse(it, signer.sign(jsonContent))
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.snackgame.server.game.sign.service

@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.FUNCTION)
internal annotation class Signed
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.snackgame.server.game.sign.service.dto

data class SignedResponse(
val original: Any,
val signed: String
)
30 changes: 30 additions & 0 deletions src/main/resources/application-local.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,33 @@ security:
access-expiry-days: 7
refresh-secret-key: snackgamelocalrefreshsecretkey12345
refresh-expiry-days: 30
game.signing:
private-key-pem: |
-----BEGIN PRIVATE KEY-----
MIIEuwIBADANBgkqhkiG9w0BAQEFAASCBKUwggShAgEAAoIBAQC92qcfpwgUADIL
QjRgrYddJYOQrapgEW7UDrrcizJ11di36VdAnAeQhfFKPEnZ+Khq2XGJXl5CKCql
aWJl1XhxkJtFQtBHzVPg/TkrpMb2Zym9ySjHnkTFObllh5GqFU2J573vwWAHm81s
RtADcYje4+l6UMWgD3P+4JVXerheGtq8I4Kdp+ePujg3Myzkjr7mdBvTD3R4VU6s
FBcxDg4OkDc0DDZRvBJ+nzVjt7pALiOvskYeKoqWYTHXpOMzeh+siWoijv7BSlFH
L53qjv2orhOKA1ugwxoxjJ2rw5rfJMy1gyuevXzsC9wXE6Z5pxKskf7V5K6eSgwC
TV1OwRBtAgMBAAECggEAYtkXLqm+0PsREz+TGcr/sYW4rHlfPcmV1J8et3lZ+nQu
9AJvxVn3rk2uxaICKZUHyyoPrSAPH47eNWVbL/4nSamqzjLtUpUbWQc+3vRaCyzH
VOMJcnV7BNz7JFujgdS5nFWmVNBv+UYrfHaaE1DnnHU5uM+ZNa8WC9xOpU9l8shO
uHmrBSFWfs3bhg9MA4PBKyam6I5VrYTu2I0NUKg7/pqiNPjLc0oOFQfTcbFF/bBQ
EFDo7KQuN8kQ6eNSt0ap0c17NP+kzvRbsx8pHJLDGdDpPMhAfR/HAkjYDHi3tGu0
gOdXGEKeeBMvOcuaXnt6lHnO6Sp7Lvn6sBUlw4ixAQKBgQDol6sgeKsGmfLYVSQh
tMI75zBGOeL+M0ElOrAqQtcXxkdtQA9vMRL2MMby1vx3Q/dL+nla6hLrsP7vYs6W
zohmwkPpKFOwjancDuzwAGPW5Oe7hSM1wgS9GmDulrlBrjlTVhwMYMAS9CrQ9s+6
HYNImJH6Qy/9wRixrFiOLWxmDQKBgQDQ9egoxkI4NoXM4T4J65KtaM+12o5aTTjN
Itri0cDtjuSf4c8Ryq+NayKbKGNGWqc/LXMwVbXL+KP9vc33U5pmbeKsZfYHZ1cX
97uK8KqqjJYcIu80EcnfF9yqSSWgSomqKg+wIhzfuOAxhDURLnc9NjGJ9cX1gDpr
HGImzJgb4QKBgQDUwN8kjZwLJsiuv3qUZk6BEuDia9LJqMJ/Nmar0YYEVjdBpKY5
lE7+6PEicipvQbk0Q4G7+n6iHr2i5OOJqJO2Qx5xaJTc20gzZWevxPuONsYNjiP9
6WICQ93EgBQnEyGWrB5t6BXpgHSlvg2W9aX/wqdMnyaH25gl9TrvmnccBQJ/K0K9
zbM+AmVHTiNTtLoQo9YPKpcU4Z0bJV0R8+N5DbWHn5IgTI6pyZ1kjLrP0sDx3yKJ
tngIKzJOwTmKaqnkTVBynT5+sQmz/kJLkXhB3Q0+BpU08JK+OHG4PYjnQUgtOA19
MfneAHNF9vpoAiaciQ+vMeCwGyNw9LvrlhzFYQKBgHflIhIL1OW+hRTM6c6eJAyd
/kWWhBFasIoKIvEM4xpL/z1fHQOcOf3yRXlvGdE3b1N/a5zbFTk1ne1Vvrz38vDI
KKCh/vU8KGYKqgdHosOY8OuDtdoeuOK1mALzGISmOEg6SsmIhieRVD+L8/tyPBGm
xC/YpPH8UAOqabX6ggSu
-----END PRIVATE KEY-----
31 changes: 31 additions & 0 deletions src/main/resources/application-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,34 @@ security:
access-expiry-days: 7
refresh-secret-key: testrefreshsecretkey1234567890
refresh-expiry-days: 30

game.signing:
private-key-pem: |
-----BEGIN PRIVATE KEY-----
MIIEuwIBADANBgkqhkiG9w0BAQEFAASCBKUwggShAgEAAoIBAQC92qcfpwgUADIL
QjRgrYddJYOQrapgEW7UDrrcizJ11di36VdAnAeQhfFKPEnZ+Khq2XGJXl5CKCql
aWJl1XhxkJtFQtBHzVPg/TkrpMb2Zym9ySjHnkTFObllh5GqFU2J573vwWAHm81s
RtADcYje4+l6UMWgD3P+4JVXerheGtq8I4Kdp+ePujg3Myzkjr7mdBvTD3R4VU6s
FBcxDg4OkDc0DDZRvBJ+nzVjt7pALiOvskYeKoqWYTHXpOMzeh+siWoijv7BSlFH
L53qjv2orhOKA1ugwxoxjJ2rw5rfJMy1gyuevXzsC9wXE6Z5pxKskf7V5K6eSgwC
TV1OwRBtAgMBAAECggEAYtkXLqm+0PsREz+TGcr/sYW4rHlfPcmV1J8et3lZ+nQu
9AJvxVn3rk2uxaICKZUHyyoPrSAPH47eNWVbL/4nSamqzjLtUpUbWQc+3vRaCyzH
VOMJcnV7BNz7JFujgdS5nFWmVNBv+UYrfHaaE1DnnHU5uM+ZNa8WC9xOpU9l8shO
uHmrBSFWfs3bhg9MA4PBKyam6I5VrYTu2I0NUKg7/pqiNPjLc0oOFQfTcbFF/bBQ
EFDo7KQuN8kQ6eNSt0ap0c17NP+kzvRbsx8pHJLDGdDpPMhAfR/HAkjYDHi3tGu0
gOdXGEKeeBMvOcuaXnt6lHnO6Sp7Lvn6sBUlw4ixAQKBgQDol6sgeKsGmfLYVSQh
tMI75zBGOeL+M0ElOrAqQtcXxkdtQA9vMRL2MMby1vx3Q/dL+nla6hLrsP7vYs6W
zohmwkPpKFOwjancDuzwAGPW5Oe7hSM1wgS9GmDulrlBrjlTVhwMYMAS9CrQ9s+6
HYNImJH6Qy/9wRixrFiOLWxmDQKBgQDQ9egoxkI4NoXM4T4J65KtaM+12o5aTTjN
Itri0cDtjuSf4c8Ryq+NayKbKGNGWqc/LXMwVbXL+KP9vc33U5pmbeKsZfYHZ1cX
97uK8KqqjJYcIu80EcnfF9yqSSWgSomqKg+wIhzfuOAxhDURLnc9NjGJ9cX1gDpr
HGImzJgb4QKBgQDUwN8kjZwLJsiuv3qUZk6BEuDia9LJqMJ/Nmar0YYEVjdBpKY5
lE7+6PEicipvQbk0Q4G7+n6iHr2i5OOJqJO2Qx5xaJTc20gzZWevxPuONsYNjiP9
6WICQ93EgBQnEyGWrB5t6BXpgHSlvg2W9aX/wqdMnyaH25gl9TrvmnccBQJ/K0K9
zbM+AmVHTiNTtLoQo9YPKpcU4Z0bJV0R8+N5DbWHn5IgTI6pyZ1kjLrP0sDx3yKJ
tngIKzJOwTmKaqnkTVBynT5+sQmz/kJLkXhB3Q0+BpU08JK+OHG4PYjnQUgtOA19
MfneAHNF9vpoAiaciQ+vMeCwGyNw9LvrlhzFYQKBgHflIhIL1OW+hRTM6c6eJAyd
/kWWhBFasIoKIvEM4xpL/z1fHQOcOf3yRXlvGdE3b1N/a5zbFTk1ne1Vvrz38vDI
KKCh/vU8KGYKqgdHosOY8OuDtdoeuOK1mALzGISmOEg6SsmIhieRVD+L8/tyPBGm
xC/YpPH8UAOqabX6ggSu
-----END PRIVATE KEY-----
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
@file:Suppress("NonAsciiCharacters")

package com.snackgame.server.game.sign.service

import com.snackgame.server.support.restassured.RestAssuredTest
import io.restassured.RestAssured
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController

@RestAssuredTest
class ResponseSignerTest {

@TestConfiguration
class TestConfig {

@RestController
class TestController {

@Signed
@GetMapping("/test")
fun test(): Any {
data class TestObject(val key: String)
return TestObject("value")
}
}
}

@Test
fun `Signed 어노테이션이 붙은 핸들러의 응답을 서명한다`() {
val responseBody = RestAssured.given()
.`when`().get("/test")
.then().extract().body().asString()

assertThat(responseBody).contains(
"""
"signed":"eyJhbGciOiJSUzI1NiJ9.eyJrZXkiOiJ2YWx1ZSJ9.Jrg2SkMT2c3LIMR_pIKmdEF2-3UyH1r8nS-
q43l5KmKN1hQve4EG18JD5btvGMfZEZu2vjwRmgP-ybhKBNt0-khZ8zBGh3YQ4FILkMETyS-7ObJD
6N_737BjVa0iMg7MKb6_enoTDJ_p-6_murR7Y1ujSGrGJWSvzdO9FooQTetc7LQorQXUhjk5Wm-ggQ
rMnTSTYlNPaSuXrtdVzB3jbMbC0jK-rcr0-sntFCvC2WCC_vRcr7aRZk72RVBI5EEDy5DW_kxIRrbT
dUSglBx5YPkCec9XKZtGKpFUhgWkyfKjEUgYgfg6e6AEzJPMuX5L2V6LKwBdn4TSkh4u3pyk-g"
""".replace("\\s+".toRegex(), "")
)
}
}