Springboot에서 Play Integrity API 사용하기 (2. Google 연동)
Java Kotlin/Spring SpringBoot
2025. 5. 12. 23:18
서론
졸업 프로젝트를 하다 React Native 환경의 클라이언트에서 앱 무결성 검증을 해야 하는 상황이 되었다.
이 기록은 앱 무결성 검증을 하며 헤맨 부분의 기록이다.
본론
구글의 Integrity API Decode 과정에는 서비스 계정이 필요하다.
https://playintegrity.googleapis.com/v1/$packageName:decodeIntegrityToken
에 Header로 Authorization: Bearer
를 넘겨줄 때, 본인의 계정을 사용할 필요가 없다. 오히려 사용해서는 안된다.
반드시 서비스 계정을 생성, 그 서비스 계정의 json을 이용해 AccessToken을 받아오도록 하자.
// implementation("com.google.auth:google-auth-library-oauth2-http:1.34.0")
@OptIn(DelicateCoroutinesApi::class)
@Scheduled(fixedRate = 3300000) // Refresh every 55 minutes
private fun refreshGoogleAccessToken() {
GlobalScope.launch {
try {
val credentials = GoogleCredentials.fromStream(ByteArrayInputStream(googleAccountJson.toByteArray()))
.createScoped("https://www.googleapis.com/auth/playintegrity")
credentials.refresh()
googleAccessToken = credentials.accessToken
} catch (e: Exception) {
throw RuntimeException("Failed to refresh Google access token: ${e.message}")
}
}
}
위의 코드로 AccessToken을 얻어올 수 있다. (googleAccountJson은 방금 받아온 서비스계정의 json 키 String이다.)
fun verifyAndroidIntegrity(challenge: String, nonce: String): IntegrityVerificationResponse {
return try {
val decodedTokenBytes = webClient.post()
.uri(decodeIntegrityTokenUrl)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(mapOf("integrity_token" to challenge))
.header("Authorization", "Bearer ${googleAccessToken?.tokenValue}")
.retrieve()
.bodyToMono(ByteArray::class.java)
.block()
if (decodedTokenBytes == null) {
return IntegrityVerificationResponse(
isValid = false,
message = "Decoded token is null"
)
}
val decodedTokenJson = String(decodedTokenBytes)
val decodeResponse: DecodeIntegrityTokenResponse = gson.fromJson(decodedTokenJson, DecodeIntegrityTokenResponse::class.java)
val tokenPayload = decodeResponse.tokenPayloadExternal
// Package Name Verification
if (tokenPayload.appIntegrity.packageName != packageName) {
return IntegrityVerificationResponse(
isValid = false,
message = "Package name mismatch",
details = mapOf(
"expected" to packageName,
"actual" to tokenPayload.requestDetails.requestPackageName
)
)
}
if (tokenPayload.requestDetails.nonce.replace("=", "").trim() !=
nonce.replace("=", "").trim()
) {
return IntegrityVerificationResponse(
isValid = false,
message = "Challenge mismatch",
details = mapOf(
"expected" to nonce,
"actual" to tokenPayload.requestDetails.nonce
)
)
}
// Timestamp Verification
if (isTokenExpired(tokenPayload.requestDetails.timestampMillis)) {
return IntegrityVerificationResponse(
isValid = false,
message = "Token has expired",
details = mapOf("timestamp" to tokenPayload.requestDetails.timestampMillis)
)
}
// App Integrity Verification
if (tokenPayload.appIntegrity.appRecognitionVerdict != "PLAY_RECOGNIZED") {
return IntegrityVerificationResponse(
isValid = false,
message = "App not recognized by Google Play",
details = mapOf("verdict" to tokenPayload.appIntegrity.appRecognitionVerdict)
)
}
// Device Integrity Verification
if (!tokenPayload.deviceIntegrity.deviceRecognitionVerdict.contains("MEETS_DEVICE_INTEGRITY")) {
return IntegrityVerificationResponse(
isValid = false,
message = "Device integrity check failed",
details = mapOf("verdicts" to tokenPayload.deviceIntegrity.deviceRecognitionVerdict)
)
}
IntegrityVerificationResponse(
isValid = true,
message = "Android app integrity verification successful"
)
} catch (e: Exception) {
IntegrityVerificationResponse(
isValid = false,
message = "Error verifying integrity: ${e.message}",
details = mapOf("errorType" to e.javaClass.simpleName)
)
}
}
위의 코드로 전체 검증을 실시한다. 앱이 설치된 환경(디바이스), 앱의 변조여부 등, 구글에서 범용적으로 검증 가능하다고 판단하는 기준을 따라 검증된 결과를 필터하는 코드이다.
결론
다음은 한국에서도 잘 알려지지 않은 iOS AppAttest의 검증코드를 올릴 예정이다.
'Java Kotlin > Spring SpringBoot' 카테고리의 다른 글
Springboot에서 AppAttest API 사용하기 (3. iOS AppAttest) (0) | 2025.05.12 |
---|---|
Springboot에서 Play Integrity API 사용하기 (1. 기본 설정) (0) | 2025.05.12 |