candy_check 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|