Category Archives: Android

Google Play 안전하지 않은 TrustManager 경고 대응하기

logo-google-play-vetor

구글이 기존의 수많은 앱들의 X509TrustManager를 무력화 시켜 구현한 것에 대한 대응을 나선 것 같습니다. 현재 HTTPS 통신을 위해 SSL 관련된 코드를 구현하였지만 그 방법이 잘못된 경우에 대해 구글 플레이에서 다음과 같은 경고가 뜨고 있습니다.

앱이 Apache HTTP 클라이언트가 있는 X509TrustManager 인터페이스의 안전하지 않은 구현을 사용하고 있어 보안 취약성에 노출되었습니다. 취약성 수정 마감일 등 자세한 내용은 Google 도움말 센터의 이 도움말을 참조하세요

영문판의 경우의 경고 메시지는 다음과 같습니다.

Your app is using an unsafe implementation of the X509TrustManager interface with an Apache HTTP client, resulting in a security vulnerability. Please see this Google Help Center article for details, including the deadline for fixing the vulnerability.

구글플레이에 앱을 출시하고 5시간(저의경우에는 하루정도 뒤에 확인 가능했었습니다)정도 뒤에 구글플레이에 앱 리스트에 경고 표시가 다음과같이 표기됩니다.

google_play_warning_x509trustmanager

이는 다음과 같은 코드가 앱내에 존재할 경우 발생하는 경고이며 2016년 5월 17일부터 해당 문제가 해결되지 않은 앱들을 차단할 예정이라고 합니다.

TrustManager tm = new X509TrustManager() {
	public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException { }
	public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException { }
	public X509Certificate[] getAcceptedIssuers() { return null; }
};

문제가 되는 부분은 checkServerTrusted 메소드의 내부 구현이 아무것도 없는것에서 기인합니다; 정확하게는 위의 코드는 SSL의 동작을 완벽하게 무력화 시켜 인증 여부를 보장할 수 없는 누구와도 통신할 수 있게 된 상태를 야기합니다. 만약 TrustManager를 커스터마이징해서 사용해야 하는 경우  내부에 원치 상황에 대해 CertificateException 또는 IllegalArgumentException 예외를 발생시키는 코드를 구현해야 합니다.

우선 문제가 되는 Apache의 HttpClient를 사용하는 올드한 형태의 예제를 확인해 보겠습니다.

KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType());
trustStore.load(null, null);

HttpParams params = new BasicHttpParams();
HttpProtocolParams.setVersion(params, HttpVersion.HTTP_1_0);
HttpProtocolParams.setContentCharset(params, HTTP.UTF_8);

SchemeRegistry registry = new SchemeRegistry();
registry.register(new Scheme("http", PlainSocketFactory.getSocketFactory(), 80));
registry.register(new Scheme("https", new MySSLSocketFactory(trustStore), 443));

ClientConnectionManager ccm = new ThreadSafeClientConnManager(params, registry);

HttpClient client = new DefaultHttpClient(ccm, params);
String getURL = "https://eye.pe.kr/index.html";
HttpGet get = new HttpGet(getURL);
HttpResponse responseGet = client.execute(get);
HttpEntity resEntityGet = responseGet.getEntity();

if (resEntityGet != null) {
    Log.i("RESPONSE", EntityUtils.toString(resEntityGet));
}

그리고 다음의 코드는 개발하신 스타일에 따라 다른 모습이겠지만 SSLSocketFactory와 X509TrustManager를 구현한 코드입니다. 위의 코드에서 MySSLSocketFactory라는 이름으로 가져다 사용하고 있습니다.

public class MySSLSocketFactory extends SSLSocketFactory {
    SSLContext sslContext = SSLContext.getInstance("TLS");

