Przeglądaj źródła

Adding code and tests for online-mode access tokens

Francois Chagnon 8 lat temu
rodzic
commit
217feec47c
5 zmienionych plików z 120 dodań i 33 usunięć
  1. 28 8
      README.md
  2. 26 7
      lib/shopify_api/session.rb
  3. 1 0
      shopify_api.gemspec
  4. 6 2
      test/fulfillment_request_test.rb
  5. 59 16
      test/session_test.rb

+ 28 - 8
README.md

@@ -67,9 +67,11 @@ ShopifyAPI uses ActiveResource to communicate with the REST web service. ActiveR
 
    For a partner app you will need to supply two parameters to the Session class before you instantiate it:
 
-  ```ruby
-  ShopifyAPI::Session.setup({:api_key => API_KEY, :secret => SHARED_SECRET})
-  ```
+   ```ruby
+   ShopifyAPI::Session.setup(api_key: API_KEY, secret: SHARED_SECRET)
+   ```
+
+   Shopify maintains [`omniauth-shopify-oauth2`](https://github.com/Shopify/omniauth-shopify-oauth2) which securely wraps the OAuth flow and interactions with Shopify (steps 3 and 4 above). Using this gem is the recommended way to use OAuth authentication in your application.
 
 3. In order to access a shop's data, apps need an access token from that specific shop. This is a two-stage process. Before interacting with a shop for the first time an app should redirect the user to the following URL:
 
@@ -79,10 +81,11 @@ ShopifyAPI uses ActiveResource to communicate with the REST web service. ActiveR
 
    with the following parameters:
 
-   * ``client_id``– Required – The API key for your app
-   * ``scope`` – Required – The list of required scopes (explained here: http://docs.shopify.com/api/tutorials/oauth)
+   * ``client_id`` – Required – The API key for your app
+   * ``scope`` – Required – The list of required scopes (explained here: https://help.shopify.com/api/guides/authentication/oauth#scopes)
    * ``redirect_uri`` – Required – The URL where you want to redirect the users after they authorize the client. The complete URL specified here must be identical to one of the Application Redirect URLs set in the App's section of the Partners dashboard. Note: in older applications, this parameter was optional, and redirected to the Application Callback URL when no other value was specified.
    * ``state`` – Optional – A randomly selected value provided by your application, which is unique for each authorization request. During the OAuth callback phase, your application must check that this value matches the one you provided during authorization. [This mechanism is important for the security of your application](https://tools.ietf.org/html/rfc6819#section-3.6).
+   * ``grant_options[]`` - Optional - Set this parameter to `per-user` to receive an access token that respects the user's permission level when making api requests (called online access). This is strongly recommended for embedded apps.
 
    We've added the create_permission_url method to make this easier, first instantiate your session object:
 
@@ -133,10 +136,28 @@ ShopifyAPI uses ActiveResource to communicate with the REST web service. ActiveR
    token = session.request_token(params)
    ```
 
-   This method will save the token to the session object and return it. For future sessions simply pass the token in when creating the session object:
+   This method will save the token to the session object and return it. All fields returned by Shopify, other than the access token itself, are stored in the session's `extra` attribute. For a list of all fields returned by Shopify, read [our oauth documentation](https://help.shopify.com/api/guides/authentication/oauth#confirming-installation). If you requested an access token that is associated with a specific user, you can retreive information about this user from the `extra` hash:
+
+   ```ruby
+   # a list of all granted scopes
+   granted_scopes = session.extra['scope']
+   # a hash containing the user information
+   user = session.extra['associated_user']
+   # the access scopes available to this user, which may be a subset of the access scopes granted to this app.
+   active_scopes = session.extra['associated_user_scope']
+   # the time at which this token expires, this is automatically converted from 'expired_in' returned by Shopify
+   expires_at = session.extra['expires_at']
+   ```
+
+   For the security of your application, after retrieving an access token you must validate the following:
+   1) The list of scopes in `session.extra['scope']` is the same as you requested.
+   2) If you requested an online-mode access token, `session.extra['associated_user']` must be present.
+   Failing either of these tests means the end-user may have tampered with the query parameters during the OAuth authentication phase. You should avoid using this access token and revoke it immediately. Using [`omniauth-shopify-oauth2`](https://github.com/Shopify/omniauth-shopify-oauth2) gem these checks are done automatically for you.
+
+   For future sessions simply pass in the `token` and `extra` hash (optional) when creating the session object:
 
    ```ruby
-   session = ShopifyAPI::Session.new("SHOP_NAME.myshopify.com", token)
+   session = ShopifyAPI::Session.new("SHOP_NAME.myshopify.com", token, extra)
    ```
 
 5. The session must be activated before use:
@@ -177,7 +198,6 @@ ShopifyAPI uses ActiveResource to communicate with the REST web service. ActiveR
    ShopifyAPI::Base.clear_session
    ```
 
-
 ### Console
 
 This package also supports the ``shopify-cli`` executable to make it easy to open up an interactive console to use the API with a shop.

+ 26 - 7
lib/shopify_api/session.rb

@@ -11,7 +11,7 @@ module ShopifyAPI
     self.protocol = 'https'
     self.myshopify_domain = 'myshopify.com'
 
-    attr_accessor :url, :token, :name
+    attr_accessor :url, :token, :name, :extra
 
     class << self
 
@@ -67,9 +67,10 @@ module ShopifyAPI
       end
     end
 
-    def initialize(url, token = nil)
+    def initialize(url, token = nil, extra = {})
       self.url = self.class.prepare_url(url)
       self.token = token
+      self.extra = extra
     end
 
     def create_permission_url(scope, redirect_uri = nil)
@@ -85,12 +86,15 @@ module ShopifyAPI
         raise ShopifyAPI::ValidationException, "Invalid Signature: Possible malicious login"
       end
 
-      code = params['code']
-
-      response = access_token_request(code)
-
+      response = access_token_request(params['code'])
       if response.code == "200"
-        token = JSON.parse(response.body)['access_token']
+        self.extra = JSON.parse(response.body)
+        self.token = extra.delete('access_token')
+
+        if expires_in = extra.delete('expires_in')
+          extra['expires_at'] = Time.now.utc.to_i + expires_in
+        end
+        token
       else
         raise RuntimeError, response.msg
       end
@@ -108,6 +112,21 @@ module ShopifyAPI
       url.present? && token.present?
     end
 
+    def expires_in
+      return unless expires_at.present?
+      [0, expires_at.to_i - Time.now.utc.to_i].max
+    end
+
+    def expires_at
+      return unless extra.present?
+      @expires_at ||= Time.at(extra['expires_at']).utc
+    end
+
+    def expired?
+      return false if expires_in.nil?
+      expires_in <= 0
+    end
+
     private
       def parameterize(params)
         URI.escape(params.collect{|k,v| "#{k}=#{v}"}.join('&'))

+ 1 - 0
shopify_api.gemspec

@@ -32,4 +32,5 @@ Gem::Specification.new do |s|
   s.add_development_dependency("fakeweb")
   s.add_development_dependency("minitest", ">= 4.0")
   s.add_development_dependency("rake")
+  s.add_development_dependency("timecop")
 end

+ 6 - 2
test/fulfillment_request_test.rb

@@ -2,7 +2,7 @@ require 'test_helper'
 
 class FulFillmentRequestTest < Test::Unit::TestCase
   def setup
-    fake "orders/450789469/fulfillment_requests/695890229", method: :get, body: load_fixture('fulfillment_request')
+    fake "orders/450789469/fulfillment_requests/255858046", method: :get, body: load_fixture('fulfillment_request')
   end
 
   context "#mark_as_failed" do
@@ -11,7 +11,11 @@ class FulFillmentRequestTest < Test::Unit::TestCase
 
       cancelled = ActiveSupport::JSON.decode(load_fixture('fulfillment_request'))
       cancelled['failure_message'] = 'failure reason'
-      fake "orders/450789469/fulfillment_requests/695890229/mark_as_failed", method: :put, body: ActiveSupport::JSON.encode(cancelled)
+      cancelled['message'] = nil
+      fake "orders/450789469/fulfillment_requests/695890229/mark_as_failed.json?message=",
+        method: :put,
+        body: ActiveSupport::JSON.encode(cancelled),
+        extension: false
 
       assert fulfillment_request.failure_message.blank?
       assert fulfillment_request.mark_as_failed

+ 59 - 16
test/session_test.rb

@@ -1,4 +1,5 @@
 require 'test_helper'
+require 'timecop'
 
 class SessionTest < Test::Unit::TestCase
 
@@ -141,19 +142,59 @@ class SessionTest < Test::Unit::TestCase
   end
 
   test "return_token_if_signature_is_valid" do
-    params = {:code => 'any-code', :timestamp => Time.now}
-    sorted_params = make_sorted_params(params)
-    signature = OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA256.new(), ShopifyAPI::Session.secret, sorted_params)
-    fake nil, :url => 'https://testshop.myshopify.com/admin/oauth/access_token',:method => :post, :body => '{"access_token" : "any-token"}'
+    fake nil,
+      url: 'https://testshop.myshopify.com/admin/oauth/access_token',
+      method: :post,
+      body: '{"access_token":"any-token"}'
     session = ShopifyAPI::Session.new("testshop.myshopify.com")
-    token = session.request_token(params.merge(:hmac => signature))
+
+    params = { code: 'any-code', timestamp: Time.now }
+    token = session.request_token(params.merge(hmac: generate_signature(params)))
+
     assert_equal "any-token", token
+    assert_equal "any-token", session.token
+  end
+
+  test "extra parameters are stored in session" do
+    fake nil,
+      url: 'https://testshop.myshopify.com/admin/oauth/access_token',
+      method: :post,
+      body: '{"access_token":"any-token","foo":"example"}'
+    session = ShopifyAPI::Session.new("testshop.myshopify.com")
+
+    params = { code: 'any-code', timestamp: Time.now }
+    assert session.request_token(params.merge(hmac: generate_signature(params)))
+
+    assert_equal ({ "foo" => "example" }), session.extra
+  end
+
+  test "expires_in is automatically converted in expires_at" do
+    fake nil,
+      url: 'https://testshop.myshopify.com/admin/oauth/access_token',
+      method: :post,
+      body: '{"access_token":"any-token","expires_in":86393}'
+    session = ShopifyAPI::Session.new("testshop.myshopify.com")
+
+    Timecop.freeze do
+      params = { code: 'any-code', timestamp: Time.now }
+      assert session.request_token(params.merge(hmac: generate_signature(params)))
+
+      expires_at = Time.now.utc + 86393
+      assert_equal ({ "expires_at" => expires_at.to_i }), session.extra
+      assert session.expires_at.is_a?(Time)
+      assert_equal expires_at.to_i, session.expires_at.to_i
+      assert_equal 86393, session.expires_in
+      assert_equal false, session.expired?
+
+      Timecop.travel(session.expires_at) do
+        assert_equal true, session.expired?
+      end
+    end
   end
 
   test "raise error if signature does not match expected" do
     params = {:code => "any-code", :timestamp => Time.now}
-    sorted_params = make_sorted_params(params)
-    signature = OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA256.new(), ShopifyAPI::Session.secret, sorted_params)
+    signature = generate_signature(params)
     params[:foo] = 'world'
     assert_raises(ShopifyAPI::ValidationException) do
       session = ShopifyAPI::Session.new("testshop.myshopify.com")
@@ -163,8 +204,7 @@ class SessionTest < Test::Unit::TestCase
 
   test "raise error if timestamp is too old" do
     params = {:code => "any-code", :timestamp => Time.now - 2.days}
-    sorted_params = make_sorted_params(params)
-    signature = OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA256.new(), ShopifyAPI::Session.secret, sorted_params)
+    signature = generate_signature(params)
     params[:foo] = 'world'
     assert_raises(ShopifyAPI::ValidationException) do
       session = ShopifyAPI::Session.new("testshop.myshopify.com")
@@ -173,18 +213,16 @@ class SessionTest < Test::Unit::TestCase
   end
 
   test "return true when the signature is valid and the keys of params are strings" do
-    now = Time.now
-    params = {"code" => "any-code", "timestamp" => now}
-    sorted_params = make_sorted_params(params)
-    signature = OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA256.new(), ShopifyAPI::Session.secret, sorted_params)
-    params = {"code" => "any-code", "timestamp" => now, "hmac" => signature}
+    params = {"code" => "any-code", "timestamp" => Time.now}
+    params["hmac"] = generate_signature(params)
+    assert_equal true, ShopifyAPI::Session.validate_signature(params)
   end
 
   test "return true when validating signature of params with ampersand and equal sign characters" do
     ShopifyAPI::Session.secret = 'secret'
     params = {'a' => '1&b=2', 'c=3&d' => '4'}
     to_sign = "a=1%26b=2&c%3D3%26d=4"
-    params['hmac'] = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), ShopifyAPI::Session.secret, to_sign)
+    params['hmac'] = generate_signature(to_sign)
 
     assert_equal true, ShopifyAPI::Session.validate_signature(params)
   end
@@ -193,7 +231,7 @@ class SessionTest < Test::Unit::TestCase
     ShopifyAPI::Session.secret = 'secret'
     params = {'a%3D1%26b' => '2%26c%3D3'}
     to_sign = "a%253D1%2526b=2%2526c%253D3"
-    params['hmac'] = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), ShopifyAPI::Session.secret, to_sign)
+    params['hmac'] = generate_signature(to_sign)
 
     assert_equal true, ShopifyAPI::Session.validate_signature(params)
   end
@@ -203,4 +241,9 @@ class SessionTest < Test::Unit::TestCase
   def make_sorted_params(params)
     sorted_params = params.with_indifferent_access.except(:signature, :hmac, :action, :controller).collect{|k,v|"#{k}=#{v}"}.sort.join('&')
   end
+
+  def generate_signature(params)
+    params = make_sorted_params(params) if params.is_a?(Hash)
+    OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA256.new, ShopifyAPI::Session.secret, params)
+  end
 end