candy_check 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (59) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +5 -0
  3. data/.rubocop.yml +6 -0
  4. data/.ruby-version +1 -0
  5. data/.travis.yml +16 -0
  6. data/Gemfile +4 -0
  7. data/LICENSE.txt +22 -0
  8. data/README.md +157 -0
  9. data/Rakefile +15 -0
  10. data/bin/cc_appstore +90 -0
  11. data/bin/cc_playstore +119 -0
  12. data/candy_check.gemspec +31 -0
  13. data/lib/candy_check/app_store/client.rb +57 -0
  14. data/lib/candy_check/app_store/config.rb +30 -0
  15. data/lib/candy_check/app_store/receipt.rb +83 -0
  16. data/lib/candy_check/app_store/verification.rb +49 -0
  17. data/lib/candy_check/app_store/verification_failure.rb +60 -0
  18. data/lib/candy_check/app_store/verifier.rb +69 -0
  19. data/lib/candy_check/app_store.rb +12 -0
  20. data/lib/candy_check/play_store/client.rb +102 -0
  21. data/lib/candy_check/play_store/config.rb +51 -0
  22. data/lib/candy_check/play_store/discovery_repository.rb +33 -0
  23. data/lib/candy_check/play_store/receipt.rb +81 -0
  24. data/lib/candy_check/play_store/verification.rb +46 -0
  25. data/lib/candy_check/play_store/verification_failure.rb +30 -0
  26. data/lib/candy_check/play_store/verifier.rb +52 -0
  27. data/lib/candy_check/play_store.rb +15 -0
  28. data/lib/candy_check/utils/attribute_reader.rb +30 -0
  29. data/lib/candy_check/utils/config.rb +40 -0
  30. data/lib/candy_check/utils.rb +2 -0
  31. data/lib/candy_check/version.rb +4 -0
  32. data/lib/candy_check.rb +8 -0
  33. data/spec/app_store/client_spec.rb +55 -0
  34. data/spec/app_store/config_spec.rb +41 -0
  35. data/spec/app_store/receipt_spec.rb +92 -0
  36. data/spec/app_store/verifcation_failure_spec.rb +28 -0
  37. data/spec/app_store/verification_spec.rb +66 -0
  38. data/spec/app_store/verifier_spec.rb +110 -0
  39. data/spec/candy_check_spec.rb +9 -0
  40. data/spec/fixtures/api_cache.dump +1 -0
  41. data/spec/fixtures/play_store/api_cache.dump +1 -0
  42. data/spec/fixtures/play_store/auth_failure.txt +18 -0
  43. data/spec/fixtures/play_store/auth_success.txt +20 -0
  44. data/spec/fixtures/play_store/discovery.txt +2841 -0
  45. data/spec/fixtures/play_store/dummy.p12 +0 -0
  46. data/spec/fixtures/play_store/empty.txt +17 -0
  47. data/spec/fixtures/play_store/products_failure.txt +29 -0
  48. data/spec/fixtures/play_store/products_success.txt +22 -0
  49. data/spec/play_store/client_spec.rb +104 -0
  50. data/spec/play_store/config_spec.rb +96 -0
  51. data/spec/play_store/discovery_respository_spec.rb +31 -0
  52. data/spec/play_store/receipt_spec.rb +88 -0
  53. data/spec/play_store/verification_failure_spec.rb +35 -0
  54. data/spec/play_store/verification_spec.rb +80 -0
  55. data/spec/play_store/verifier_spec.rb +95 -0
  56. data/spec/spec_helper.rb +35 -0
  57. data/spec/support/with_fixtures.rb +9 -0
  58. data/spec/support/with_temp_file.rb +23 -0
  59. 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
@@ -0,0 +1,2 @@
1
+ require 'candy_check/utils/attribute_reader'
2
+ require 'candy_check/utils/config'
@@ -0,0 +1,4 @@
1
+ module CandyCheck
2
+ # The current gem's version
3
+ VERSION = '0.0.1'
4
+ end
@@ -0,0 +1,8 @@
1
+ require 'candy_check/version'
2
+ require 'candy_check/utils'
3
+ require 'candy_check/app_store'
4
+ require 'candy_check/play_store'
5
+
6
+ # Module to check and verify in-app receipts
7
+ module CandyCheck
8
+ end
@@ -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