    public MySSLSocketFactory(KeyStore truststore) throws NoSuchAlgorithmException, KeyManagementException, KeyStoreException, UnrecoverableKeyException {
        super(truststore);

        TrustManager tm = new X509TrustManager() {
            public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {
            }

            public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {
            }

            public X509Certificate[] getAcceptedIssuers() {
                return null;
            }
        };

        sslContext.init(null, new TrustManager[] { tm }, null);
    }

    @Override
    public Socket createSocket(Socket socket, String host, int port, boolean autoClose) throws IOException, UnknownHostException {
        return sslContext.getSocketFactory().createSocket(socket, host, port, autoClose);
    }

    @Override
    public Socket createSocket() throws IOException {
        return sslContext.getSocketFactory().createSocket();
    }
}

SSLSocketFactory를 상속하여 내부적으로 커스터마이징 된 X509TrustManager를 사용한 SSLContext를 통하여 소켓을 생성하도록 구현되어있습니다. 이렇게 생성한 SSL Socket은 보안에 취약한 상태입니다.

사실 정상적인 CA가 발급한 SSL 인증서로 구축된 서버라면 다음과 같은 코드만으로 통신에 문제가 없습니다.

HttpParams params = new BasicHttpParams();
HttpProtocolParams.setVersion(params, HttpVersion.HTTP_1_1);
HttpProtocolParams.setContentCharset(params, HTTP.UTF_8);

SchemeRegistry registry = new SchemeRegistry();
registry.register(new Scheme("http", PlainSocketFactory.getSocketFactory(), 80));
registry.register(new Scheme("https", SSLSocketFactory.getSocketFactory(), 443));

ClientConnectionManager ccm = new ThreadSafeClientConnManager(params, registry);

HttpClient client = new DefaultHttpClient(ccm, params);
String getURL = "https://google.com";
HttpGet get = new HttpGet(getURL);
HttpResponse responseGet = client.execute(get);
HttpEntity resEntityGet = responseGet.getEntity();

if (resEntityGet != null) {
    Log.i("RESPONSE", EntityUtils.toString(resEntityGet));
}

하지만 TrustManager를 무력화 시키면서까지 HTTPS 통신을 하려 했다는건 인증서 비용등의 문제로 자체 발급한 Self-Signed 인증서를 사용중이기 때문이 아닐까 생각됩니다. 확인 할 수 없는 인증서로 인한 통신 불가 상황에서는 다음과 같은 예외가 발생합니다.

javax.net.ssl.SSLPeerUnverifiedException: No peer certificate
at com.android.org.conscrypt.SSLNullSession.getPeerCertificates(SSLNullSession.java:104)
at org.apache.http.conn.ssl.AbstractVerifier.verify(AbstractVerifier.java:98)
at org.apache.http.conn.ssl.SSLSocketFactory.createSocket(SSLSocketFactory.java:394)
at org.apache.http.impl.conn.DefaultClientConnectionOperator.openConnection(DefaultClientConnectionOperator.java:219)
at org.apache.http.impl.conn.AbstractPoolEntry.open(AbstractPoolEntry.java:172)
at org.apache.http.impl.conn.AbstractPooledConnAdapter.open(AbstractPooledConnAdapter.java:130)
at org.apache.http.impl.client.DefaultRequestDirector.executeOriginal(DefaultRequestDirector.java:1317)
at org.apache.http.impl.client.DefaultRequestDirector.execute(DefaultRequestDirector.java:707)
at org.apache.http.impl.client.AbstractHttpClient.execute(AbstractHttpClient.java:694)
at org.apache.http.impl.client.AbstractHttpClient.execute(AbstractHttpClient.java:520)
at org.apache.http.impl.client.AbstractHttpClient.execute(AbstractHttpClient.java:498)
at example.trustmanager.theeye.pe.kr.trustmanagerexample.MainActivity$1$1.run(MainActivity.java:36)
at java.lang.Thread.run(Thread.java:818)

이 문제를 해결하는 가장 간단한 방법은 정상적인 CA가 발급한 인증서를 구매하여 서버에 채택하고 위쪽의 예제 코드처럼 SSLSocketFactory 를 기본 형태로 사용하는 방법입니다.

