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 |