diff --git a/README.md b/README.md index 59ab246e..8a4ff511 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,8 @@ First, you need to mount the gem in the routes file like this Rails.application.routes.draw do mount_graphql_devise_for 'User', at: 'api/v1', authenticable_type: Types::CustomUserType, operations: { login: Mutations::Login - } + }, skip: [:sign_up] +end ``` If you used DTA's installer you will have to remove the `mount_devise_token_auth_for` line. @@ -49,7 +50,7 @@ line. Here are the option for the mount method: 1. `at`: Route where the GraphQL schema will be mounted on the Rails server. In the -example your API will have this two routes `POST /api/v1//graphql_auth` `GET /api/v1//graphql_auth`. +example your API will have this two routes `POST /api/v1/graphql_auth` and `GET /api/v1/graphql_auth`. If no this option is not specified, the schema will be mounted at `/graphql_auth`. 1. `operations`: Specifying this one is optional. Here you can override default behavior by specifying your own mutations and queries for every GraphQL operation. @@ -62,6 +63,23 @@ our default classes and yielding your customized code after calling `super`, exa and an `authenticable` type to every query. Gem will try to use `Types::Type` by default, so in our example you could define `Types::UserType` and every query and mutation will use it. But, you can override this type with this option like in the example. +1. `skip`: An array of the operations that should not be available in the authentication schema. All these operations are +symbols and should belong to the list of available operations in the gem. +1. `only`: An array of the operations that should be available in the authentication schema. The `skip` and `only` options are +mutually exclusive, an error will be raised if you pass both to the mount method. + +#### Available Operations +The following is a list of the symbols you can provide to the `operations`, `skip` and `only` options of the mount method: +```ruby +:login +:logout +:sign_up +:update_password +:send_reset_password +:confirm_account +:check_password_token +``` + ### Configuring Model Just like with Devise and DTA, you need to include a module in your authenticable model, @@ -145,8 +163,7 @@ templates. We will continue to improve the gem and add better docs. 1. Add install generator. -1. Support more options on the mount method. -1. Better support for multiple mounted models (it already works by mounting in different routes). +1. Add mount option that will create a separate schema for the mounted resource. 1. Make sure this gem can correctly work alongside DTA and the original Devise gem. 1. Improve DOCS. 1. Add support for unlockable and other Devise modules. diff --git a/lib/graphql_devise/rails/routes.rb b/lib/graphql_devise/rails/routes.rb index 928e576b..0203d495 100644 --- a/lib/graphql_devise/rails/routes.rb +++ b/lib/graphql_devise/rails/routes.rb @@ -1,7 +1,33 @@ module ActionDispatch::Routing class Mapper def mount_graphql_devise_for(resource, opts = {}) - custom_operations = opts[:operations] || {} + custom_operations = opts[:operations] || {} + skipped_operations = opts.fetch(:skip, []) + only_operations = opts.fetch(:only, []) + + if [skipped_operations, only_operations].all?(&:any?) + raise GraphqlDevise::Error, "Can't specify both `skip` and `only` options when mounting the route." + end + + default_mutations = { + login: GraphqlDevise::Mutations::Login, + logout: GraphqlDevise::Mutations::Logout, + sign_up: GraphqlDevise::Mutations::SignUp, + update_password: GraphqlDevise::Mutations::UpdatePassword, + send_reset_password: GraphqlDevise::Mutations::SendPasswordReset + }.freeze + default_queries = { + confirm_account: GraphqlDevise::Resolvers::ConfirmAccount, + check_password_token: GraphqlDevise::Resolvers::CheckPasswordToken + } + supported_operations = default_mutations.keys + default_queries.keys + + unless skipped_operations.all? { |skipped| supported_operations.include?(skipped) } + raise GraphqlDevise::Error, 'Trying to skip a non supported operation. Check for typos.' + end + unless only_operations.all? { |only| supported_operations.include?(only) } + raise GraphqlDevise::Error, 'One of the `only` operations is not supported. Check for typos.' + end path = opts.fetch(:at, '/graphql_auth') mapping_name = resource.underscore.tr('/', '_').to_sym @@ -16,15 +42,12 @@ def mount_graphql_devise_for(resource, opts = {}) "Types::#{resource}Type".safe_constantize || GraphqlDevise::Types::AuthenticableType - default_mutations = { - login: GraphqlDevise::Mutations::Login, - logout: GraphqlDevise::Mutations::Logout, - sign_up: GraphqlDevise::Mutations::SignUp, - update_password: GraphqlDevise::Mutations::UpdatePassword, - send_reset_password: GraphqlDevise::Mutations::SendPasswordReset - }.freeze - - default_mutations.each do |action, mutation| + used_mutations = if only_operations.present? + default_mutations.slice(*only_operations) + else + default_mutations.except(*skipped_operations) + end + used_mutations.each do |action, mutation| used_mutation = if custom_operations[action].present? custom_operations[action] else @@ -39,12 +62,12 @@ def mount_graphql_devise_for(resource, opts = {}) GraphqlDevise::Types::MutationType.field("#{mapping_name}_#{action}", mutation: used_mutation) end - default_queries = { - confirm_account: GraphqlDevise::Resolvers::ConfirmAccount, - check_password_token: GraphqlDevise::Resolvers::CheckPasswordToken - } - - default_queries.each do |action, query| + used_queries = if only_operations.present? + default_queries.slice(*only_operations) + else + default_queries.except(*skipped_operations) + end + used_queries.each do |action, query| used_query = if custom_operations[action].present? custom_operations[action] else diff --git a/spec/dummy/app/models/guest.rb b/spec/dummy/app/models/guest.rb new file mode 100644 index 00000000..97842ff0 --- /dev/null +++ b/spec/dummy/app/models/guest.rb @@ -0,0 +1,9 @@ +class Guest < ApplicationRecord + devise :database_authenticatable, + :registerable, + :recoverable, + :validatable, + :confirmable + + include GraphqlDevise::Concerns::Model +end diff --git a/spec/dummy/config/routes.rb b/spec/dummy/config/routes.rb index 785043bc..77946377 100644 --- a/spec/dummy/config/routes.rb +++ b/spec/dummy/config/routes.rb @@ -7,8 +7,15 @@ mount_graphql_devise_for( 'Admin', authenticable_type: Types::CustomAdminType, + skip: [:sign_up, :check_password_token], at: '/api/v1/admin/graphql_auth' ) + mount_graphql_devise_for( + 'Guest', + only: [:login, :logout], + at: '/api/v1/guest/graphql_auth' + ) + post '/api/v1/graphql', to: 'api/v1/graphql#graphql' end diff --git a/spec/dummy/db/migrate/20191013213045_create_guests.rb b/spec/dummy/db/migrate/20191013213045_create_guests.rb new file mode 100644 index 00000000..77581e06 --- /dev/null +++ b/spec/dummy/db/migrate/20191013213045_create_guests.rb @@ -0,0 +1,36 @@ +class CreateGuests < ActiveRecord::Migration[6.0] + def change + create_table :guests do |t| + ## Required + t.string :provider, null: false, default: 'email' + t.string :uid, null: false, default: '' + + ## Database authenticatable + t.string :encrypted_password, null: false, default: '' + + ## Recoverable + t.string :reset_password_token + t.datetime :reset_password_sent_at + t.boolean :allow_password_change, default: false + + ## Confirmable + t.string :confirmation_token + t.datetime :confirmed_at + t.datetime :confirmation_sent_at + t.string :unconfirmed_email # Only if using reconfirmable + + ## User Info + t.string :email + + ## Tokens + t.text :tokens + + t.timestamps + end + + add_index :guests, :email, unique: true + add_index :guests, [:uid, :provider], unique: true + add_index :guests, :reset_password_token, unique: true + add_index :guests, :confirmation_token, unique: true + end +end diff --git a/spec/dummy/db/schema.rb b/spec/dummy/db/schema.rb index b82d5e05..fdc6e356 100644 --- a/spec/dummy/db/schema.rb +++ b/spec/dummy/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2019_09_16_012505) do +ActiveRecord::Schema.define(version: 2019_10_13_213045) do create_table "admins", force: :cascade do |t| t.string "provider", default: "email", null: false @@ -33,6 +33,27 @@ t.index ["uid", "provider"], name: "index_admins_on_uid_and_provider", unique: true end + create_table "guests", force: :cascade do |t| + t.string "provider", default: "email", null: false + t.string "uid", default: "", null: false + t.string "encrypted_password", default: "", null: false + t.string "reset_password_token" + t.datetime "reset_password_sent_at" + t.boolean "allow_password_change", default: false + t.string "confirmation_token" + t.datetime "confirmed_at" + t.datetime "confirmation_sent_at" + t.string "unconfirmed_email" + t.string "email" + t.text "tokens" + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["confirmation_token"], name: "index_guests_on_confirmation_token", unique: true + t.index ["email"], name: "index_guests_on_email", unique: true + t.index ["reset_password_token"], name: "index_guests_on_reset_password_token", unique: true + t.index ["uid", "provider"], name: "index_guests_on_uid_and_provider", unique: true + end + create_table "users", force: :cascade do |t| t.string "provider", default: "email", null: false t.string "uid", default: "", null: false diff --git a/spec/factories/guests.rb b/spec/factories/guests.rb new file mode 100644 index 00000000..ce502f78 --- /dev/null +++ b/spec/factories/guests.rb @@ -0,0 +1,10 @@ +FactoryBot.define do + factory :guest do + email { Faker::Internet.unique.email } + password { Faker::Internet.password } + + trait :confirmed do + confirmed_at { Time.now } + end + end +end diff --git a/spec/requests/mutations/login_spec.rb b/spec/requests/mutations/login_spec.rb index abf8dc56..95ebc7c8 100644 --- a/spec/requests/mutations/login_spec.rb +++ b/spec/requests/mutations/login_spec.rb @@ -114,4 +114,28 @@ ) end end + + context 'when using the guest model' do + let(:guest) { create(:guest, :confirmed, password: password) } + let(:query) do + <<-GRAPHQL + mutation { + guestLogin( + email: "#{guest.email}", + password: "#{password}" + ) { + authenticable { email } + } + } + GRAPHQL + end + + before { post_request } + + it 'works alongside the user mount point' do + expect(json_response[:data][:guestLogin]).to include( + authenticable: { email: guest.email } + ) + end + end end diff --git a/spec/requests/mutations/sign_up_spec.rb b/spec/requests/mutations/sign_up_spec.rb index 87973e70..56bf15a6 100644 --- a/spec/requests/mutations/sign_up_spec.rb +++ b/spec/requests/mutations/sign_up_spec.rb @@ -7,69 +7,126 @@ let(:password) { Faker::Internet.password } let(:email) { Faker::Internet.email } let(:redirect) { Faker::Internet.url } - let(:query) do - <<-GRAPHQL - mutation { - userSignUp( - email: "#{email}" - name: "#{name}" - password: "#{password}" - passwordConfirmation: "#{password}" - confirmSuccessUrl: "#{redirect}" - ) { - user { - email - name + + context 'when using the user model' do + let(:query) do + <<-GRAPHQL + mutation { + userSignUp( + email: "#{email}" + name: "#{name}" + password: "#{password}" + passwordConfirmation: "#{password}" + confirmSuccessUrl: "#{redirect}" + ) { + user { + email + name + } } } - } - GRAPHQL - end + GRAPHQL + end - context 'when params are correct' do - it 'creates a new resource that requires confirmation' do - expect { post_request }.to( - change(User, :count).by(1) - .and(change(ActionMailer::Base.deliveries, :count).by(1)) - ) + context 'when params are correct' do + it 'creates a new resource that requires confirmation' do + expect { post_request }.to( + change(User, :count).by(1) + .and(change(ActionMailer::Base.deliveries, :count).by(1)) + ) - user = User.last + user = User.last - expect(user).not_to be_active_for_authentication - expect(user.confirmed_at).to be_nil - expect(user.valid_password?(password)).to be_truthy - expect(json_response[:data][:userSignUp]).to include( - user: { - email: email, - name: name - } - ) + expect(user).not_to be_active_for_authentication + expect(user.confirmed_at).to be_nil + expect(user).to be_valid_password(password) + expect(json_response[:data][:userSignUp]).to include( + user: { + email: email, + name: name + } + ) - email = Nokogiri::HTML(ActionMailer::Base.deliveries.last.body.encoded) - link = email.css('a').first + email = Nokogiri::HTML(ActionMailer::Base.deliveries.last.body.encoded) + link = email.css('a').first - expect do - get link['href'] - user.reload - end.to change { user.active_for_authentication? }.to(true) + expect do + get link['href'] + user.reload + end.to change { user.active_for_authentication? }.to(true) + end + end + + context 'when required params are missing' do + let(:email) { '' } + + it 'does *NOT* create resource a resource nor send an email' do + expect { post_request }.to( + not_change(User, :count) + .and(not_change(ActionMailer::Base.deliveries, :count)) + ) + + expect(json_response[:data][:userSignUp]).to be_nil + expect(json_response[:errors]).to containing_exactly( + hash_including( + message: "User couldn't be registered", + extensions: { code: 'USER_ERROR', detailed_errors: ["Email can't be blank"] } + ) + ) + end end end - context 'when required params are missing' do - let(:email) { '' } + context 'when using the admin model' do + let(:query) do + <<-GRAPHQL + mutation { + adminSignUp( + email: "#{email}" + password: "#{password}" + passwordConfirmation: "#{password}" + confirmSuccessUrl: "#{redirect}" + ) { + authenticable { + email + } + } + } + GRAPHQL + end - it 'does *NOT* create resource a resource nor send an email' do - expect { post_request }.to( - not_change(User, :count) - .and(not_change(ActionMailer::Base.deliveries, :count)) + before { post_request } + + it 'skips the sign up mutation' do + expect(json_response[:errors]).to contain_exactly( + hash_including(message: "Field 'adminSignUp' doesn't exist on type 'Mutation'") ) + end + end - expect(json_response[:data][:userSignUp]).to be_nil - expect(json_response[:errors]).to containing_exactly( - hash_including( - message: "User couldn't be registered", - extensions: { code: 'USER_ERROR', detailed_errors: ["Email can't be blank"] } - ) + context 'when using the guest model' do + let(:query) do + <<-GRAPHQL + mutation { + guestSignUp( + email: "#{email}" + password: "#{password}" + passwordConfirmation: "#{password}" + confirmSuccessUrl: "#{redirect}" + ) { + authenticable { + email + } + } + } + GRAPHQL + end + + before { post_request } + + it 'skips the sign up mutation' do + expect(json_response[:errors]).to contain_exactly( + hash_including(message: "Field 'guestSignUp' doesn't exist on type 'Mutation'") ) end end diff --git a/spec/requests/queries/check_password_token_spec.rb b/spec/requests/queries/check_password_token_spec.rb index a39893c7..9c949e54 100644 --- a/spec/requests/queries/check_password_token_spec.rb +++ b/spec/requests/queries/check_password_token_spec.rb @@ -5,76 +5,127 @@ let(:user) { create(:user, :confirmed) } let(:redirect_url) { 'https://google.com' } - let(:query) do - <<-GRAPHQL - query { - userCheckPasswordToken( - resetPasswordToken: "#{token}", - redirectUrl: "#{redirect_url}" - ) { - email + + context 'when using the user model' do + let(:query) do + <<-GRAPHQL + query { + userCheckPasswordToken( + resetPasswordToken: "#{token}", + redirectUrl: "#{redirect_url}" + ) { + email + } } - } - GRAPHQL - end + GRAPHQL + end - context 'when reset password token is valid' do - let(:token) { user.send(:set_reset_password_token) } + context 'when reset password token is valid' do + let(:token) { user.send(:set_reset_password_token) } - context 'when redirect_url is not provided' do - let(:redirect_url) { nil } + context 'when redirect_url is not provided' do + let(:redirect_url) { nil } - it 'returns authenticable and credentials in the headers' do - get_request + it 'returns authenticable and credentials in the headers' do + get_request - expect(response).to include_auth_headers - expect(json_response[:data][:userCheckPasswordToken]).to match( - email: user.email - ) + expect(response).to include_auth_headers + expect(json_response[:data][:userCheckPasswordToken]).to match( + email: user.email + ) + end end - end - context 'when redirect url is provided' do - it 'redirects to redirect url' do - expect do - get_request + context 'when redirect url is provided' do + it 'redirects to redirect url' do + expect do + get_request - user.reload - end.to change { user.tokens.keys.count }.from(0).to(1).and( - change(user, :allow_password_change).from(false).to(true) - ) + user.reload + end.to change { user.tokens.keys.count }.from(0).to(1).and( + change(user, :allow_password_change).from(false).to(true) + ) - expect(response).to redirect_to %r{\Ahttps://google.com} - expect(response.body).to include("client=#{user.reload.tokens.keys.first}") - expect(response.body).to include('access-token=') - expect(response.body).to include('uid=') - expect(response.body).to include('expiry=') + expect(response).to redirect_to %r{\Ahttps://google.com} + expect(response.body).to include("client=#{user.reload.tokens.keys.first}") + expect(response.body).to include('access-token=') + expect(response.body).to include('uid=') + expect(response.body).to include('expiry=') + end end - end - context 'when token has expired' do - it 'returns an expired token error' do - travel_to 10.hours.ago do - token + context 'when token has expired' do + it 'returns an expired token error' do + travel_to 10.hours.ago do + token + end + + get_request + + expect(json_response[:errors]).to contain_exactly( + hash_including(message: 'Reset password token is no longer valid.', extensions: { code: 'USER_ERROR' }) + ) end + end + end + context 'when reset password token is not found' do + let(:token) { user.send(:set_reset_password_token) + 'invalid' } + + it 'redirects to redirect url' do get_request expect(json_response[:errors]).to contain_exactly( - hash_including(message: 'Reset password token is no longer valid.', extensions: { code: 'USER_ERROR' }) + hash_including(message: 'No user found for the specified reset token.', extensions: { code: 'USER_ERROR' }) ) end end end - context 'when reset password token is not found' do - let(:token) { user.send(:set_reset_password_token) + 'invalid' } + context 'when using the admin model' do + let(:token) { 'not_important' } + let(:query) do + <<-GRAPHQL + query { + adminCheckPasswordToken( + resetPasswordToken: "#{token}", + redirectUrl: "#{redirect_url}" + ) { + email + } + } + GRAPHQL + end + + before { post_request } + + it 'skips the sign up mutation' do + expect(json_response[:errors]).to contain_exactly( + hash_including(message: "Field 'adminCheckPasswordToken' doesn't exist on type 'Query'") + ) + end + end + + context 'when using the guest model' do + let(:token) { 'not_important' } + let(:query) do + <<-GRAPHQL + query { + guestCheckPasswordToken( + resetPasswordToken: "#{token}", + redirectUrl: "#{redirect_url}" + ) { + email + } + } + GRAPHQL + end - it 'redirects to redirect url' do - get_request + before { post_request } + it 'skips the sign up mutation' do expect(json_response[:errors]).to contain_exactly( - hash_including(message: 'No user found for the specified reset token.', extensions: { code: 'USER_ERROR' }) + hash_including(message: "Field 'guestCheckPasswordToken' doesn't exist on type 'Query'") ) end end