하지만 여전히 자체 발급한 인증서로도 구글의 권고안을 따라 문제를 해결할 수 있습니다. TrustManager를 재구현하면서 무력화 시킨 코드가 문제가 되기 때문에 TrustManager를 무력화 시키지 않는 방법으로도 해결을 해보도록 하겠습니다.

우선 자체 발급한 인증서는 일반적인 공인된 CA들이 인증해 주지 않는다는것이 중요하므로 자체적인 CA의 인증서를 앱내에 탑재하여 인증을 가능케 하는 방법이 있습니다. 인증서 자체 발급에 대해서는 [Self-Signed SSL로 Apache HTTPS 구현하기]를 참고해주시기 바랍니다.

앱내에 탑재될 인증서는 CA의 PEM 포맷의 인증서이며 위의 참고글에서 언급되는 rootCA.crt 파일이 여기에 해당됩니다. 이 인증서의 내용은 BASE64 인코딩이 되어있는 형태입니다. 이것을 이용하여 코드를 수정해 보겠습니다.

String certificateString = "-----BEGIN CERTIFICATE-----\n" +
        "MIIDdzCCAl+gAwIBAgIJAMapd+KIAR0MMA0GCSqGSIb3DBQUAMFIxCzAJBgNV\n" +
        "BAYTAktSMRQwEgYDVQQIDAtHeWVvbmdnaS1kbzEUMB1UEBwwLU2VvbmduYW0t\n" +
        "c2kxFzAVBgNVBAoMDlRoZWV5ZSBDb21wYW55MB4E2MDIyMDA5MjEyMVoXDTE3\n" +
        "MDIxOTA5MjEyMVowUjELMAkGA1UEBhMCS1IxSBgNVBAgMC0d5ZW9uZ2dpLWRv\n" +
        "MRQwEgYDVQQHDAtTZW9uZ25hbS1zaTEXMA1UECgwOVGhlZXllIENvbXBhbnkw\n" +
        "ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwgEKAoIBAQDbrzdE+u+cIMnGJRZWlOZ+\n" +
        "8fBil6pVPQntbu3nz92omfiTAeGjb25gkwkLamsM0QISnXSmI+gl3iJmuCyFw\n" +
        "r3F+GiAUXS7e87vJoOKclbGVIQHPtEsbx+IjK7C2MJgoToNczedFzhr1xI9NV\n" +
        "ZpFMKV+YONM7qCsKKMjVrj4TE7DCZUaWu8QlPYC6p8kR5ZM6DNtxoE2QOWpiB\n" +
        "RwRwM6k88KfUVXysuvj3LCRVOqN3GrpCGF8GjuMohrI0zcK2XHvZ6D8GaFUEA\n" +
        "TLG9QcfD1N/Jo1tHpdWe3kQIYjtGBvjZ3VfUmqt9PFsqjK5sx6Kppd9yPreQp\n" +
        "AgMBAAGjUDBOGA1UdDgQWBBTjlXdy+BlcQGHfVdF9/8QkW1ciTTAfBgNVHSME\n" +
        "GDAWgBTjl+BlcQGHfVdF9/8QkW1ciTTAMBgNVHRMEBTADAQH/MA0GCSqGSIb3\n" +
        "DQEBBQ4IBAQCpWmUEr5p4CdWSGWHSopcNGPgJIODJ4K6Ir/IFJRb5tyzYY02R\n" +
        "mfUtyP39M8GnnEz5QqYNobCNIVV3cSgAMKeUTAbBQDbE+F04LzR6DRXtdhjp7\n" +
        "VtRG6McnecEi4D2Zq2ZGdFuAncfzhvdjybgkhkn1TZnbbtsiYqazKsNhdBvzR\n" +
        "mvEpRkC7eqpAw36A9zHyjvP9tJW0mVJjlUtevARDkLyX+VpMtKOUWHYikLXgK\n" +
        "UfRm9rtQMIpCC9n0qTMvDkxuBJakdSI7YRCpPYrsv0IEP23UN3Y32j1lthVIU\n" +
        "9S5KyRYKhDzJXadgO3dNI9bFL0H2SZ6mXqYL\n" +
        "-----END CERTIFICATE-----";

