candy_check 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +5 -0
- data/.rubocop.yml +6 -0
- data/.ruby-version +1 -0
- data/.travis.yml +16 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.md +157 -0
- data/Rakefile +15 -0
- data/bin/cc_appstore +90 -0
- data/bin/cc_playstore +119 -0
- data/candy_check.gemspec +31 -0
- data/lib/candy_check/app_store/client.rb +57 -0
- data/lib/candy_check/app_store/config.rb +30 -0
- data/lib/candy_check/app_store/receipt.rb +83 -0
- data/lib/candy_check/app_store/verification.rb +49 -0
- data/lib/candy_check/app_store/verification_failure.rb +60 -0
- data/lib/candy_check/app_store/verifier.rb +69 -0
- data/lib/candy_check/app_store.rb +12 -0
- data/lib/candy_check/play_store/client.rb +102 -0
- data/lib/candy_check/play_store/config.rb +51 -0
- data/lib/candy_check/play_store/discovery_repository.rb +33 -0
- data/lib/candy_check/play_store/receipt.rb +81 -0
- data/lib/candy_check/play_store/verification.rb +46 -0
- data/lib/candy_check/play_store/verification_failure.rb +30 -0
- data/lib/candy_check/play_store/verifier.rb +52 -0
- data/lib/candy_check/play_store.rb +15 -0
- data/lib/candy_check/utils/attribute_reader.rb +30 -0
- data/lib/candy_check/utils/config.rb +40 -0
- data/lib/candy_check/utils.rb +2 -0
- data/lib/candy_check/version.rb +4 -0
- data/lib/candy_check.rb +8 -0
- data/spec/app_store/client_spec.rb +55 -0
- data/spec/app_store/config_spec.rb +41 -0
- data/spec/app_store/receipt_spec.rb +92 -0
- data/spec/app_store/verifcation_failure_spec.rb +28 -0
- data/spec/app_store/verification_spec.rb +66 -0
- data/spec/app_store/verifier_spec.rb +110 -0
- data/spec/candy_check_spec.rb +9 -0
- data/spec/fixtures/api_cache.dump +1 -0
- data/spec/fixtures/play_store/api_cache.dump +1 -0
- data/spec/fixtures/play_store/auth_failure.txt +18 -0
- data/spec/fixtures/play_store/auth_success.txt +20 -0
- data/spec/fixtures/play_store/discovery.txt +2841 -0
- data/spec/fixtures/play_store/dummy.p12 +0 -0
- data/spec/fixtures/play_store/empty.txt +17 -0
- data/spec/fixtures/play_store/products_failure.txt +29 -0
- data/spec/fixtures/play_store/products_success.txt +22 -0
- data/spec/play_store/client_spec.rb +104 -0
- data/spec/play_store/config_spec.rb +96 -0
- data/spec/play_store/discovery_respository_spec.rb +31 -0
- data/spec/play_store/receipt_spec.rb +88 -0
- data/spec/play_store/verification_failure_spec.rb +35 -0
- data/spec/play_store/verification_spec.rb +80 -0
- data/spec/play_store/verifier_spec.rb +95 -0
- data/spec/spec_helper.rb +35 -0
- data/spec/support/with_fixtures.rb +9 -0
- data/spec/support/with_temp_file.rb +23 -0
- metadata +270 -0
@@ -0,0 +1,51 @@
|
|
1
|
+
module CandyCheck
|
2
|
+
module PlayStore
|
3
|
+
# Configure the usage of the official Google API SDK client
|
4
|
+
class Config < Utils::Config
|
5
|
+
# @return [String] your application name
|
6
|
+
attr_reader :application_name
|
7
|
+
# @return [String] your application's version
|
8
|
+
attr_reader :application_version
|
9
|
+
# @return [String] an optional file to cache the discovery API result
|
10
|
+
attr_reader :cache_file
|
11
|
+
# @return [String] your issuer's service account e-mail
|
12
|
+
attr_reader :issuer
|
13
|
+
# @return [String] the path to your local *.p12 certificate file
|
14
|
+
attr_reader :key_file
|
15
|
+
# @return [String] the secret to load your certificate file
|
16
|
+
attr_reader :key_secret
|
17
|
+
|
18
|
+
# Initializes a new configuration from a hash
|
19
|
+
# @param attributes [Hash]
|
20
|
+
# @example Initialize with a discovery cache file
|
21
|
+
# ClientConfig.new(
|
22
|
+
# application_name: 'YourApplication',
|
23
|
+
# application_version: '1.0',
|
24
|
+
# cache_file: 'tmp/google_api_cache',
|
25
|
+
# issuer: 'abcdefg@developer.gserviceaccount.com',
|
26
|
+
# key_file: 'local/google.p12',
|
27
|
+
# key_secret: 'notasecret'
|
28
|
+
# )
|
29
|
+
def initialize(attributes)
|
30
|
+
super
|
31
|
+
end
|
32
|
+
|
33
|
+
# @return [String] the decrypted API key from Google
|
34
|
+
def api_key
|
35
|
+
@api_key ||= begin
|
36
|
+
Google::APIClient::KeyUtils.load_from_pkcs12(key_file, key_secret)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
|
42
|
+
def validate!
|
43
|
+
validates_presence(:application_name)
|
44
|
+
validates_presence(:application_version)
|
45
|
+
validates_presence(:issuer)
|
46
|
+
validates_presence(:key_file)
|
47
|
+
validates_presence(:key_secret)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
module CandyCheck
|
2
|
+
module PlayStore
|
3
|
+
# A file-based repository to cache a local copy of the Google API
|
4
|
+
# discovery as suggested by Google.
|
5
|
+
# @see https://github.com/google/google-api-ruby-client
|
6
|
+
class DiscoveryRepository
|
7
|
+
# Create a new instance bound to a single file path
|
8
|
+
# @param file_path [String] to save and load the cached copy
|
9
|
+
def initialize(file_path)
|
10
|
+
@file_path = file_path
|
11
|
+
end
|
12
|
+
|
13
|
+
# Tries to load a cached copy of the discovery API. Me be nil if
|
14
|
+
# no cached version is available
|
15
|
+
# @return [Google::APIClient::API]
|
16
|
+
def load
|
17
|
+
return unless @file_path && File.exist?(@file_path)
|
18
|
+
File.open(@file_path, 'rb') do |file|
|
19
|
+
return Marshal.load(file)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
# Tries to save a local copy of the discovery API.
|
24
|
+
# @param discovery [Google::APIClient::API]
|
25
|
+
def save(discovery)
|
26
|
+
return unless @file_path && discovery
|
27
|
+
File.open(@file_path, 'wb') do |file|
|
28
|
+
Marshal.dump(discovery, file)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,81 @@
|
|
1
|
+
module CandyCheck
|
2
|
+
module PlayStore
|
3
|
+
# Describes a successful response from the Google verification server
|
4
|
+
class Receipt
|
5
|
+
include Utils::AttributeReader
|
6
|
+
|
7
|
+
# @return [Hash] the raw attributes returned from the server
|
8
|
+
attr_reader :attributes
|
9
|
+
|
10
|
+
# Purchased product (0 is purchased, don't ask me why)
|
11
|
+
# @see https://developers.google.com/android-publisher/api-ref/purchases/products
|
12
|
+
PURCHASE_STATE_PURCHASED = 0
|
13
|
+
|
14
|
+
# A consumed product
|
15
|
+
CONSUMPTION_STATE_CONSUMED = 1
|
16
|
+
|
17
|
+
# Initializes a new instance which bases on a JSON result
|
18
|
+
# from Google API servers
|
19
|
+
# @param attributes [Hash]
|
20
|
+
def initialize(attributes)
|
21
|
+
@attributes = attributes
|
22
|
+
end
|
23
|
+
|
24
|
+
# A product may be purchased or canceled. Ensure a receipt
|
25
|
+
# is valid before granting some candy
|
26
|
+
# @return [Boolean]
|
27
|
+
def valid?
|
28
|
+
purchase_state == PURCHASE_STATE_PURCHASED
|
29
|
+
end
|
30
|
+
|
31
|
+
# A purchased product may already be consumed. In this case you
|
32
|
+
# should grant candy even if it's valid.
|
33
|
+
# @return [Boolean]
|
34
|
+
def consumed?
|
35
|
+
consumption_state == CONSUMPTION_STATE_CONSUMED
|
36
|
+
end
|
37
|
+
|
38
|
+
# The purchase state of the order. Possible values are:
|
39
|
+
# * 0: Purchased
|
40
|
+
# * 1: Cancelled
|
41
|
+
# @return [Fixnum]
|
42
|
+
def purchase_state
|
43
|
+
read_integer('purchaseState')
|
44
|
+
end
|
45
|
+
|
46
|
+
# The consumption state of the inapp product. Possible values are:
|
47
|
+
# * 0: Yet to be consumed
|
48
|
+
# * 1: Consumed
|
49
|
+
# @return [Fixnum]
|
50
|
+
def consumption_state
|
51
|
+
read_integer('consumptionState')
|
52
|
+
end
|
53
|
+
|
54
|
+
# The developer payload which was used when buying the product
|
55
|
+
# @return [String]
|
56
|
+
def developer_payload
|
57
|
+
read('developerPayload')
|
58
|
+
end
|
59
|
+
|
60
|
+
# This kind represents an inappPurchase object in the androidpublisher
|
61
|
+
# service.
|
62
|
+
# @return [String]
|
63
|
+
def kind
|
64
|
+
read('kind')
|
65
|
+
end
|
66
|
+
|
67
|
+
# The time the product was purchased, in milliseconds since the
|
68
|
+
# epoch (Jan 1, 1970)
|
69
|
+
# @return [Fixnum]
|
70
|
+
def purchase_time_millis
|
71
|
+
read_integer('purchaseTimeMillis')
|
72
|
+
end
|
73
|
+
|
74
|
+
# The date and time the product was purchased
|
75
|
+
# @return [DateTime]
|
76
|
+
def purchased_at
|
77
|
+
read_datetime_from_millis('purchaseTimeMillis')
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
module CandyCheck
|
2
|
+
module PlayStore
|
3
|
+
# Verifies a purchase token against the Google API
|
4
|
+
# The call return either an {Receipt} or an {VerificationFailure}
|
5
|
+
class Verification
|
6
|
+
# @return [String] the package which will be queried
|
7
|
+
attr_reader :package
|
8
|
+
# @return [String] the item id which will be queried
|
9
|
+
attr_reader :product_id
|
10
|
+
# @return [String] the token for authentication
|
11
|
+
attr_reader :token
|
12
|
+
|
13
|
+
# Initializes a new call to the API
|
14
|
+
# @param client [Client] a shared client instance
|
15
|
+
# @param package [String]
|
16
|
+
# @param product_id [String]
|
17
|
+
# @param token [String]
|
18
|
+
def initialize(client, package, product_id, token)
|
19
|
+
@client = client
|
20
|
+
@package, @product_id, @token = package, product_id, token
|
21
|
+
end
|
22
|
+
|
23
|
+
# Performs the verification against the remote server
|
24
|
+
# @return [Receipt] if successful
|
25
|
+
# @return [VerificationFailure] otherwise
|
26
|
+
def call!
|
27
|
+
verify!
|
28
|
+
if valid?
|
29
|
+
Receipt.new(@response)
|
30
|
+
else
|
31
|
+
VerificationFailure.new(@response['error'])
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
def valid?
|
38
|
+
@response && @response['purchaseState'] && @response['consumptionState']
|
39
|
+
end
|
40
|
+
|
41
|
+
def verify!
|
42
|
+
@response = @client.verify(package, product_id, token)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
module CandyCheck
|
2
|
+
module PlayStore
|
3
|
+
# Represents a failing call against the Google API server
|
4
|
+
class VerificationFailure
|
5
|
+
include Utils::AttributeReader
|
6
|
+
|
7
|
+
# @return [Hash] the raw attributes returned from the server
|
8
|
+
attr_reader :attributes
|
9
|
+
|
10
|
+
# Initializes a new instance which bases on a JSON result
|
11
|
+
# from Google API servers
|
12
|
+
# @param attributes [Hash]
|
13
|
+
def initialize(attributes)
|
14
|
+
@attributes = attributes || {}
|
15
|
+
end
|
16
|
+
|
17
|
+
# The code of the failure
|
18
|
+
# @return [Fixnum]
|
19
|
+
def code
|
20
|
+
read('code') || -1
|
21
|
+
end
|
22
|
+
|
23
|
+
# The message of the failure
|
24
|
+
# @return [String]
|
25
|
+
def message
|
26
|
+
read('message') || 'Unknown error'
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
module CandyCheck
|
2
|
+
module PlayStore
|
3
|
+
# Verifies purchase tokens against the Google API.
|
4
|
+
# The call return either an {Receipt} or a {VerificationFailure}
|
5
|
+
class Verifier
|
6
|
+
# Error thrown when the verifier isn't booted before the first
|
7
|
+
# verification check or on double invocation
|
8
|
+
class BootRequiredError < RuntimeError; end
|
9
|
+
|
10
|
+
# @return [Config] the current configuration
|
11
|
+
attr_reader :config
|
12
|
+
|
13
|
+
# Initializes a new verifier for the application which is bound
|
14
|
+
# to a configuration
|
15
|
+
# @param config [Config]
|
16
|
+
def initialize(config)
|
17
|
+
@config = config
|
18
|
+
end
|
19
|
+
|
20
|
+
# Boot the module
|
21
|
+
def boot!
|
22
|
+
boot_error('You\'re only allowed to boot the verifier once') if @client
|
23
|
+
@client = Client.new(config)
|
24
|
+
@client.boot!
|
25
|
+
end
|
26
|
+
|
27
|
+
# Contacts the Google API and requests the product state
|
28
|
+
# @param package [String] to query
|
29
|
+
# @param product_id [String] to query
|
30
|
+
# @param token [String] to use for authentication
|
31
|
+
# @return [Receipt] if successful
|
32
|
+
# @return [VerificationFailure] otherwise
|
33
|
+
def verify(package, product_id, token)
|
34
|
+
check_boot!
|
35
|
+
verification = Verification.new(@client, package, product_id, token)
|
36
|
+
verification.call!
|
37
|
+
end
|
38
|
+
|
39
|
+
private
|
40
|
+
|
41
|
+
def check_boot!
|
42
|
+
return if @client
|
43
|
+
boot_error 'You need to boot the verifier service first: '\
|
44
|
+
'CandyCheck::PlayStore::Verifier#boot!'
|
45
|
+
end
|
46
|
+
|
47
|
+
def boot_error(message)
|
48
|
+
fail BootRequiredError, message
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
require 'google/api_client'
|
2
|
+
|
3
|
+
require 'candy_check/play_store/discovery_repository'
|
4
|
+
require 'candy_check/play_store/client'
|
5
|
+
require 'candy_check/play_store/config'
|
6
|
+
require 'candy_check/play_store/receipt'
|
7
|
+
require 'candy_check/play_store/verification'
|
8
|
+
require 'candy_check/play_store/verification_failure'
|
9
|
+
require 'candy_check/play_store/verifier'
|
10
|
+
|
11
|
+
module CandyCheck
|
12
|
+
# Module to request and verify a AppStore receipt
|
13
|
+
module PlayStore
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
require 'date'
|
2
|
+
|
3
|
+
module CandyCheck
|
4
|
+
module Utils
|
5
|
+
# @private
|
6
|
+
module AttributeReader
|
7
|
+
protected
|
8
|
+
|
9
|
+
def read(field)
|
10
|
+
attributes[field]
|
11
|
+
end
|
12
|
+
|
13
|
+
def has?(field)
|
14
|
+
attributes.key?(field)
|
15
|
+
end
|
16
|
+
|
17
|
+
def read_integer(field)
|
18
|
+
(val = read(field)) && val.to_i
|
19
|
+
end
|
20
|
+
|
21
|
+
def read_datetime_from_string(field)
|
22
|
+
(val = read(field)) && DateTime.parse(val)
|
23
|
+
end
|
24
|
+
|
25
|
+
def read_datetime_from_millis(field)
|
26
|
+
(val = read_integer(field)) && Time.at(val / 1000).utc.to_datetime
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
module CandyCheck
|
2
|
+
module Utils
|
3
|
+
# Very basic base implementation to store and validate a configuration
|
4
|
+
class Config
|
5
|
+
# Initializes a new configuration from a hash
|
6
|
+
# @param attributes [Hash]
|
7
|
+
def initialize(attributes)
|
8
|
+
attributes.each do |k, v|
|
9
|
+
instance_variable_set "@#{k}", v
|
10
|
+
end if attributes.is_a? Hash
|
11
|
+
validate!
|
12
|
+
end
|
13
|
+
|
14
|
+
protected
|
15
|
+
|
16
|
+
# Hook to check for validation error in the sub classes
|
17
|
+
# should raise an error if not passed
|
18
|
+
def validate!
|
19
|
+
# pass
|
20
|
+
end
|
21
|
+
|
22
|
+
# Check for the presence of an attribute
|
23
|
+
# @param name [String]
|
24
|
+
# @raise [ArgumentError] if attribute is missing
|
25
|
+
def validates_presence(name)
|
26
|
+
return if send(name)
|
27
|
+
fail ArgumentError, "Configuration field #{name} is missing"
|
28
|
+
end
|
29
|
+
|
30
|
+
# Checks for the inclusion of an attribute
|
31
|
+
# @param name [String]
|
32
|
+
# @param values [Array] of possible values
|
33
|
+
def validates_inclusion(name, *values)
|
34
|
+
return if values.include?(send(name))
|
35
|
+
fail ArgumentError, "Configuration field #{name} should be "\
|
36
|
+
"one of: #{values.join(', ')}"
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
data/lib/candy_check.rb
ADDED
@@ -0,0 +1,55 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe CandyCheck::AppStore::Client do
|
4
|
+
let(:endpoint_url) { 'https://some.endpoint.com/verify' }
|
5
|
+
let(:receipt_data) do
|
6
|
+
'some_very_long_receipt_information_which_is_normaly_base64_encoded'
|
7
|
+
end
|
8
|
+
let(:password) do
|
9
|
+
'some_secret_password'
|
10
|
+
end
|
11
|
+
let(:response) do
|
12
|
+
'{"status": 0}'
|
13
|
+
end
|
14
|
+
|
15
|
+
subject { CandyCheck::AppStore::Client.new(endpoint_url) }
|
16
|
+
|
17
|
+
describe 'valid response' do
|
18
|
+
it 'sends JSON and parses the JSON response without a secret' do
|
19
|
+
stub_endpoint
|
20
|
+
.with(
|
21
|
+
body: {
|
22
|
+
'receipt-data' => receipt_data
|
23
|
+
}
|
24
|
+
)
|
25
|
+
.to_return(
|
26
|
+
body: response
|
27
|
+
)
|
28
|
+
result = subject.verify(receipt_data)
|
29
|
+
expected = { 'status' => 0 }
|
30
|
+
result.must_equal expected
|
31
|
+
end
|
32
|
+
|
33
|
+
it 'sends JSON and parses the JSON response with a secret' do
|
34
|
+
stub_endpoint
|
35
|
+
.with(
|
36
|
+
body: {
|
37
|
+
'receipt-data' => receipt_data,
|
38
|
+
'password' => password
|
39
|
+
}
|
40
|
+
)
|
41
|
+
.to_return(
|
42
|
+
body: response
|
43
|
+
)
|
44
|
+
result = subject.verify(receipt_data, password)
|
45
|
+
expected = { 'status' => 0 }
|
46
|
+
result.must_equal expected
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
private
|
51
|
+
|
52
|
+
def stub_endpoint
|
53
|
+
stub_request(:post, endpoint_url)
|
54
|
+
end
|
55
|
+
end
|