Browse Source

Merge pull request #19 from redronin/oauth

replaces the legacy auth code with Oauth2 functionality
David Underwood 13 years ago
parent
commit
cf23a8fa1d
4 changed files with 104 additions and 118 deletions
  1. 22 0
      lib/shopify_api/resources/base.rb
  2. 15 106
      lib/shopify_api/session.rb
  3. 50 0
      test/base_test.rb
  4. 17 12
      test/session_test.rb

+ 22 - 0
lib/shopify_api/resources/base.rb

@@ -8,6 +8,28 @@ module ShopifyAPI
                                   "ActiveResource/#{ActiveResource::VERSION::STRING}",
                                   "Ruby/#{RUBY_VERSION}"].join(' ')
 
+    class << self
+      def headers
+        if defined?(@headers)
+          @headers
+        elsif superclass != Object && superclass.headers
+          superclass.headers
+        else
+          @headers ||= {}
+        end
+      end
+
+      def activate_session(session)
+        self.site = session.site
+        self.headers.merge!('X-Shopify-Access-Token' => session.token)
+      end
+
+      def clear_session
+        self.site = nil
+        self.headers.delete('X-Shopify-Access-Token')
+      end
+    end                  
+
     private
     def only_id
       encode(:only => :id, :include => [], :methods => [])

+ 15 - 106
lib/shopify_api/session.rb

@@ -1,84 +1,6 @@
+
 module ShopifyAPI
-  # 
-  #  The Shopify API authenticates each call via HTTP Authentication, using
-  #    * the application's API key as the username, and
-  #    * a hex digest of the application's shared secret and an 
-  #      authentication token as the password.
-  #  
-  #  Generation & acquisition of the beforementioned looks like this:
-  # 
-  #    0. Developer (that's you) registers Application (and provides a
-  #       callback url) and receives an API key and a shared secret
-  # 
-  #    1. User visits Application and are told they need to authenticate the
-  #       application first for read/write permission to their data (needs to
-  #       happen only once). User is asked for their shop url.
-  # 
-  #    2. Application redirects to Shopify : GET <user's shop url>/admin/api/auth?api_key=<API key>
-  #       (See Session#create_permission_url)
-  # 
-  #    3. User logs-in to Shopify, approves application permission request
-  # 
-  #    4. Shopify redirects to the Application's callback url (provided during
-  #       registration), including the shop's name, and an authentication token in the parameters:
-  #         GET client.com/customers?shop=snake-oil.myshopify.com&t=a94a110d86d2452eb3e2af4cfb8a3828
-  # 
-  #    5. Authentication password computed using the shared secret and the
-  #       authentication token (see Session#computed_password)
-  # 
-  #    6. Profit!
-  #       (API calls can now authenticate through HTTP using the API key, and
-  #       computed password)
-  # 
-  #  LoginController and ShopifyLoginProtection use the Session class to set Shopify::Base.site
-  #  so that all API calls are authorized transparently and end up just looking like this:
-  # 
-  #    # get 3 products
-  #    @products = ShopifyAPI::Product.find(:all, :params => {:limit => 3})
-  #    
-  #    # get latest 3 orders
-  #    @orders = ShopifyAPI::Order.find(:all, :params => {:limit => 3, :order => "created_at DESC" })
-  # 
-  #  As an example of what your LoginController should look like, take a look
-  #  at the following:
-  # 
-  #    class LoginController < ApplicationController
-  #      def index
-  #        # Ask user for their #{shop}.myshopify.com address
-  #      end
-  #    
-  #      def authenticate
-  #        redirect_to ShopifyAPI::Session.new(params[:shop]).create_permission_url
-  #      end
-  #    
-  #      # Shopify redirects the logged-in user back to this action along with
-  #      # the authorization token t.
-  #      # 
-  #      # This token is later combined with the developer's shared secret to form
-  #      # the password used to call API methods.
-  #      def finalize
-  #        shopify_session = ShopifyAPI::Session.new(params[:shop], params[:t])
-  #        if shopify_session.valid?
-  #          session[:shopify] = shopify_session
-  #          flash[:notice] = "Logged in to shopify store."
-  #    
-  #          return_address = session[:return_to] || '/home'
-  #          session[:return_to] = nil
-  #          redirect_to return_address
-  #        else
-  #          flash[:error] = "Could not log in to Shopify store."
-  #          redirect_to :action => 'index'
-  #        end
-  #      end
-  #    
-  #      def logout
-  #        session[:shopify] = nil
-  #        flash[:notice] = "Successfully logged out."
-  #    
-  #        redirect_to :action => 'index'
-  #      end
-  #    end
-  # 
+  
   class Session
     cattr_accessor :api_key
     cattr_accessor :secret