ByteArrayInputStream derInputStream = new ByteArrayInputStream(certificateString.getBytes());
CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509");
X509Certificate cert = (X509Certificate) certificateFactory.generateCertificate(derInputStream);
String alias = cert.getSubjectX500Principal().getName();

KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType());
trustStore.load(null, null);
trustStore.setCertificateEntry(alias, cert);

HttpParams params = new BasicHttpParams();
HttpProtocolParams.setVersion(params, HttpVersion.HTTP_1_0);
HttpProtocolParams.setContentCharset(params, HTTP.UTF_8);

SchemeRegistry registry = new SchemeRegistry();
registry.register(new Scheme("http", PlainSocketFactory.getSocketFactory(), 80));
registry.register(new Scheme("https", new MySSLSocketFactory(trustStore), 443));

ClientConnectionManager ccm = new ThreadSafeClientConnManager(params, registry);

HttpClient client = new DefaultHttpClient(ccm, params);
String getURL = "https://eye.pe.kr/index.html";
HttpGet get = new HttpGet(getURL);
HttpResponse responseGet = client.execute(get);
HttpEntity resEntityGet = responseGet.getEntity();

if (resEntityGet != null) {
    Log.i("RESPONSE", EntityUtils.toString(resEntityGet));
}

다른 많은 예제들은 인증서를 앱내에 파일로 포함하여 FileInputStream등으로 읽어서 쓰는 예시가 많았습니다만 파일을 앱에 포함할 정도면 텍스트를 그대로 하드코딩 해도 문제가 되지 않겠다고 생각되어 위와 같이 작성 해 보았습니다. 위에서 언급되었던 잘못된 MySSLSocketFactory의 구현은 다음과 같이 변경되었습니다.

public class MySSLSocketFactory extends SSLSocketFactory {
    SSLContext sslContext = SSLContext.getInstance("TLS");

    public MySSLSocketFactory(KeyStore truststore) throws NoSuchAlgorithmException, KeyManagementException, KeyStoreException, UnrecoverableKeyException {
        super(truststore);

        TrustManagerFactory tmf = TrustManagerFactory.getInstance("X509");
        tmf.init(truststore);

        sslContext.init(null, tmf.getTrustManagers(), null);
    }

    @Override
    public Socket createSocket(Socket socket, String host, int port, boolean autoClose) throws IOException, UnknownHostException {
        return sslContext.getSocketFactory().createSocket(socket, host, port, autoClose);
    }

    @Override
    public Socket createSocket() throws IOException {
        return sslContext.getSocketFactory().createSocket();
    }
}

위와 같이 수정 후 통신 테스트를 해보면 잘 되는 것을 확인할 수 있습니다. 해당 CA로 부터 발급된 모든 인증서가 적용된 서버들과 통신이 문제없이 가능합니다. 만약에 지정한 CA 인증서와 연관이 없는 인증서가 적용된 서버일 경우 다음과 같은 오류가 발생합니다.

