diff --git a/CHANGELOG.md b/CHANGELOG.md index 60a4e6e85..86db53340 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ 11.7.0 ----- * Move ExtensionVerificationController from engine to app controllers, as being in the engine makes ActionController::Base get loaded before app initiates [#855](https://github.com/Shopify/shopify_app/pull/855) +* Add back per-user token support (added in 11.5.0, reverted in 11.5.1) 11.6.0 ----- diff --git a/README.md b/README.md index 729ada14f..085d17be7 100644 --- a/README.md +++ b/README.md @@ -12,61 +12,35 @@ Shopify Application Rails engine and generator Table of Contents ----------------- -* [**Description**](#description) -* [**Quickstart**](#quickstart) -* [**Becoming a Shopify App Developer**](#becoming-a-shopify-app-developer) -* [**App Tunneling**](#app-tunneling) -* [**Installation**](#installation) - * [Rails Compatibility](#rails-compatibility) -* [**Generators**](#generators) - * [Default Generator](#default-generator) - * [Install Generator](#install-generator) - * [Shop Model Generator](#shop-model-generator) - * [Home Controller Generator](#home-controller-generator) - * [App Proxy Controller Generator](#app-proxy-controller-generator) - * [Controllers, Routes and Views](#controllers-routes-and-views) -* [**Mounting the Engine**](#mounting-the-engine) -* [**WebhooksManager**](#webhooksmanager) -* [**ScripttagsManager**](#scripttagsmanager) -* [**AfterAuthenticate Job**](#afterauthenticate-job) -* [**ShopifyApp::SessionRepository**](#shopifyappsessionrepository) -* [**Authenticated**](#authenticated) -* [**AppProxyVerification**](#appproxyverification) - * [Recommended Usage](#recommended-usage) -* [**Upgrading from 8.6 to 9.0.0**](#upgrading-from-86-to-900) -* [**Troubleshooting**](#troubleshooting) - * [Generator shopify_app:install hangs](#generator-shopify_appinstall-hangs) -* [**Testing an embedded app outside the Shopify admin**](#testing-an-embedded-app-outside-the-shopify-admin) -* [**Questions or problems?**](#questions-or-problems) - - -Description +- [Introduction](#introduction) +- [Becoming a Shopify App Developer](#becoming-a-shopify-app-developer) +- [Installation](#installation) +- [Generators](#generators) +- [Mounting the Engine](#mounting-the-engine) +- [Authentication](#authentication) +- [WebhooksManager](#webhooksmanager) +- [ScripttagsManager](#scripttagsmanager) +- [RotateShopifyTokenJob](#rotateshopifytokenjob) +- [App Tunneling](#app-tunneling) +- [AppProxyVerification](#appproxyverification) +- [Troubleshooting](#troubleshooting) +- [Testing an embedded app outside the Shopify admin](#testing-an-embedded-app-outside-the-shopify-admin) +- [Questions or problems?](#questions-or-problems-) +- [Rails 6 Compatibility](#rails-6-compatibility) +- [Upgrading from 8.6 to 9.0.0](#upgrading-from-86-to-900) + +Introduction ----------- This gem includes a Rails Engine and generators for writing Rails applications using the Shopify API. The Engine provides a SessionsController and all the required code for authenticating with a shop via Oauth (other authentication methods are not supported). *Note: It's recommended to use this on a new Rails project, so that the generator won't overwrite/delete some of your files.* - -Quickstart ----------- - Check out this screencast on how to create and deploy a new Shopify App to Heroku in 5 minutes: [https://www.youtube.com/watch?v=yGxeoAHlQOg](https://www.youtube.com/watch?v=yGxeoAHlQOg) Or if you prefer text instructions the steps in the video are written out [here](https://github.com/Shopify/shopify_app/blob/master/docs/Quickstart.md) -App Tunneling -------------- - -Your local app needs to be accessible from the public Internet in order to install it on a shop, use the [App Proxy Controller](#app-proxy-controller-generator) or receive Webhooks. Use a tunneling service like [ngrok](https://ngrok.com/), [Forward](https://forwardhq.com/), [Beeceptor](https://beeceptor.com/), [Mockbin](http://mockbin.org/), [Hookbin](https://hookbin.com/), etc. - -For example with [ngrok](https://ngrok.com/), run this command to set up proxying to Rails' default port: - -```sh -ngrok http 3000 -``` - Becoming a Shopify App Developer -------------------------------- If you don't have a Shopify Partner account yet head over to http://shopify.com/partners to create one, you'll need it before you can start developing apps. @@ -106,7 +80,7 @@ The default generator will run the `install`, `shop`, and `home_controller` gene $ rails generate shopify_app ``` -After running the generator, you will need to run `rake db:migrate` to add tables to your database. You can start your app with `bundle exec rails server` and install your app by visiting localhost. +After running the generator, you will need to run `rails db:migrate` to add tables to your database. You can start your app with `bundle exec rails server` and install your app by visiting localhost. ### API Keys @@ -143,17 +117,6 @@ The generator adds ShopifyApp and the required initializers to the host Rails ap After running the `install` generator, you can start your app with `bundle exec rails server` and install your app by visiting localhost. -### Shop Model Generator - -```sh -$ rails generate shopify_app:shop_model -``` - -The `install` generator doesn't create any database tables or models for you. If you are starting a new app its quite likely that you will want a shops table and model to store the tokens when your app is installed (most of our internally developed apps do!). This generator creates a shop model and a migration. This model includes the `ShopifyApp::SessionStorage` concern which adds two methods to make it compatible as a `SessionRepository`. After running this generator you'll notice the `session_repository` in your `config/initializers/shopify_app.rb` will be set to the `Shop` model. This means that internally ShopifyApp will try and load tokens from this model. - -*Note that you will need to run rake db:migrate after this generator* - - ### Home Controller Generator ```sh @@ -245,21 +208,82 @@ ShopifyApp.configure do |config| end ``` -Per User Authentication ------------------------ -To enable per user authentication you need to update the `omniauth.rb` initializer: +Authentication +-------------- + +### ShopifyApp::SessionRepository + +`ShopifyApp::SessionRepository` allows you as a developer to define how your sessions are stored and retrieved for shops. The `SessionRepository` is configured in the `config/initializers/shopify_app.rb` file and can be set to any object that implements `self.store(auth_session)` which stores the session and returns a unique identifier and `self.retrieve(id)` which returns a `ShopifyAPI::Session` for the passed id. See either the `ShopifyApp::InMemorySessionStore` class or the `ShopifyApp::SessionStorage` concern for details. + +If you only run the install generator then by default you will have an in memory store but it **won't work** on multi-server environments including Heroku. For multi-server environments, implement one of the following token-storage strategies. + +#### Shop-based token storage +Storing tokens on the store model means that any user login associated to the store will have equal access levels to whatever the original user granted the app. +```sh +$ rails generate shopify_app:shop_model +``` +This will generate a shop model which will be the storage for the tokens necessary for authentication. + +#### User-based token storage +A more granular control over level of access per user on an app might be necessary, to which the shop-based token strategy is not sufficient. Shopify supports a user-based token storage strategy where a unique token to each user can be managed. +```sh +$ rails generate shopify_app:user_model +``` +This will generate a user model which will be the storage for the tokens necessary for authentication. + +The current Shopify user will be stored in the rails session at `session[:shopify_user]` + +This will change the type of token that Shopify returns and it will only be valid for a short time. Read more about `Online access` [here](https://help.shopify.com/api/getting-started/authentication/oauth). Note that this means you won't be able to use this token to respond to Webhooks. + +#### Migrating from shop-based to user-based token strategy +After running the generator, ensure that configuration settings are successfully changed: ```ruby +# In the `omniauth.rb` initializer: provider :shopify, ShopifyApp.configuration.api_key, ShopifyApp.configuration.secret, scope: ShopifyApp.configuration.scope, per_user_permissions: true + +# In the `shopify_app.rb` initializer: +config.session_repository = 'User' +config.per_user_tokens = true ``` -The current Shopify user will be stored in the rails session at `session[:shopify_user]` +### Authenticated -This will change the type of token that Shopify returns and it will only be valid for a short time. Read more about `Online access` [here](https://help.shopify.com/api/getting-started/authentication/oauth). Note that this means you won't be able to use this token to respond to Webhooks. +The engine provides a `ShopifyApp::Authenticated` concern which should be included in any controller that is intended to be behind Shopify OAuth. It adds `before_action`s to ensure that the user is authenticated and will redirect to the Shopify login page if not. It is best practice to include this concern in a base controller inheriting from your `ApplicationController`, from which all controllers that require Shopify authentication inherit. + +For backwards compatibility, the engine still provides a controller called `ShopifyApp::AuthenticatedController` which includes the `ShopifyApp::Authenticated` concern. Note that it inherits directly from `ActionController::Base`, so you will not be able to share functionality between it and your application's `ApplicationController`. + +### AfterAuthenticate Job + +If your app needs to perform specific actions after the user is authenticated successfully (i.e. every time a new session is created), ShopifyApp can queue or run a job of your choosing (note that we already provide support for automatically creating Webhooks and Scripttags). To configure the after authenticate job update your initializer as follows: + +```ruby +ShopifyApp.configure do |config| + config.after_authenticate_job = { job: "Shopify::AfterAuthenticateJob" } +end +``` + +The job can be configured as either a class or a class name string. + +If you need the job to run synchronously add the `inline` flag: + +```ruby +ShopifyApp.configure do |config| + config.after_authenticate_job = { job: Shopify::AfterAuthenticateJob, inline: true } +end +``` + +We've also provided a generator which creates a skeleton job and updates the initializer for you: + +``` +bin/rails g shopify_app:add_after_authenticate_job +``` + +If you want to perform that action only once, e.g. send a welcome email to the user when they install the app, you should make sure that this action is idempotent, meaning that it won't have an impact if run multiple times. WebhooksManager @@ -353,36 +377,6 @@ Scripttags are created in the same way as the Webhooks, with a background job wh If `src` responds to `call` its return value will be used as the scripttag's source. It will be called on scripttag creation and deletion. -AfterAuthenticate Job ---------------------- - -If your app needs to perform specific actions after the user is authenticated successfully (i.e. every time a new session is created), ShopifyApp can queue or run a job of your choosing (note that we already provide support for automatically creating Webhooks and Scripttags). To configure the after authenticate job update your initializer as follows: - -```ruby -ShopifyApp.configure do |config| - config.after_authenticate_job = { job: "Shopify::AfterAuthenticateJob" } -end -``` - -The job can be configured as either a class or a class name string. - -If you need the job to run synchronously add the `inline` flag: - -```ruby -ShopifyApp.configure do |config| - config.after_authenticate_job = { job: Shopify::AfterAuthenticateJob, inline: true } -end -``` - -We've also provided a generator which creates a skeleton job and updates the initializer for you: - -``` -bin/rails g shopify_app:add_after_authenticate_job -``` - -If you want to perform that action only once, e.g. send a welcome email to the user when they install the app, you should make sure that this action is idempotent, meaning that it won't have an impact if run multiple times. - - RotateShopifyTokenJob --------------------- @@ -409,19 +403,16 @@ The generated rake task will be found at `lib/tasks/shopify/rotate_shopify_token strategy.options[:old_client_secret] = ShopifyApp.configuration.old_secret ``` -ShopifyApp::SessionRepository ------------------------------ - -`ShopifyApp::SessionRepository` allows you as a developer to define how your sessions are retrieved and stored for shops. The `SessionRepository` is configured in the `config/initializers/shopify_app.rb` file and can be set to any object that implements `self.store(shopify_session)` which stores the session and returns a unique identifier and `self.retrieve(id)` which returns a `ShopifyAPI::Session` for the passed id. See either the `ShopifyApp::InMemorySessionStore` class or the `ShopifyApp::SessionStorage` concern for examples. - -If you only run the install generator then by default you will have an in memory store but it **won't work** on multi-server environments including Heroku. If you ran all the generators including the shop_model generator then the `Shop` model itself will be the `SessionRepository`. If you look at the implementation of the generated shop model you'll see that this gem provides a concern for the `SessionRepository`. You can use this concern on any model that responds to `shopify_domain`, `shopify_token` and `api_version`. - -Authenticated +App Tunneling ------------- -The engine provides a `ShopifyApp::Authenticated` concern which should be included in any controller that is intended to be behind Shopify OAuth. It adds `before_action`s to ensure that the user is authenticated and will redirect to the Shopify login page if not. It is best practice to include this concern in a base controller inheriting from your `ApplicationController`, from which all controllers that require Shopify authentication inherit. +Your local app needs to be accessible from the public Internet in order to install it on a shop, use the [App Proxy Controller](#app-proxy-controller-generator) or receive Webhooks. Use a tunneling service like [ngrok](https://ngrok.com/), [Forward](https://forwardhq.com/), [Beeceptor](https://beeceptor.com/), [Mockbin](http://mockbin.org/), [Hookbin](https://hookbin.com/), etc. -For backwards compatibility, the engine still provides a controller called `ShopifyApp::AuthenticatedController` which includes the `ShopifyApp::Authenticated` concern. Note that it inherits directly from `ActionController::Base`, so you will not be able to share functionality between it and your application's `ApplicationController`. +For example with [ngrok](https://ngrok.com/), run this command to set up proxying to Rails' default port: + +```sh +ngrok http 3000 +``` AppProxyVerification -------------------- @@ -465,7 +456,7 @@ Questions or problems? - [Read the docs!](https://help.shopify.com/api/guides) Rails 6 Compatibility ---------------------------- +--------------------- ### Disable Webpacker If you are using sprockets in rails 6 or want to generate a shopify_app without webpacker run the install task by running diff --git a/app/controllers/concerns/shopify_app/authenticated.rb b/app/controllers/concerns/shopify_app/authenticated.rb index ba3c6fa8d..7ac447edf 100644 --- a/app/controllers/concerns/shopify_app/authenticated.rb +++ b/app/controllers/concerns/shopify_app/authenticated.rb @@ -8,7 +8,7 @@ module Authenticated include ShopifyApp::Localization include ShopifyApp::LoginProtection include ShopifyApp::EmbeddedApp - before_action :login_again_if_different_shop + before_action :login_again_if_different_user_or_shop around_action :shopify_session end end diff --git a/app/controllers/shopify_app/callback_controller.rb b/app/controllers/shopify_app/callback_controller.rb index db0ab63fa..e959b68c9 100644 --- a/app/controllers/shopify_app/callback_controller.rb +++ b/app/controllers/shopify_app/callback_controller.rb @@ -55,10 +55,16 @@ def set_shopify_session token: token, api_version: ShopifyApp.configuration.api_version ) - - session[:shopify] = ShopifyApp::SessionRepository.store(session_store) + session[:shopify] = ShopifyApp::SessionRepository.store(session_store, user: associated_user) session[:shopify_domain] = shop_name session[:shopify_user] = associated_user + + if ShopifyApp.configuration.per_user_tokens? + # Adds the user_session to the session to determine if the logged in user has changed + user_session = auth_hash&.extra&.session + raise IndexError, "Missing user session signature" if user_session.nil? + session[:user_session] = user_session + end end def install_webhooks diff --git a/lib/generators/shopify_app/install/templates/shopify_provider.rb b/lib/generators/shopify_app/install/templates/shopify_provider.rb index b12cc2750..a90ac7f55 100644 --- a/lib/generators/shopify_app/install/templates/shopify_provider.rb +++ b/lib/generators/shopify_app/install/templates/shopify_provider.rb @@ -4,6 +4,7 @@ ShopifyApp.configuration.api_key, ShopifyApp.configuration.secret, scope: ShopifyApp.configuration.scope, + per_user_permissions: ShopifyApp.configuration.per_user_tokens, setup: lambda { |env| strategy = env['omniauth.strategy'] diff --git a/lib/generators/shopify_app/user_model/templates/db/migrate/create_users.erb b/lib/generators/shopify_app/user_model/templates/db/migrate/create_users.erb new file mode 100644 index 000000000..aa8b71f2d --- /dev/null +++ b/lib/generators/shopify_app/user_model/templates/db/migrate/create_users.erb @@ -0,0 +1,16 @@ +class CreateUsers < ActiveRecord::Migration[<%= rails_migration_version %>] + def self.up + create_table :users do |t| + t.bigint :shopify_user_id, null: false + t.string :shopify_domain, null: false + t.string :shopify_token, null: false + t.timestamps + end + + add_index :users, :shopify_user_id, unique: true + end + + def self.down + drop_table :users + end +end diff --git a/lib/generators/shopify_app/user_model/templates/user.rb b/lib/generators/shopify_app/user_model/templates/user.rb new file mode 100644 index 000000000..9ded5255a --- /dev/null +++ b/lib/generators/shopify_app/user_model/templates/user.rb @@ -0,0 +1,7 @@ +class User < ActiveRecord::Base + include ShopifyApp::SessionStorage + + def api_version + ShopifyApp.configuration.api_version + end +end diff --git a/lib/generators/shopify_app/user_model/templates/users.yml b/lib/generators/shopify_app/user_model/templates/users.yml new file mode 100644 index 000000000..2322f5643 --- /dev/null +++ b/lib/generators/shopify_app/user_model/templates/users.yml @@ -0,0 +1,4 @@ +regular_user: + shopify_domain: 'regular-shop.myshopify.com' + shopify_token: 'token' + shopify_user_id: 1 diff --git a/lib/generators/shopify_app/user_model/user_model_generator.rb b/lib/generators/shopify_app/user_model/user_model_generator.rb new file mode 100644 index 000000000..d65794119 --- /dev/null +++ b/lib/generators/shopify_app/user_model/user_model_generator.rb @@ -0,0 +1,38 @@ +require 'rails/generators/base' +require 'rails/generators/active_record' + +module ShopifyApp + module Generators + class UserModelGenerator < Rails::Generators::Base + include Rails::Generators::Migration + source_root File.expand_path('../templates', __FILE__) + + def create_user_model + copy_file 'user.rb', 'app/models/user.rb' + end + + def create_user_migration + migration_template 'db/migrate/create_users.erb', 'db/migrate/create_users.rb' + end + + def update_shopify_app_initializer + gsub_file 'config/initializers/shopify_app.rb', 'ShopifyApp::InMemorySessionStore', 'User' + end + + def create_user_fixtures + copy_file 'users.yml', 'test/fixtures/users.yml' + end + + private + + def rails_migration_version + Rails.version.match(/\d\.\d/)[0] + end + + # for generating a timestamp when using `create_migration` + def self.next_migration_number(dir) + ActiveRecord::Generators::Base.next_migration_number(dir) + end + end + end +end diff --git a/lib/shopify_app.rb b/lib/shopify_app.rb index 9de1736e3..a052d212c 100644 --- a/lib/shopify_app.rb +++ b/lib/shopify_app.rb @@ -44,6 +44,8 @@ def self.use_webpacker? require 'shopify_app/middleware/same_site_cookie_middleware' # session + require 'shopify_app/session/storage_strategies/shop_storage_strategy' + require 'shopify_app/session/storage_strategies/user_storage_strategy' require 'shopify_app/session/session_storage' require 'shopify_app/session/session_repository' require 'shopify_app/session/in_memory_session_store' diff --git a/lib/shopify_app/configuration.rb b/lib/shopify_app/configuration.rb index 964e92dd7..35f495aff 100644 --- a/lib/shopify_app/configuration.rb +++ b/lib/shopify_app/configuration.rb @@ -15,6 +15,8 @@ class Configuration attr_accessor :scripttags attr_accessor :after_authenticate_job attr_reader :session_repository + attr_accessor :per_user_tokens + alias_method :per_user_tokens?, :per_user_tokens attr_accessor :api_version # customise urls @@ -42,6 +44,7 @@ def initialize @myshopify_domain = 'myshopify.com' @scripttags_manager_queue_name = Rails.application.config.active_job.queue_name @webhooks_manager_queue_name = Rails.application.config.active_job.queue_name + @per_user_tokens = false @disable_webpacker = ENV['SHOPIFY_APP_DISABLE_WEBPACKER'].present? end diff --git a/lib/shopify_app/controller_concerns/login_protection.rb b/lib/shopify_app/controller_concerns/login_protection.rb index 9f1deafa2..3e139b7f2 100644 --- a/lib/shopify_app/controller_concerns/login_protection.rb +++ b/lib/shopify_app/controller_concerns/login_protection.rb @@ -27,12 +27,30 @@ def shopify_session end def shop_session - return unless session[:shopify] - @shop_session ||= ShopifyApp::SessionRepository.retrieve(session[:shopify]) + if ShopifyApp.configuration.per_user_tokens? + return unless session[:shopify_user] + @shop_session ||= ShopifyApp::SessionRepository.retrieve(session[:shopify_user]['id']) + else + return unless session[:shopify] + @shop_session ||= ShopifyApp::SessionRepository.retrieve(session[:shopify]) + end end - def login_again_if_different_shop + def login_again_if_different_user_or_shop + if ShopifyApp.configuration.per_user_tokens? + valid_session_data = session[:user_session].present? && params[:session].present? # session data was sent/stored correctly + sessions_do_not_match = session[:user_session] != params[:session] # current user is different from stored user + + if valid_session_data && sessions_do_not_match + clear_session = true + end + end + if shop_session && params[:shop] && params[:shop].is_a?(String) && (shop_session.domain != params[:shop]) + clear_session = true + end + + if clear_session clear_shop_session redirect_to_login end @@ -60,6 +78,7 @@ def clear_shop_session session[:shopify] = nil session[:shopify_domain] = nil session[:shopify_user] = nil + session[:user_session] = nil end def login_url_with_optional_shop(top_level: false) diff --git a/lib/shopify_app/session/in_memory_session_store.rb b/lib/shopify_app/session/in_memory_session_store.rb index 1c212b8f9..c7c928bac 100644 --- a/lib/shopify_app/session/in_memory_session_store.rb +++ b/lib/shopify_app/session/in_memory_session_store.rb @@ -6,7 +6,7 @@ def self.retrieve(id) repo[id] end - def self.store(session) + def self.store(session, *args) id = SecureRandom.uuid repo[id] = session id diff --git a/lib/shopify_app/session/session_repository.rb b/lib/shopify_app/session/session_repository.rb index 46c333e9b..84fb93e42 100644 --- a/lib/shopify_app/session/session_repository.rb +++ b/lib/shopify_app/session/session_repository.rb @@ -15,8 +15,8 @@ def retrieve(id) storage.retrieve(id) end - def store(session) - storage.store(session) + def store(session, *args) + storage.store(session, *args) end def storage diff --git a/lib/shopify_app/session/session_storage.rb b/lib/shopify_app/session/session_storage.rb index 913ad5dff..408776bf7 100644 --- a/lib/shopify_app/session/session_storage.rb +++ b/lib/shopify_app/session/session_storage.rb @@ -3,9 +3,12 @@ module SessionStorage extend ActiveSupport::Concern included do - validates :shopify_domain, presence: true, uniqueness: { case_sensitive: false } validates :shopify_token, presence: true validates :api_version, presence: true + validates :shopify_domain, presence: true, + if: Proc.new {|_| ShopifyApp.configuration.per_user_tokens? } + validates :shopify_domain, presence: true, uniqueness: { case_sensitive: false }, + if: Proc.new {|_| !ShopifyApp.configuration.per_user_tokens? } end def with_shopify_session(&block) @@ -18,23 +21,19 @@ def with_shopify_session(&block) end class_methods do - def store(session) - shop = find_or_initialize_by(shopify_domain: session.domain) - shop.shopify_token = session.token - shop.save! - shop.id + + def strategy_klass + ShopifyApp.configuration.per_user_tokens? ? + ShopifyApp::SessionStorage::UserStorageStrategy : + ShopifyApp::SessionStorage::ShopStorageStrategy end - def retrieve(id) - return unless id + def store(auth_session, user: nil) + strategy_klass.store(auth_session, user) + end - if shop = self.find_by(id: id) - ShopifyAPI::Session.new( - domain: shop.shopify_domain, - token: shop.shopify_token, - api_version: shop.api_version - ) - end + def retrieve(id) + strategy_klass.retrieve(id) end end end diff --git a/lib/shopify_app/session/storage_strategies/shop_storage_strategy.rb b/lib/shopify_app/session/storage_strategies/shop_storage_strategy.rb new file mode 100644 index 000000000..5d41cd055 --- /dev/null +++ b/lib/shopify_app/session/storage_strategies/shop_storage_strategy.rb @@ -0,0 +1,24 @@ +module ShopifyApp + module SessionStorage + class ShopStorageStrategy + + def self.store(auth_session, *args) + shop = Shop.find_or_initialize_by(shopify_domain: auth_session.domain) + shop.shopify_token = auth_session.token + shop.save! + shop.id + end + + def self.retrieve(id) + return unless id + if shop = Shop.find_by(id: id) + ShopifyAPI::Session.new( + domain: shop.shopify_domain, + token: shop.shopify_token, + api_version: shop.api_version + ) + end + end + end + end +end diff --git a/lib/shopify_app/session/storage_strategies/user_storage_strategy.rb b/lib/shopify_app/session/storage_strategies/user_storage_strategy.rb new file mode 100644 index 000000000..e4cc5b923 --- /dev/null +++ b/lib/shopify_app/session/storage_strategies/user_storage_strategy.rb @@ -0,0 +1,26 @@ +module ShopifyApp + module SessionStorage + class UserStorageStrategy + + def self.store(auth_session, user) + user = User.find_or_initialize_by(shopify_user_id: user[:id]) + user.shopify_token = auth_session.token + user.shopify_domain = auth_session.domain + user.save! + user.id + end + + def self.retrieve(id) + return unless id + if user = User.find_by(shopify_user_id: id) + ShopifyAPI::Session.new( + domain: user.shopify_domain, + token: user.shopify_token, + api_version: user.api_version + ) + end + end + + end + end +end diff --git a/service.yml b/service.yml index 5e25ce3e9..eb7c89d36 100644 --- a/service.yml +++ b/service.yml @@ -2,6 +2,6 @@ audience: partner classification: library org_line: App & Partner Platform owners: - - Shopify/app-partner-dev-tools-education + - Shopify/platform-dev-tools-education slack_channels: - dev-tools-education diff --git a/shopify_app.gemspec b/shopify_app.gemspec index 8fec2e377..853f87c41 100644 --- a/shopify_app.gemspec +++ b/shopify_app.gemspec @@ -18,6 +18,9 @@ Gem::Specification.new do |s| s.add_development_dependency('rake') s.add_development_dependency('byebug') s.add_development_dependency('pry') + s.add_development_dependency('pry-nav') + s.add_development_dependency('pry-stack_explorer') + s.add_development_dependency('rb-readline') s.add_development_dependency('sqlite3', '~> 1.4') s.add_development_dependency('minitest') s.add_development_dependency('mocha') @@ -26,4 +29,4 @@ Gem::Specification.new do |s| s.files = `git ls-files`.split("\n").reject { |f| f.match(%r{^(test|example)/}) } s.test_files = `git ls-files -- {test}/*`.split("\n") s.require_paths = ["lib"] -end +end \ No newline at end of file diff --git a/test/controllers/callback_controller_test.rb b/test/controllers/callback_controller_test.rb index 472e30360..cbe6dd91c 100644 --- a/test/controllers/callback_controller_test.rb +++ b/test/controllers/callback_controller_test.rb @@ -50,12 +50,19 @@ class CallbackControllerTest < ActionController::TestCase end test '#callback sets up a shopify session with a user for online mode' do - mock_shopify_user_omniauth - - get :callback, params: { shop: 'shop' } - assert_not_nil session[:shopify] - assert_equal 'shop.myshopify.com', session[:shopify_domain] - assert_equal 'user_object', session[:shopify_user] + begin + ShopifyApp.configuration.per_user_tokens = true + + mock_shopify_user_omniauth + + get :callback, params: { shop: 'shop' } + assert_not_nil session[:shopify] + assert_equal 'shop.myshopify.com', session[:shopify_domain] + assert_equal 'user_object', session[:shopify_user] + assert_equal 'this.is.a.user.session', session[:user_session] + ensure + ShopifyApp.configuration.per_user_tokens = false + end end test '#callback starts the WebhooksManager if webhooks are configured' do @@ -161,7 +168,12 @@ def mock_shopify_user_omniauth provider: :shopify, uid: 'shop.myshopify.com', credentials: { token: '1234' }, - extra: { associated_user: 'user_object' } + extra: { + associated_user: 'user_object', + associated_user_scope: "read_products", + scope: "read_products", + session: "this.is.a.user.session" + } ) request.env['omniauth.auth'] = OmniAuth.config.mock_auth[:shopify] if request request.env['omniauth.params'] = { shop: 'shop.myshopify.com' } if request diff --git a/test/generators/user_model_generator_test.rb b/test/generators/user_model_generator_test.rb new file mode 100644 index 000000000..7476c8eb4 --- /dev/null +++ b/test/generators/user_model_generator_test.rb @@ -0,0 +1,43 @@ +require 'test_helper' +require 'generators/shopify_app/user_model/user_model_generator' + +class UserModelGeneratorTest < Rails::Generators::TestCase + tests ShopifyApp::Generators::UserModelGenerator + destination File.expand_path("../tmp", File.dirname(__FILE__)) + + setup do + prepare_destination + provide_existing_initializer_file + end + + test "create the user model" do + run_generator + assert_file "app/models/user.rb" do |user| + assert_match "class User < ActiveRecord::Base", user + assert_match "include ShopifyApp::SessionStorage", user + assert_match(/def api_version\n\s*ShopifyApp\.configuration\.api_version\n\s*end/, user) + end + end + + test "creates UserModel migration" do + run_generator + assert_migration "db/migrate/create_users.rb" do |migration| + assert_match "create_table :users do |t|", migration + end + end + + test "updates the shopify_app initializer to use User to store session" do + run_generator + assert_file "config/initializers/shopify_app.rb" do |file| + assert_match "config.session_repository = 'User'", file + end + end + + test "creates default user fixtures" do + run_generator + assert_file "test/fixtures/users.yml" do |file| + assert_match "regular_user:", file + end + end + +end diff --git a/test/shopify_app/configuration_test.rb b/test/shopify_app/configuration_test.rb index ec81c7054..71a392e50 100644 --- a/test/shopify_app/configuration_test.rb +++ b/test/shopify_app/configuration_test.rb @@ -20,6 +20,22 @@ class ConfigurationTest < ActiveSupport::TestCase assert_equal false, ShopifyApp.configuration.after_authenticate_job end + test "configure object defaults to shop tokens" do + assert_equal false, ShopifyApp.configuration.per_user_tokens? + end + + test "configure object can set per-user tokens" do + begin + ShopifyApp.configure do |config| + config.per_user_tokens = true + end + + assert_equal true, ShopifyApp.configuration.per_user_tokens? + ensure + ShopifyApp.configuration.per_user_tokens = false + end + end + test "defaults login_url" do assert_equal "/login", ShopifyApp.configuration.login_url end diff --git a/test/shopify_app/controller_concerns/login_protection_test.rb b/test/shopify_app/controller_concerns/login_protection_test.rb index 6bac4799a..3445491e6 100644 --- a/test/shopify_app/controller_concerns/login_protection_test.rb +++ b/test/shopify_app/controller_concerns/login_protection_test.rb @@ -9,7 +9,7 @@ class LoginProtectionController < ActionController::Base helper_method :shop_session around_action :shopify_session, only: [:index] - before_action :login_again_if_different_shop, only: [:second_login] + before_action :login_again_if_different_user_or_shop, only: [:second_login] def index render plain: "OK" @@ -62,6 +62,36 @@ class LoginProtectionTest < ActionController::TestCase end end + test "#shop_session retrieves using shopify_user_id when configured for per-user tokens" do + begin + ShopifyApp.configuration.per_user_tokens = true + with_application_test_routes do + session[:shopify] = "foobar" + session[:shopify_user] = { 'id' => 'shopify_user_id', 'email' => 'foo@example.com' } + get :index + ShopifyApp::SessionRepository.expects(:retrieve).with(session[:shopify_user]['id']).returns(session).once + assert @controller.shop_session + end + ensure + ShopifyApp.configuration.per_user_tokens = false + end + end + + test "#shop_session retrieves using shop_id when configured for per-shop tokens" do + begin + ShopifyApp.configuration.per_user_tokens = false + with_application_test_routes do + session[:shopify] = "shopify_id" + session[:shopify_user] = { 'id' => 'shopify_user_id', 'email' => 'foo@example.com' } + get :index + ShopifyApp::SessionRepository.expects(:retrieve).with(session[:shopify]).returns(session).once + assert @controller.shop_session + end + ensure + ShopifyApp.configuration.per_user_tokens = false + end + end + test "#shop_session retreives the session from storage" do with_application_test_routes do session[:shopify] = "foobar" @@ -81,7 +111,66 @@ class LoginProtectionTest < ActionController::TestCase end end - test "#login_again_if_different_shop removes current session and redirects to login url" do + test "#login_again_if_different_user_or_shop removes current session if the user changes when in per-user-token mode" do + begin + ShopifyApp.configuration.per_user_tokens = true + with_application_test_routes do + session[:shopify] = "1" + session[:shopify_domain] = "foobar" + session[:shopify_user] = { 'id' => 1, 'email' => 'foo@example.com' } + session[:user_session] = 'old-user-session' + params = { shop: 'foobar', session: 'new-user-session' } + get :second_login, params: params + assert_nil session[:shopify] + assert_nil session[:shopify_domain] + assert_nil session[:shopify_user] + assert_nil session[:user_session] + end + ensure + ShopifyApp.configuration.per_user_tokens = false + end + end + + test "#login_again_if_different_user_or_shop retains current session if the users session doesn't change" do + begin + ShopifyApp.configuration.per_user_tokens = true + with_application_test_routes do + session[:shopify] = "1" + session[:shopify_domain] = "foobar" + session[:shopify_user] = { 'id' => 1, 'email' => 'foo@example.com' } + session[:user_session] = 'old-user-session' + params = { shop: 'foobar', session: 'old-user-session' } + get :second_login, params: params + assert session[:shopify], "1" + assert session[:shopify_domain], "foobar" + assert session[:shopify_user], { 'id' => 1, 'email' => 'foo@example.com' } + assert session[:user_session], 'old-user-session' + end + ensure + ShopifyApp.configuration.per_user_tokens = false + end + end + + test "#login_again_if_different_user_or_shop retains current session if params not present" do + begin + ShopifyApp.configuration.per_user_tokens = true + with_application_test_routes do + session[:shopify] = "1" + session[:shopify_domain] = "foobar" + session[:shopify_user] = { 'id' => 1, 'email' => 'foo@example.com' } + session[:user_session] = 'old-user-session' + get :second_login + assert session[:shopify], "1" + assert session[:shopify_domain], "foobar" + assert session[:shopify_user], { 'id' => 1, 'email' => 'foo@example.com' } + assert session[:user_session], 'old-user-session' + end + ensure + ShopifyApp.configuration.per_user_tokens = false + end + end + + test "#login_again_if_different_user_or_shop removes current session and redirects to login url" do with_application_test_routes do session[:shopify] = "foobar" session[:shopify_domain] = "foobar" @@ -96,7 +185,7 @@ class LoginProtectionTest < ActionController::TestCase end end - test "#login_again_if_different_shop ignores non-String shop params so that Rails params for Shop model can be accepted" do + test "#login_again_if_different_user_or_shop ignores non-String shop params so that Rails params for Shop model can be accepted" do with_application_test_routes do session[:shopify] = "foobar" session[:shopify_domain] = "foobar" diff --git a/test/shopify_app/session/shopify_session_repository_test.rb b/test/shopify_app/session/shopify_session_repository_test.rb index 5b86f4191..f07f19d5b 100644 --- a/test/shopify_app/session/shopify_session_repository_test.rb +++ b/test/shopify_app/session/shopify_session_repository_test.rb @@ -26,6 +26,11 @@ def self.retrieve(id) end end +class MockSessionStore < ActiveRecord::Base + include ShopifyApp::SessionStorage +end + + class ShopifySessionRepositoryTest < ActiveSupport::TestCase attr_reader :session_store, :session @@ -74,4 +79,22 @@ class ShopifySessionRepositoryTest < ActiveSupport::TestCase assert_equal TestSessionStoreClass, ShopifyApp::SessionRepository.storage end + test "session store picks correct session strategy for per-store tokens" do + begin + ShopifyApp.configuration.per_user_tokens = false + assert_equal MockSessionStore.strategy_klass, ShopifyApp::SessionStorage::ShopStorageStrategy + ensure + ShopifyApp.configuration.per_user_tokens = false + end + end + + test "session store picks correct session strategy for per-users tokens" do + begin + ShopifyApp.configuration.per_user_tokens = true + assert_equal MockSessionStore.strategy_klass, ShopifyApp::SessionStorage::UserStorageStrategy + ensure + ShopifyApp.configuration.per_user_tokens = false + end + end + end diff --git a/test/shopify_app/session/storage_strategies/shop_storage_strategy_test.rb b/test/shopify_app/session/storage_strategies/shop_storage_strategy_test.rb new file mode 100644 index 000000000..3d2a285d3 --- /dev/null +++ b/test/shopify_app/session/storage_strategies/shop_storage_strategy_test.rb @@ -0,0 +1,58 @@ +require 'test_helper' + + +module ShopifyApp + class ShopStorageStrategyTest < ActiveSupport::TestCase + + test "tests that session store can retrieve shop session records" do + TEST_SHOPIFY_DOMAIN = "example.myshopify.com" + TEST_SHOPIFY_TOKEN = "1234567890qwertyuiop" + + mock_shop_class = Object.new + + mock_shop_class.stubs(:find_by).returns(MockShopInstance.new( + shopify_domain:TEST_SHOPIFY_DOMAIN, + shopify_token:TEST_SHOPIFY_TOKEN + )) + ShopifyApp::SessionStorage::ShopStorageStrategy.const_set("Shop", mock_shop_class) + + begin + ShopifyApp.configuration.per_user_tokens = false + session = MockSessionStore.retrieve(id=1) + + assert_equal TEST_SHOPIFY_DOMAIN, session.domain + assert_equal TEST_SHOPIFY_TOKEN, session.token + ensure + ShopifyApp.configuration.per_user_tokens = false + end + + ShopifyApp::SessionStorage::ShopStorageStrategy.send(:remove_const , "Shop") + end + + test "tests that session store can store shop session records" do + mock_shop_instance = MockShopInstance.new(id:12345) + mock_shop_instance.stubs(:save!).returns(true) + + mock_shop_class = Object.new + mock_shop_class.stubs(:find_or_initialize_by).returns(mock_shop_instance) + + ShopifyApp::SessionStorage::ShopStorageStrategy.const_set("Shop", mock_shop_class) + + begin + ShopifyApp.configuration.per_user_tokens = false + + mock_auth_hash = mock() + mock_auth_hash.stubs(:domain).returns(mock_shop_instance.shopify_domain) + mock_auth_hash.stubs(:token).returns("a-new-token!") + saved_id = MockSessionStore.store(mock_auth_hash) + + assert_equal "a-new-token!", mock_shop_instance.shopify_token + assert_equal mock_shop_instance.id, saved_id + + ensure + ShopifyApp.configuration.per_user_tokens = false + end + ShopifyApp::SessionStorage::ShopStorageStrategy.send(:remove_const , "Shop") + end + end +end diff --git a/test/shopify_app/session/storage_strategies/user_storage_strategy_test.rb b/test/shopify_app/session/storage_strategies/user_storage_strategy_test.rb new file mode 100644 index 000000000..79d6543a3 --- /dev/null +++ b/test/shopify_app/session/storage_strategies/user_storage_strategy_test.rb @@ -0,0 +1,77 @@ +require 'test_helper' + + +class MockSessionStore < ActiveRecord::Base + include ShopifyApp::SessionStorage +end + + +module ShopifyApp + class UserStorageStrategyTest < ActiveSupport::TestCase + + test "tests that UserStorageStrategy is used for session storage" do + begin + ShopifyApp.configuration.per_user_tokens = true + assert_equal MockSessionStore.strategy_klass, ShopifyApp::SessionStorage::UserStorageStrategy + ensure + ShopifyApp.configuration.per_user_tokens = false + end + end + + test "tests that session store can retrieve user session records" do + TEST_SHOPIFY_USER_ID = 42 + TEST_SHOPIFY_DOMAIN = "example.myshopify.com" + TEST_SHOPIFY_USER_TOKEN = "some-user-token-42" + + mock_user_class = Object.new + + mock_user_class.stubs(:find_by).returns(MockUserInstance.new( + shopify_user_id:TEST_SHOPIFY_USER_ID, + shopify_domain:TEST_SHOPIFY_DOMAIN, + shopify_token:TEST_SHOPIFY_USER_TOKEN + )) + ShopifyApp::SessionStorage::UserStorageStrategy.const_set("User", mock_user_class) + + begin + ShopifyApp.configuration.per_user_tokens = true + session = MockSessionStore.retrieve(shopify_user_id:TEST_SHOPIFY_USER_ID) + + assert_equal TEST_SHOPIFY_DOMAIN, session.domain + assert_equal TEST_SHOPIFY_USER_TOKEN, session.token + ensure + ShopifyApp.configuration.per_user_tokens = false + ShopifyApp::SessionStorage::UserStorageStrategy.send(:remove_const , "User") + end + end + + test "tests that session store can store user session records" do + mock_user_instance = MockUserInstance.new(shopify_user_id:100) + mock_user_instance.stubs(:save!).returns(true) + + mock_user_class = Object.new + mock_user_class.stubs(:find_or_initialize_by).returns(mock_user_instance) + + ShopifyApp::SessionStorage::UserStorageStrategy.const_set("User", mock_user_class) + begin + ShopifyApp.configuration.per_user_tokens = true + + mock_auth_hash = mock() + mock_auth_hash.stubs(:domain).returns(mock_user_instance.shopify_domain) + mock_auth_hash.stubs(:token).returns("a-new-user_token!") + + associated_user = { + id: 100, + } + + saved_id = MockSessionStore.store(mock_auth_hash, user:associated_user) + + assert_equal "a-new-user_token!", mock_user_instance.shopify_token + assert_equal mock_user_instance.id, saved_id + + ensure + ShopifyApp.configuration.per_user_tokens = false + ShopifyApp::SessionStorage::UserStorageStrategy.send(:remove_const , "User") + end + end + end +end diff --git a/test/support/session_store_strategy_test_helpers.rb b/test/support/session_store_strategy_test_helpers.rb new file mode 100644 index 000000000..75a8101ef --- /dev/null +++ b/test/support/session_store_strategy_test_helpers.rb @@ -0,0 +1,32 @@ +module SessionStoreStrategyTestHelpers + + class MockSessionStore < ActiveRecord::Base + include ShopifyApp::SessionStorage + end + + + class MockShopInstance + attr_reader :id, :shopify_domain, :shopify_token, :api_version + attr_writer :shopify_token + + def initialize(id:1, shopify_domain:'example.myshopify.com', shopify_token:'abcd-shop-token', api_version:'unstable') + @id = id + @shopify_domain = shopify_domain + @shopify_token = shopify_token + @api_version = api_version + end + end + + class MockUserInstance + attr_reader :id, :shopify_user_id, :shopify_domain, :shopify_token, :api_version + attr_writer :shopify_token, :shopify_domain + + def initialize(id:1, shopify_user_id:1, shopify_domain:'example.myshopify.com', shopify_token:'1234-user-token', api_version:'unstable') + @id = id + @shopify_user_id = shopify_user_id + @shopify_domain = shopify_domain + @shopify_token = shopify_token + @api_version = api_version + end + end +end \ No newline at end of file diff --git a/test/test_helper.rb b/test/test_helper.rb index 187ba095a..1f909c1fa 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -16,6 +16,7 @@ class ActiveSupport::TestCase include GeneratorTestHelpers + include SessionStoreStrategyTestHelpers API_META_TEST_RESPONSE = <<~JSON {