@@ -95,13 +17,18 @@ module ShopifyAPI
       
       def temp(domain, token, &block)
         session = new(domain, token)
+        begin
+          original_domain  = URI.parse(ShopifyAPI::Base.site.to_s).host
+        rescue URI::InvalidURIError
+        end
+        original_token   = ShopifyAPI::Base.headers['X-Shopify-Access-Token']
+        original_session = new(original_domain, original_token)
 
-        original_site = ShopifyAPI::Base.site
         begin
-          ShopifyAPI::Base.site = session.site
+          ShopifyAPI::Base.activate_session(session)
           yield
         ensure
-          ShopifyAPI::Base.site = original_site
+          ShopifyAPI::Base.activate_session(original_session)
         end
       end
       
@@ -110,56 +37,38 @@ module ShopifyAPI
         url.gsub!(/https?:\/\//, '')                            # remove http:// or https://
         url.concat(".myshopify.com") unless url.include?('.')   # extend url to myshopify.com if no host is given
       end
-      
+
       def validate_signature(params)
         return false unless signature = params[:signature]
 
         sorted_params = params.except(:signature, :action, :controller).collect{|k,v|"#{k}=#{v}"}.sort.join
         Digest::MD5.hexdigest(secret + sorted_params) == signature
       end
-    
+
     end
     
     def initialize(url, token = nil, params = nil)
       self.url, self.token = url, token
+      self.class.prepare_url(self.url)
 
       if params
         unless self.class.validate_signature(params) && params[:timestamp].to_i > 24.hours.ago.utc.to_i
           raise "Invalid Signature: Possible malicious login" 
         end
       end
-
-      self.class.prepare_url(self.url)
     end
     
     def shop
       Shop.current
     end
-    
-    def create_permission_url
-      return nil if url.blank? || api_key.blank?
-      "http://#{url}/admin/api/auth?api_key=#{api_key}"
-    end
 
-    # Used by ActiveResource::Base to make all non-authentication API calls
-    # 
-    # (ShopifyAPI::Base.site set in ShopifyLoginProtection#shopify_session)
     def site
-      "#{protocol}://#{api_key}:#{computed_password}@#{url}/admin"
+      "#{protocol}://#{url}/admin"
     end
 
     def valid?
       url.present? && token.present?
     end
-
-    private
-
-    # The secret is computed by taking the shared_secret which we got when 
-    # registring this third party application and concating the request_to it, 
-    # and then calculating a MD5 hexdigest. 
-    def computed_password
-      Digest::MD5.hexdigest(secret + token.to_s)
-    end
-    
+  
   end
 end

+ 50 - 0
test/base_test.rb

@@ -0,0 +1,50 @@
+require 'test_helper'
+
+
+class BaseTest < Test::Unit::TestCase
+
+  def setup
+    @session1 = ShopifyAPI::Session.new('shop1.myshopify.com', 'token1')
+    @session2 = ShopifyAPI::Session.new('shop2.myshopify.com', 'token2')
+  end
+
+  test '#activate_session should set site and headers for given session' do
+    ShopifyAPI::Base.activate_session @session1
+
+    assert_nil ActiveResource::Base.site
+    assert_equal 'https://shop1.myshopify.com/admin', ShopifyAPI::Base.site.to_s
+    assert_equal 'https://shop1.myshopify.com/admin', ShopifyAPI::Shop.site.to_s
+    
+    assert_nil ActiveResource::Base.headers['X-Shopify-Access-Token']
+    assert_equal 'token1', ShopifyAPI::Base.headers['X-Shopify-Access-Token']
+    assert_equal 'token1', ShopifyAPI::Shop.headers['X-Shopify-Access-Token']
+  end
+
+  test '#clear_session should clear site and headers from Base' do
+    ShopifyAPI::Base.activate_session @session1    
+    ShopifyAPI::Base.clear_session
+
+    assert_nil ActiveResource::Base.site
+    assert_nil ShopifyAPI::Base.site
+    assert_nil ShopifyAPI::Shop.site
+
+    assert_nil ActiveResource::Base.headers['X-Shopify-Access-Token']
+    assert_nil ShopifyAPI::Base.headers['X-Shopify-Access-Token']
+    assert_nil ShopifyAPI::Shop.headers['X-Shopify-Access-Token']
+  end
+
+  test '#activate_session with one session, then clearing and activating with another session should send request to correct shop' do
+    ShopifyAPI::Base.activate_session @session1   
+    ShopifyAPI::Base.clear_session    
+    ShopifyAPI::Base.activate_session @session2
+
+    assert_nil ActiveResource::Base.site
+    assert_equal 'https://shop2.myshopify.com/admin', ShopifyAPI::Base.site.to_s
+    assert_equal 'https://shop2.myshopify.com/admin', ShopifyAPI::Shop.site.to_s
+
+    assert_nil ActiveResource::Base.headers['X-Shopify-Access-Token']
+    assert_equal 'token2', ShopifyAPI::Base.headers['X-Shopify-Access-Token']
+    assert_equal 'token2', ShopifyAPI::Shop.headers['X-Shopify-Access-Token']
+  end
+
+end

+ 17 - 12
test/session_test.rb

@@ -23,7 +23,7 @@ class SessionTest < Test::Unit::TestCase
         session = ShopifyAPI::Session.new("testshop.myshopify.com", "any-token")
       end
     end
-    
+
     should "raise error if params passed but signature omitted" do
       assert_raises(RuntimeError) do
         session = ShopifyAPI::Session.new("testshop.myshopify.com", "any-token", {'foo' => 'bar'})
@@ -41,25 +41,30 @@ class SessionTest < Test::Unit::TestCase
     end
 
     should "#temp reset ShopifyAPI::Base.site to original value" do
-      ShopifyAPI::Base.site = 'http://www.original.com'
-
+      
       ShopifyAPI::Session.setup(:api_key => "key", :secret => "secret")
-      assigned_site = nil
+      session1 = ShopifyAPI::Session.new('fakeshop.myshopify.com', 'token1')
+      ShopifyAPI::Base.activate_session(session1)
+
       ShopifyAPI::Session.temp("testshop.myshopify.com", "any-token") {
-        assigned_site = ShopifyAPI::Base.site
+        @assigned_site = ShopifyAPI::Base.site
       }
-      assert_equal 'https://key:e56d5793b869753d87cf03ceb6bb5dfc@testshop.myshopify.com/admin', assigned_site.to_s
-      assert_equal 'http://www.original.com', ShopifyAPI::Base.site.to_s
+      assert_equal 'https://testshop.myshopify.com/admin', @assigned_site.to_s
+      assert_equal 'https://fakeshop.myshopify.com/admin', ShopifyAPI::Base.site.to_s
     end
 
-    should "return permissions url" do
+    should "return site for session" do
       session = ShopifyAPI::Session.new("testshop.myshopify.com", "any-token")
-      assert_equal "http://testshop.myshopify.com/admin/api/auth?api_key=key", session.create_permission_url
+      assert_equal "https://testshop.myshopify.com/admin", session.site
     end
 
-    should "return site for session" do
-      session = ShopifyAPI::Session.new("testshop.myshopify.com", "any-token")
-      assert_equal "https://key:e56d5793b869753d87cf03ceb6bb5dfc@testshop.myshopify.com/admin", session.site
+    should "raise error if signature does not match expected" do
+      ShopifyAPI::Session.secret = 'secret'
+      params = {:foo => 'hello', :foo => 'world', :timestamp => Time.now}
+      sorted_params = params.except(:signature, :action, :controller).collect{|k,v|"#{k}=#{v}"}.sort.join
+      signature = Digest::MD5.hexdigest(ShopifyAPI::Session.secret + sorted_params)
+
+      session = ShopifyAPI::Session.new("testshop.myshopify.com", "any-token", params.merge(:signature => signature))
     end
   end
 end