javax.net.ssl.SSLHandshakeException: java.security.cert.CertPathValidatorException: Trust anchor for certification path not found.
at com.android.org.conscrypt.OpenSSLSocketImpl.startHandshake(OpenSSLSocketImpl.java:340)
at com.android.org.conscrypt.OpenSSLSocketImpl.waitForHandshake(OpenSSLSocketImpl.java:677)
at com.android.org.conscrypt.OpenSSLSocketImpl.getInputStream(OpenSSLSocketImpl.java:639)
at org.apache.http.impl.io.SocketInputBuffer.<init>(SocketInputBuffer.java:75)
at org.apache.http.impl.SocketHttpClientConnection.createSessionInputBuffer(SocketHttpClientConnection.java:88)
at org.apache.http.impl.conn.DefaultClientConnection.createSessionInputBuffer(DefaultClientConnection.java:175)
at org.apache.http.impl.SocketHttpClientConnection.bind(SocketHttpClientConnection.java:111)
at org.apache.http.impl.conn.DefaultClientConnection.openCompleted(DefaultClientConnection.java:134)
at org.apache.http.impl.conn.DefaultClientConnectionOperator.openConnection(DefaultClientConnectionOperator.java:226)
at org.apache.http.impl.conn.AbstractPoolEntry.open(AbstractPoolEntry.java:172)
at org.apache.http.impl.conn.AbstractPooledConnAdapter.open(AbstractPooledConnAdapter.java:130)
at org.apache.http.impl.client.DefaultRequestDirector.executeOriginal(DefaultRequestDirector.java:1317)
at org.apache.http.impl.client.DefaultRequestDirector.execute(DefaultRequestDirector.java:707)
at org.apache.http.impl.client.AbstractHttpClient.execute(AbstractHttpClient.java:694)
at org.apache.http.impl.client.AbstractHttpClient.execute(AbstractHttpClient.java:520)
at org.apache.http.impl.client.AbstractHttpClient.execute(AbstractHttpClient.java:498)
at example.trustmanager.theeye.pe.kr.trustmanagerexample.MainActivity$1$1.run(MainActivity.java:93)
at java.lang.Thread.run(Thread.java:818)

만약에 서버의 CA인증서 존재 여부가 불분명하고 자체 발급하여 사용중인 인증서 역시 확인하기가 어렵다면 아쉬운대로 다음과 같은 명령을 사용하여 자체 발급된 인증서를 확인 하실 수 있습니다.

# openssl s_client -connect eye.pe.kr:443
CONNECTED(00000003)
depth=0 C = KR, ST = Gyeonggi-do, L = Seongnam-si, O = TheEye Company
verify error:num=18:self signed certificate
verify return:1
depth=0 C = KR, ST = Gyeonggi-do, L = Seongnam-si, O = TheEye Company
verify return:1
---
Certificate chain
 0 s:/C=KR/ST=Gyeonggi-do/L=Seongnam-si/O=TheEye Company
   i:/C=KR/ST=Gyeonggi-do/L=Seongnam-si/O=Theeye Company
---
Server certificate
-----BEGIN CERTIFICATE-----
MIIDIDCCAggCCQCkHog/OqFR2TANBgkqhkiG9w0BAQUFADBSMQswCQYDVQwJL
UjEUMBIGA1UECAwLR3llb25nZ2ktZG8xFDASBgNVBAcMC1Nlb25nbmFNpMRcw
FQYDVQQKDA5UaGVleWUgQ29tcGFueTAeFw0xNjAyMjAxMDI3NDlaxNzAyMTkx
MDI3NDlaMFIxCzAJBgNVBAYTAktSMRQwEgYDVQQIDAtHeWVvbaS1kbzEUMBIG
A1UEBwwLU2VvbmduYW0tc2kxFzAVBgNVBAoMDlRoZUV5ZS21wYW55MIIBIjAN
BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAthNyPJFOp3z7vvuiEwVpuz56o
Y7bmZziN0dKoh8E1DXQnUeIMeotegSvS4r1S1lzK5KqR0Aka8ft7QccOFM5zx
bqv7yvBZ57fzoKeTwNUHrURseuSj90Q4zHK68l/OUyxULDR7HYRmxtVCB/72L
W11awXCqnEyAdS26XLeFf5JZv1sSleW/J/1FGsWDy8/AcQ4hEiDj/nke9TkQ4
JlHHPuLObouKOhoJkj2wZaNlC2GuJ3+mIFFsleEVx2IXH5ezcLG4gE0FQJk+9
lohtAXZwHq3A/CF+bMspbiHVwOYT3cCLJ/Hi8J5Mz0NOzTIhvygaA4wIDAQAB
MA0GCSqGSIb3DQEBBQUAA4IBAy/LVoqKqlvYEcfDAconAGGOTlxkkxIWOMfUK
DdViDNgAvIlO8J5bvkCdi9ap0UAX8bN+XLuxXmd+t55qmHAaA/54uswhYpqz0
2pQeP/Hpt5HPlAPUMySdVkJovAJbYi0vBSv6L8WpDGDa/9AX0yPvaW1OLtzVY
VddpqTLlYGoYzcvps/GnZO7v/4/meda6gQyphg8S91WrPbUKvziGkdtex6CST
8zwSn7gTvo7FXbTjK28DoWDq/rRr3Ni8KyqWvXtcDQWGyAjUvjO6O/p7C2UPy
BC15dcAEkGL4GMYANJPJsiIjo1Umbu5kbXeE8ISW+pbyh
-----END CERTIFICATE-----

