-
Notifications
You must be signed in to change notification settings - Fork 0
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
Changes from all commits
891a8a2
f90c01c
9ed3705
9448e0b
d671dee
6e0049b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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,22 @@ | ||
package com.snackgame.server.game.sign.domain | ||
|
||
import java.security.KeyFactory | ||
import java.security.PrivateKey | ||
import java.security.spec.PKCS8EncodedKeySpec | ||
import java.util.* | ||
|
||
class PKCS8PEMKey( | ||
private val rawPEM: String | ||
) { | ||
|
||
fun toPrivateKey(): PrivateKey { | ||
val base64EncodedKey = rawPEM.replace("\n", "") | ||
.removePrefix("-----BEGIN PRIVATE KEY-----") | ||
.removeSuffix("-----END PRIVATE KEY-----") | ||
|
||
return Base64.getDecoder().decode(base64EncodedKey).let { | ||
val keyFactory = KeyFactory.getInstance("RSA") | ||
keyFactory.generatePrivate(PKCS8EncodedKeySpec(it)) | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,20 @@ | ||
package com.snackgame.server.game.sign.domain | ||
|
||
import com.fasterxml.jackson.databind.ObjectMapper | ||
import io.jsonwebtoken.Jwts | ||
import java.security.PrivateKey | ||
|
||
class Signer(private val privateKey: PrivateKey) { | ||
|
||
fun sign(content: String): String { | ||
runCatching { ObjectMapper().readTree(content) } | ||
.onFailure { throw Exception("Could not parse JSON") } | ||
|
||
return Jwts.builder() | ||
.header().keyId(privateKey.hashCode().toString()) | ||
.and() | ||
.content(content) | ||
.signWith(privateKey) | ||
.compact() | ||
} | ||
} |
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> { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 선요약: 최소한의 접점만으로 문제를 가장 직접적으로 해결하는 방법이다 이것도 풀자면 긴 스토리긴 한데요. 원래 HandlerMethodReturnValueHandler 혹은 HandlerInterceptor를 사용하려고 했습니다. (둘 다 실패) 전자는 후자는 HttpServletResponse의 body를 조작해야 하는데, 이 body는 이미 String이 된 상태이므로 조작이 조금 복잡해지며 부작용의 우려가 있습니다. ResponseBodyAdvice 인터페이스는 이름처럼 이 친구의 작동방식을 고려해보면 - 실제로 MessageConverter가 바디를 변환하기 직전!에 작동하므로, 변환하고자 하는 값을 직접적으로(타입도 살아있는 상태로) 받고 제어할 수 있게 됩니다. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,93 @@ | ||
@file:Suppress("NonAsciiCharacters") | ||
|
||
package com.snackgame.server.game.sign.domain | ||
|
||
import org.assertj.core.api.Assertions.assertThat | ||
import org.junit.jupiter.api.Test | ||
|
||
class PKCS8PEMKeyTest { | ||
|
||
@Test | ||
fun `PKCS#8 형식의 PEM 키를 프라이빗 키로 변환한다`() { | ||
val pemKey = PKCS8PEMKey(RAW_PKCS8_PEM) | ||
|
||
val privateKey = pemKey.toPrivateKey() | ||
|
||
assertThat(privateKey.encoded).isEqualTo(KEY_IN_BYTES) | ||
} | ||
|
||
companion object { | ||
private const val RAW_PKCS8_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----- | ||
""" | ||
val KEY_IN_BYTES = """ | ||
308204bb020100300d06092a864886f70d0101010500048204a5308204a1020100 | ||
0282010100bddaa71fa7081400320b423460ad875d258390adaa60116ed40ebad | ||
c8b3275d5d8b7e957409c079085f14a3c49d9f8a86ad971895e5e42282aa56962 | ||
65d57871909b4542d047cd53e0fd392ba4c6f66729bdc928c79e44c539b96587 | ||
91aa154d89e7bdefc160079bcd6c46d0037188dee3e97a50c5a00f73fee09557 | ||
7ab85e1adabc23829da7e78fba3837332ce48ebee6741bd30f7478554eac1417 | ||
310e0e0e9037340c3651bc127e9f3563b7ba402e23afb2461e2a8a966131d7a4 | ||
e3337a1fac896a228efec14a51472f9dea8efda8ae138a035ba0c31a318c9dab | ||
c39adf24ccb5832b9ebd7cec0bdc1713a679a712ac91fed5e4ae9e4a0c024d5d | ||
4ec1106d02030100010282010062d9172ea9bed0fb11133f9319caffb185b8ac | ||
795f3dc995d49f1eb77959fa742ef4026fc559f7ae4daec5a202299507cb2a0f | ||
ad200f1f8ede35655b2ffe2749a9aace32ed52951b59073edef45a0b2cc754e3 | ||
0972757b04dcfb245ba381d4b99c55a654d06ff9462b7c769a1350e79c7539b8 | ||
cf9935af160bdc4ea54f65f2c84eb879ab0521567ecddb860f4c0383c12b26a6 | ||
e88e55ad84eed88d0d50a83bfe9aa234f8cb734a0e1507d371b145fdb0501050 | ||
e8eca42e37c910e9e352b746a9d1cd7b34ffa4cef45bb31f291c92c319d0e93c | ||
c8407d1fc70248d80c78b7b46bb480e75718429e78132f39cb9a5e7b7a9479ce | ||
e92a7b2ef9fab01525c388b10102818100e897ab2078ab0699f2d8552421b4c2 | ||
3be7304639e2fe3341253ab02a42d717c6476d400f6f3112f630c6f2d6fc7743 | ||
f74bfa795aea12ebb0feef62ce96ce8866c243e92853b08da9dc0eecf00063d6 | ||
e4e7bb852335c204bd1a60ee96b941ae3953561c0c60c012f42ad0f6cfba1d83 | ||
489891fa432ffdc118b1ac588e2d6c660d02818100d0f5e828c642383685cce1 | ||
3e09eb92ad68cfb5da8e5a4d38cd22dae2d1c0ed8ee49fe1cf11caaf8d6b229b | ||
2863465aa73f2d733055b5cbf8a3fdbdcdf7539a666de2ac65f607675717f7bb | ||
8af0aaaa8c961c22ef3411c9df17dcaa4925a04a89aa2a0fb0221cdfb8e03184 | ||
35112e773d363189f5c5f5803a6b1c6226cc981be102818100d4c0df248d9c0b | ||
26c8aebf7a94664e8112e0e26bd2c9a8c27f3666abd18604563741a4a639944e | ||
fee8f122722a6f41b9344381bbfa7ea21ebda2e4e389a893b6431e716894dcdb | ||
48336567afc4fb8e36c60d8e23fde9620243ddc4801427132196ac1e6de815e9 | ||
8074a5be0d96f5a5ffc2a74c9f2687db9825f53aef9a771c05027f2b42bdcdb3 | ||
3e0265474e2353b4ba10a3d60f2a9714e19d1b255d11f3e3790db5879f92204c | ||
8ea9c99d648cbacfd2c0f1df2289b678082b324ec1398a6aa9e44d50729d3e7e | ||
b109b3fe424b917841dd0d3e069534f092be3871b83d88e741482d380d7d31f9 | ||
de007345f6fa6802269c890faf31e0b01b2370f4bbeb961cc56102818077e522 | ||
120bd4e5be8514cce9ce9e240c9dfe459684115ab08a0a22f10ce31a4bff3d5f | ||
1d039c39fdf245796f19d1376f537f6b9cdb1539359ded55bebcf7f2f0c828a0 | ||
a1fef53c28660aaa0747a2c398f0eb83b5da1eb8e2b59802f31884a638483a4a | ||
c988862791543f8bf3fb723c11a6c42fd8a4f1fc5003aa69b5fa8204ae""" | ||
.replace("\n", "") | ||
.chunked(2) | ||
.map { it.toInt(16).toByte() } | ||
.toByteArray() | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,75 @@ | ||
@file:Suppress("NonAsciiCharacters") | ||
|
||
package com.snackgame.server.game.sign.domain | ||
|
||
import com.fasterxml.jackson.databind.ObjectMapper | ||
import org.assertj.core.api.Assertions.assertThat | ||
import org.assertj.core.api.Assertions.assertThatThrownBy | ||
import org.junit.jupiter.api.Test | ||
|
||
class SignerTest { | ||
|
||
private val signer = Signer(PRIVATE_KEY) | ||
|
||
@Test | ||
fun `개인키를 사용해 객체를 JWT로 서명한다`() { | ||
data class TestObject(val key: String) | ||
|
||
val testObjectAsJson = TestObject("value") | ||
.let { ObjectMapper().writeValueAsString(it) } | ||
|
||
val jwtPayload = signer.sign(testObjectAsJson) | ||
.substringAfter('.') | ||
.substringBeforeLast('.') | ||
|
||
assertThat(jwtPayload).asBase64Decoded().asString() | ||
.isEqualTo(testObjectAsJson) | ||
} | ||
|
||
@Test | ||
fun `개인키의 hashCode를 keyId로 사용하여 서명한다`() { | ||
val jwtHeader = signer.sign("") | ||
.substringBefore('.') | ||
assertThat(jwtHeader).asBase64Decoded().asString().contains(""""kid":"${PRIVATE_KEY.hashCode()}"""") | ||
} | ||
|
||
@Test | ||
fun `서명할 데이터는 JSON이어야한다`() { | ||
assertThatThrownBy { signer.sign("{") } | ||
.hasMessage("Could not parse JSON") | ||
} | ||
|
||
companion object { | ||
private val PRIVATE_KEY = PKCS8PEMKey( | ||
"""-----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----- | ||
""" | ||
).toPrivateKey() | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
keyId
를 추가하는 것은 무엇을 위한 작업인가요??There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
이 부분은 스낵게임 Biz 문서 하단을 보시면 이해가 쉬울 것 같은데요.
RSASHA로 서명된 JWT는 공개키인 JWK로 검증이 가능합니다.
이 JWK를 여러개 묶어놓은게 JWKS(JSON Web Key Set)인데요.
그 키들 중에서 해당 JWT에 맞는 공개키를 찾는데 kid를 사용할 수 있습니다.
이렇게 되면 '키 순환'이 가능해진다는 장점도 있습니다.
만일 기존 키페어의 유출/분실 사고가 발생한다면 키셋에서 해당 키를 제거하고 새 키페어를 등록해줄 수 있습니다.
그러면 기존 JWT는 검증에 실패하고, 새 키페어로 서명된 JWT는 검증이 잘 되겠죠?
이런 장점들 때문에 서명에 사용한 keyId를 적고, 키를 관리하는 것이 좋습니다.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
아하 이해했습니다! 자세한 설명 감사드립니다 🫢