commit a1d5dacb5e40f95a134ae14044eec431c8c19a47 Author: Axel Date: Mon Oct 29 23:03:15 2018 +0800 Implemented Device Check API call Implemented Device Check API call diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..51d633d --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +DEVICE_CHECK_KEY_FILE="AuthKey_ABCDEFGHI.p8" +DEVICE_CHECK_KEY_ID=ABCDEFGHI +DEVICE_CHECK_TEAM_ID=HZZZZZZZZZ \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..87b8876 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.DS_Store +/.env +.env +*.p8 \ No newline at end of file diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..624bfd5 --- /dev/null +++ b/Gemfile @@ -0,0 +1,7 @@ +source 'https://rubygems.org' + +gem 'dotenv' +gem 'jwt' +gem 'http' +gem 'sinatra' +gem 'sinatra-contrib' diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..88db3e9 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,67 @@ +GEM + remote: https://rubygems.org/ + specs: + activesupport (5.2.1) + concurrent-ruby (~> 1.0, >= 1.0.2) + i18n (>= 0.7, < 2) + minitest (~> 5.1) + tzinfo (~> 1.1) + addressable (2.5.2) + public_suffix (>= 2.0.2, < 4.0) + backports (3.11.4) + concurrent-ruby (1.0.5) + domain_name (0.5.20180417) + unf (>= 0.0.5, < 1.0.0) + dotenv (2.5.0) + http (4.0.0) + addressable (~> 2.3) + http-cookie (~> 1.0) + http-form_data (~> 2.0) + http_parser.rb (~> 0.6.0) + http-cookie (1.0.3) + domain_name (~> 0.5) + http-form_data (2.1.1) + http_parser.rb (0.6.0) + i18n (1.1.1) + concurrent-ruby (~> 1.0) + jwt (2.1.0) + minitest (5.11.3) + multi_json (1.13.1) + mustermann (1.0.3) + public_suffix (3.0.3) + rack (2.0.5) + rack-protection (2.0.4) + rack + sinatra (2.0.4) + mustermann (~> 1.0) + rack (~> 2.0) + rack-protection (= 2.0.4) + tilt (~> 2.0) + sinatra-contrib (2.0.4) + activesupport (>= 4.0.0) + backports (>= 2.8.2) + multi_json + mustermann (~> 1.0) + rack-protection (= 2.0.4) + sinatra (= 2.0.4) + tilt (>= 1.3, < 3) + thread_safe (0.3.6) + tilt (2.0.8) + tzinfo (1.2.5) + thread_safe (~> 0.1) + unf (0.1.4) + unf_ext + unf_ext (0.0.7.5) + +PLATFORMS + ruby + +DEPENDENCIES + dotenv + http + jwt + sinatra + sinatra-contrib + +BUNDLED WITH + 1.16.4 diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..5bea21d --- /dev/null +++ b/Rakefile @@ -0,0 +1,3 @@ +require 'sinatra/activerecord' +require 'sinatra/activerecord/rake' +require './app' \ No newline at end of file diff --git a/app.rb b/app.rb new file mode 100644 index 0000000..8df237a --- /dev/null +++ b/app.rb @@ -0,0 +1,95 @@ +require 'sinatra' +require 'sinatra/json' +require 'dotenv' +Dotenv.load +require 'openssl' +require 'http' +require 'jwt' +require 'SecureRandom' + +configure do + # change to https://api.devicecheck.apple.com for production app, ie. App in App Store / Testflight + set :device_check_api_url, 'https://api.development.devicecheck.apple.com' + set :query_url, settings.device_check_api_url + '/v1/query_two_bits' + set :update_url, settings.device_check_api_url + '/v1/update_two_bits' +end + +get '/' do + "Please send the base 64 encoded device check token in JSON parameter key 'token' to POST /redeem" +end + +post '/redeem' do + begin + request_payload = JSON.parse request.body.read + rescue JSON::ParserError + return json({ message: 'please supply a valid token parameter', redeemable: false }) + end + + # request_payload['token'] is the 'token' parameter we sent in the iOS app + unless request_payload.key? 'token' + return json({ message: 'please supply a token', redeemable: false }) + end + + response = query_two_bits(request_payload['token']) + + unless response.status == 200 + return json({ message: 'Error communicating with Apple server', redeemable: false }) + end + + begin + response_hash = JSON.parse response.body + rescue JSON::ParserError + # if status 200 and no json returned, means the state was not set previously, we set them to nil / null + response_hash = { bit0: nil, bit1: nil } + end + + # if the bit0 has been set and set to true, means user has already redeemed using their phone + if response_hash.key? 'bit0' + if response_hash['bit0'] == true + return json({ message: 'You have already redeemed it previously', redeemable: false }) + end + end + + # update the first bit to true, and tell the iOS app user can redeem the free gift + update_two_bits(request_payload['token'], true, false) + + json({ message: 'Congratulations!', redeemable: true }) +end + +def jwt_token + private_key = File.read(ENV['DEVICE_CHECK_KEY_FILE']) + key_id = ENV['DEVICE_CHECK_KEY_ID'] + team_id = ENV['DEVICE_CHECK_TEAM_ID'] + + # Elliptic curve key, similar to login password, used for communication with apple server + ec_key = OpenSSL::PKey::EC.new(private_key) + jwt_token = JWT.encode({iss: team_id, iat: Time.now.to_i}, ec_key, 'ES256', {kid: key_id,}) +end + +def query_two_bits(device_token) + payload = { + 'device_token' => device_token, + 'timestamp' => (Time.now.to_f * 1000).to_i, + 'transaction_id' => SecureRandom.uuid + } + + response = HTTP.auth("Bearer #{jwt_token}").post(settings.query_url, json: payload) + + # if there is no bit state set before, apple will return the string 'Bit State Not Found' instead of json + + # if the bit state was set before, below will be returned + #{"bit0":false,"bit1":false,"last_update_time":"2018-10"} +end + +def update_two_bits(device_token, bit_zero, bit_one) + payload = { + 'device_token' => device_token, + 'timestamp' => (Time.now.to_f * 1000).to_i, + 'transaction_id' => SecureRandom.uuid, + 'bit0': bit_zero, + 'bit1': bit_one + } + + response = HTTP.auth("Bearer #{jwt_token}").post(settings.update_url, json: payload) + # Apple will return status 200 with blank response body if the update is successful +end \ No newline at end of file diff --git a/config.ru b/config.ru new file mode 100644 index 0000000..bec6071 --- /dev/null +++ b/config.ru @@ -0,0 +1,3 @@ +require './app' + +run Sinatra::Application \ No newline at end of file