s_client 명령을 사용하여 통신하고자 하는 서버의 인증서를 확인 할 수 있습니다. 이는 CA 인증서는 아니지만 이렇게 조회된 인증서를 이용해도 1:1간의 통신에는 문제가 없습니다.

문제를 해결하고 Version Code를 올린 APK를 재등록하고 하루정도가 지나면 해당 경고메시지가 사리지는것을 확인하실 수 있습니다.

스크린샷 2016-02-22 오후 3.26.24

참고 :

  • https://support.google.com/faqs/answer/6346016
  • https://developer.android.com/intl/ko/training/articles/security-ssl.html#UnknownCa

Android In-App Billing 서버사이드 보안 완벽 정리

google_developers_logo

이번에는 안드로이드 어플리케이션의 앱내 구매(In-App Billing)를 사용한 상품 구매시에 해킹 여부를 서버에서 검증하는 방법에 대해 정리해보겠습니다. 기존에 인터넷 상에 최종 사용자(End User) 기준의 구글 API 사용 방법에 대한 레퍼런스는 많은편인것 같으니 좀 더 사용이 용이한 서비스 계정(Service Account)를 이용한 결제 검증을 하는 방법에 대해 알아보겠습니다. 이 문서는 [Android In-App Billing 보안 완벽 정리]를 먼저 읽으신 다음에 읽으시길 권장드립니다.

여기서 사용하려는 방식은 구글 사용자의 계정 로그인 후 엑세스/리프레시 토큰을 관리 유지하면서 API를 사용하지 않고 구글이 제공하는 여러 인증 방식중에 하나인 서비스 계정으로 API를 바로 사용하는 방법입니다. 이를 위해서는 먼서 서비스 계정을 만들 필요가 있습니다. 해당 부분은 [Server to Server 어플리케이션을 위한 Google OAuth 2.0]에 정리해 두었으니 이 내용을 참고하셔서 서비스 계정을 우선 준비하시기 바랍니다. 이후의 내용은 이 서비스 계정이 준비된 상태라고 판단하고 진행을 하겠습니다.

서비스 계정 발급을 통해 준비가 되어야 하는 것은 크게 두가지 입니다.

  • 유니크한 구글이 발급한 이메일 주소
  • 구글이 생성한 P12 방식 비밀키 파일

구글 개발자 센터에서 서비스 계정 연결하기

준비에 문제가 없다면 구글 개발자 콘솔에 접속하여 현재 설정 상태가 구글 API를 호출하기에 문제가 없는지 점검을 해보겠습니다. 우선 설정 – API 액세스에 들어가 봅니다.

google_validation_iab_01

오른쪽에 나오는 정보중에 마지막으로 서비스 계정이라는 항목이 있습니다. 여기에 미리 추가했던 서비스 계정이 보여지게 됩니다. 엑세스 권한 부여 버튼을 눌러 해당 서비스 계정으로 안드로이드 관련 API를 호출 할 수 있도록 해 줍니다. 이미 권한이 부여되었다면 권한 보기 버튼이 있을 것입니다.

google_validation_iab_03

여기서 중요한것은 구매내역 및 영수증 검증을 하기 위해서는 재무 보고서 보기 권한이 필요하다는 부분입니다. 역할을 금융으로 선택해 주면 해당 권한이 자동으로 선택됩니다. 즉 영수증 검증을 위해서는 금융 역할을 갖는 서비스 계정이 필요합니다.

google_validation_iab_04

정상적으로 권한 설정이 된것을 확인할 수 있습니다. 이것으로 Google 쪽에서 필요한 작업은 모두 끝났습니다. 이과정은 하나의 안드로이드 개발자 당 한번만 수행하면 됩니다. 앱을 추가할때마다 하실 필요는 없습니다. 이제 코드를 보도록 하겠습니다.

구글 클라이언트 라이브러리를 이용하여 API 호출하기

복잡하게 토큰을 관리하며 HTTP/REST API를 사용할것 없이 구글이 제공하는 클라이언트 라이브러리를 사용하도록 합시다. [이곳]에서 자신의 언어에 맞는 라이브러리를 찾으실 수 있습니다. 여기서는 Java로 진행하도록 하겠습니다. Java 기준 필요한 라이브러리는 두가지입니다. google-api-client 와 google-api-services-androidpublisher 입니다. 저의 경우에는 gradle을 사용하여 예제 프로젝트를 만들어보았습니다. 다음과 같이 dependencies를 추가 해 주면 됩니다.

dependencies {
    compile 'com.google.api-client:google-api-client:1.21.0'
    compile 'com.google.apis:google-api-services-androidpublisher:v2-rev22-1.21.0'
}

이제 다음과 같은 방법으로 GoogleCredential을 생성해주도록 합시다. 여기서는 기존에 서비스 계정 발급을 통해 받은 이메일주소와 P12 비밀키 파일을 잘 지정해 주셔야 합니다.

String emailAddress = "my-new-service-account@api-project-000000.iam.gserviceaccount.com";

JsonFactory JSON_FACTORY = JacksonFactory.getDefaultInstance();
HttpTransport httpTransport = GoogleNetHttpTransport.newTrustedTransport();
GoogleCredential credential = new GoogleCredential.Builder()
     .setTransport(httpTransport)
     .setJsonFactory(JSON_FACTORY)
     .setServiceAccountId(emailAddress)
     .setServiceAccountPrivateKeyFromP12File(new File("src/GooglePlayAndroidDeveloperPrivateKey.p12"))
     .setServiceAccountScopes(Collections.singleton("https://www.googleapis.com/auth/androidpublisher"))
     .build();

이제 특정 구글 앱내 구매를 처리하기 위해서 다음과 같은 간단한 코드만으로 API를 호출하고 결과를 확인할 수 있습니다.

String packageName = "pe.kr.theeye.trivialdrive";
String productId = "gas";
String purchaseToken = "njnbhmfid...AuSkyASqY";

AndroidPublisher publisher = new AndroidPublisher.Builder(httpTransport, JSON_FACTORY, credential)
	.setApplicationName(packageName)
	.build();

AndroidPublisher.Purchases.Products.Get get = publisher.purchases().products().get(packageName, productId, purchaseToken);
ProductPurchase productPurchase = get.execute();
System.out.println(productPurchase.toPrettyString());

// 인앱 상품의 소비 상태. 0 아직 소비 안됨(Yet to be consumed) / 1 소비됨(Consumed)
Integer consumptionState = productPurchase.getConsumptionState();

// 개발자가 지정한 임의 문자열 정보
String developerPayload = productPurchase.getDeveloperPayload();

// 구매 상태. 0 구매완료 / 1 취소됨
Integer purchaseState = productPurchase.getPurchaseState();

// 상품이 구매된 시각. 타임스탬프 형태
Long purchaseTimeMillis = productPurchase.getPurchaseTimeMillis();

받을 수 있는 결과의 예시는 다음과 같습니다. 정상적인 상품이라면 HTTP 200 OK가 떨어지며 다음과 같이 정상적인 상품의 정보와 consumptionState가 1이고 purchaseState가 0이게 됩니다.  consumptionState가 0이라면 개발자의 실수라던가 구매 과정에서 앱이 크래시되거나 다양한 이유가 있겠지만 어쨌든 결제 과정에서 오류가 발생했다고 보시면 될 것 같습니다.

{
  "consumptionState" : 1,	// 0 아직 컨슘 안됨, 1 컨슘됨
  "developerPayload" : "",
  "kind" : "androidpublisher#productPurchase",
  "purchaseState" : 0,	// 0 구매완료, 1 취소
  "purchaseTimeMillis" : "1454502702978"
}

purchaseState의 경우 결제 완료 상태의 경우 0으로 오게 되며 개발자가 환불을 해주거나 유저가 임의로 환불을 한 상태라면 1로 바뀌게 됩니다. 이게 즉시 바뀌지는 않고 몇분정도 딜레이가 있는것 같습니다.

만약에 위변조된 구매 토큰을 가지고 검증 요청을 하였다면 다음과 같은 오류 메시지를 받게 됩니다. HTTP 400번대의 오류코드가 떨어지게 됩니다. 인증관련 오류는 401이 존재하지 않는 구매 영수증의 경우 404가 떨어집니다.

{
  "code" : 404,
  "errors" : [ {
    "domain" : "global",
    "location" : "token",
    "locationType" : "parameter",
    "message" : "The purchase token was not found.",
    "reason" : "purchaseTokenNotFound"
  } ],
  "message" : "The purchase token was not found."
}

이런 오류의 경우 get.execute() 호출 할 때에 GoogleJsonResponseException 예외가 발생하니 관련하여 적절한 처리를 해주시면 됩니다.

참고 사항

Google Purchase API 버전별 차이점 (v1.1 / v2)

기존에 v1.1은 권한과 상관없이 아무앱의 결제 정보를 영수증값과 패키지명 등을 이용하여 조회가 가능했다고 들었지만 실제로 테스트 해본 결과 다른 앱의 정보를 조회하는것은 불가능했습니다. v1.1 및 v2 모두 다음과 같이 권한 오류가 발생하였습니다.

{
  "error": {
    "errors": [
      {
        "domain": "androidpublisher",
        "reason": "permissionDenied",
        "message": "The current user has insufficient permissions to perform the requested operation."
      }
    ],
    "code": 401,
    "message": "The current user has insufficient permissions to perform the requested operation."
  }
}

purchaseState의 값을 조회하면 현재 구매 완료된 상태인지 취소된 상태인지 여부를 확인할 수 있습니다. 하지만 v1.1에서는 무조건 0으로 나오는 등 정상적으로 값을 조회할 수 없었습니다. 결제 취소 여부를 조회할 필요가 있으시다면 필히 v2로 구현을 하시기를 추천드립니다.

쿼터 제약 사항

구글플레이 개발자 API를 사용하는 어플리케이션들은 초기 사용 할당량으로 하루에 (하나의 어플리케이션 당) 20만건을 부여받습니다. 이 정도의 수량이면 대부분의 일반적인 어플리케이션들에는 충분한 양이지만 만약에 더 높은 제한 조건이 필요할 경우 구글 개발자 콘솔의 “Request more” 링크를 사용해 주시기 바랍니다.

참고 :

https://stackoverflow.com/questions/11115381/unable-to-get-the-subscription-information-from-google-play-android-developer-ap
https://developers.google.com/android-publisher/api-ref/purchases/products