graphql_devise 0.13.4 → 0.14.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/.coveralls.yml +1 -0
  3. data/.gitlab-ci.yml +109 -0
  4. data/Appraisals +39 -5
  5. data/CHANGELOG.md +47 -0
  6. data/README.md +20 -9
  7. data/app/views/graphql_devise/mailer/reset_password_instructions.html.erb +7 -1
  8. data/config/locales/en.yml +2 -1
  9. data/docs/usage/reset_password_flow.md +90 -0
  10. data/graphql_devise.gemspec +1 -1
  11. data/lib/graphql_devise/concerns/controller_methods.rb +7 -1
  12. data/lib/graphql_devise/default_operations/mutations.rb +10 -6
  13. data/lib/graphql_devise/mutations/resend_confirmation.rb +2 -0
  14. data/lib/graphql_devise/mutations/send_password_reset.rb +2 -0
  15. data/lib/graphql_devise/mutations/send_password_reset_with_token.rb +37 -0
  16. data/lib/graphql_devise/mutations/sign_up.rb +1 -3
  17. data/lib/graphql_devise/mutations/update_password_with_token.rb +38 -0
  18. data/lib/graphql_devise/resolvers/check_password_token.rb +1 -0
  19. data/lib/graphql_devise/resolvers/confirm_account.rb +2 -0
  20. data/lib/graphql_devise/schema_plugin.rb +22 -11
  21. data/lib/graphql_devise/version.rb +1 -1
  22. data/spec/dummy/app/graphql/dummy_schema.rb +4 -3
  23. data/spec/dummy/app/graphql/mutations/reset_admin_password_with_token.rb +13 -0
  24. data/spec/dummy/config/initializers/devise_token_auth.rb +2 -0
  25. data/spec/dummy/config/routes.rb +2 -1
  26. data/spec/graphql/user_queries_spec.rb +118 -0
  27. data/spec/requests/mutations/additional_mutations_spec.rb +0 -1
  28. data/spec/requests/mutations/resend_confirmation_spec.rb +16 -1
  29. data/spec/requests/mutations/send_password_reset_spec.rb +16 -1
  30. data/spec/requests/mutations/send_password_reset_with_token_spec.rb +78 -0
  31. data/spec/requests/mutations/sign_up_spec.rb +19 -1
  32. data/spec/requests/mutations/update_password_with_token_spec.rb +119 -0
  33. data/spec/requests/queries/check_password_token_spec.rb +16 -1
  34. data/spec/requests/queries/confirm_account_spec.rb +17 -2
  35. data/spec/requests/queries/introspection_query_spec.rb +149 -0
  36. data/spec/requests/user_controller_spec.rb +9 -9
  37. data/spec/support/contexts/schema_test.rb +14 -0
  38. metadata +25 -9
  39. data/.travis.yml +0 -79
@@ -28,7 +28,7 @@ Gem::Specification.new do |spec|
28
28
  spec.required_ruby_version = '>= 2.2.0'
29
29
 
30
30
  spec.add_dependency 'devise_token_auth', '>= 0.1.43', '< 2.0'
31
- spec.add_dependency 'graphql', '>= 1.8', '< 1.12.0'
31
+ spec.add_dependency 'graphql', '>= 1.8', '< 1.13.0'
32
32
  spec.add_dependency 'rails', '>= 4.2', '< 6.2'
33
33
 
34
34
  spec.add_development_dependency 'appraisal'
@@ -7,6 +7,12 @@ module GraphqlDevise
7
7
 
8
8
  private
9
9
 
10
+ def check_redirect_url_whitelist!(redirect_url)
11
+ if blacklisted_redirect_url?(redirect_url)
12
+ raise_user_error(I18n.t('graphql_devise.redirect_url_not_allowed', redirect_url: redirect_url))
13
+ end
14
+ end
15
+
10
16
  def raise_user_error(message)
11
17
  raise GraphqlDevise::UserError, message
12
18
  end
@@ -90,7 +96,7 @@ module GraphqlDevise
90
96
  end
91
97
 
92
98
  def find_resource(field, value)
93
- if resource_class.try(:connection_config).try(:[], :adapter).try(:include?, 'mysql')
99
+ if resource_class.connection.adapter_name.downcase.include?('mysql')
94
100
  # fix for mysql default case insensitivity
95
101
  resource_class.where("BINARY #{field} = ? AND provider= ?", value, provider).first
96
102
  elsif Gem::Version.new(DeviseTokenAuth::VERSION) < Gem::Version.new('1.1.0')
@@ -5,18 +5,22 @@ require 'graphql_devise/mutations/login'
5
5
  require 'graphql_devise/mutations/logout'
6
6
  require 'graphql_devise/mutations/resend_confirmation'
7
7
  require 'graphql_devise/mutations/send_password_reset'
8
+ require 'graphql_devise/mutations/send_password_reset_with_token'
8
9
  require 'graphql_devise/mutations/sign_up'
9
10
  require 'graphql_devise/mutations/update_password'
11
+ require 'graphql_devise/mutations/update_password_with_token'
10
12
 
11
13
  module GraphqlDevise
12
14
  module DefaultOperations
13
15
  MUTATIONS = {
14
- login: { klass: GraphqlDevise::Mutations::Login, authenticatable: true },
15
- logout: { klass: GraphqlDevise::Mutations::Logout, authenticatable: true },
16
- sign_up: { klass: GraphqlDevise::Mutations::SignUp, authenticatable: true },
17
- update_password: { klass: GraphqlDevise::Mutations::UpdatePassword, authenticatable: true },
18
- send_password_reset: { klass: GraphqlDevise::Mutations::SendPasswordReset, authenticatable: false },
19
- resend_confirmation: { klass: GraphqlDevise::Mutations::ResendConfirmation, authenticatable: false }
16
+ login: { klass: GraphqlDevise::Mutations::Login, authenticatable: true },
17
+ logout: { klass: GraphqlDevise::Mutations::Logout, authenticatable: true },
18
+ sign_up: { klass: GraphqlDevise::Mutations::SignUp, authenticatable: true },
19
+ update_password: { klass: GraphqlDevise::Mutations::UpdatePassword, authenticatable: true },
20
+ update_password_with_token: { klass: GraphqlDevise::Mutations::UpdatePasswordWithToken, authenticatable: true },
21
+ send_password_reset: { klass: GraphqlDevise::Mutations::SendPasswordReset, authenticatable: false },
22
+ send_password_reset_with_token: { klass: GraphqlDevise::Mutations::SendPasswordResetWithToken, authenticatable: false },
23
+ resend_confirmation: { klass: GraphqlDevise::Mutations::ResendConfirmation, authenticatable: false }
20
24
  }.freeze
21
25
  end
22
26
  end
@@ -9,6 +9,8 @@ module GraphqlDevise
9
9
  field :message, String, null: false
10
10
 
11
11
  def resolve(email:, redirect_url:)
12
+ check_redirect_url_whitelist!(redirect_url)
13
+
12
14
  resource = find_confirmable_resource(email)
13
15
 
14
16
  if resource
@@ -9,6 +9,8 @@ module GraphqlDevise
9
9
  field :message, String, null: false
10
10
 
11
11
  def resolve(email:, redirect_url:)
12
+ check_redirect_url_whitelist!(redirect_url)
13
+
12
14
  resource = find_resource(:email, get_case_insensitive_field(:email, email))
13
15
 
14
16
  if resource
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphqlDevise
4
+ module Mutations
5
+ class SendPasswordResetWithToken < Base
6
+ argument :email, String, required: true
7
+ argument :redirect_url, String, required: true
8
+
9
+ field :message, String, null: false
10
+
11
+ def resolve(email:, redirect_url:)
12
+ check_redirect_url_whitelist!(redirect_url)
13
+
14
+ resource = find_resource(:email, get_case_insensitive_field(:email, email))
15
+
16
+ if resource
17
+ yield resource if block_given?
18
+
19
+ resource.send_reset_password_instructions(
20
+ email: email,
21
+ provider: 'email',
22
+ redirect_url: redirect_url,
23
+ template_path: ['graphql_devise/mailer']
24
+ )
25
+
26
+ if resource.errors.empty?
27
+ { message: I18n.t('graphql_devise.passwords.send_instructions') }
28
+ else
29
+ raise_user_error_list(I18n.t('graphql_devise.invalid_resource'), errors: resource.errors.full_messages)
30
+ end
31
+ else
32
+ raise_user_error(I18n.t('graphql_devise.user_not_found'))
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -22,9 +22,7 @@ module GraphqlDevise
22
22
  raise_user_error(I18n.t('graphql_devise.registrations.missing_confirm_redirect_url'))
23
23
  end
24
24
 
25
- if blacklisted_redirect_url?(redirect_url)
26
- raise_user_error(I18n.t('graphql_devise.registrations.redirect_url_not_allowed', redirect_url: redirect_url))
27
- end
25
+ check_redirect_url_whitelist!(redirect_url)
28
26
 
29
27
  resource.skip_confirmation_notification! if resource.respond_to?(:skip_confirmation_notification!)
30
28
 
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphqlDevise
4
+ module Mutations
5
+ class UpdatePasswordWithToken < Base
6
+ argument :password, String, required: true
7
+ argument :password_confirmation, String, required: true
8
+ argument :reset_password_token, String, required: true
9
+
10
+ field :credentials,
11
+ GraphqlDevise::Types::CredentialType,
12
+ null: true,
13
+ description: 'Authentication credentials. Resource must be signed_in for credentials to be returned.'
14
+
15
+ def resolve(reset_password_token:, **attrs)
16
+ raise_user_error(I18n.t('graphql_devise.passwords.password_recovery_disabled')) unless recoverable_enabled?
17
+
18
+ resource = resource_class.with_reset_password_token(reset_password_token)
19
+ raise_user_error(I18n.t('graphql_devise.passwords.reset_token_not_found')) if resource.blank?
20
+ raise_user_error(I18n.t('graphql_devise.passwords.reset_token_expired')) unless resource.reset_password_period_valid?
21
+
22
+ if resource.update(attrs)
23
+ yield resource if block_given?
24
+
25
+ response_payload = { authenticatable: resource }
26
+ response_payload[:credentials] = set_auth_headers(resource) if controller.signed_in?(resource_name)
27
+
28
+ response_payload
29
+ else
30
+ raise_user_error_list(
31
+ I18n.t('graphql_devise.passwords.update_password_error'),
32
+ errors: resource.errors.full_messages
33
+ )
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -27,6 +27,7 @@ module GraphqlDevise
27
27
  )
28
28
 
29
29
  if redirect_url.present?
30
+ check_redirect_url_whitelist!(redirect_url)
30
31
  controller.redirect_to(resource.build_auth_url(redirect_url, built_redirect_headers))
31
32
  else
32
33
  set_auth_headers(resource)
@@ -7,6 +7,8 @@ module GraphqlDevise
7
7
  argument :redirect_url, String, required: true
8
8
 
9
9
  def resolve(confirmation_token:, redirect_url:)
10
+ check_redirect_url_whitelist!(redirect_url)
11
+
10
12
  resource = resource_class.confirm_by_token(confirmation_token)
11
13
 
12
14
  if resource.errors.empty?
@@ -2,13 +2,16 @@
2
2
 
3
3
  module GraphqlDevise
4
4
  class SchemaPlugin
5
+ # NOTE: Based on GQL-Ruby docs https://graphql-ruby.org/schema/introspection.html
6
+ INTROSPECTION_FIELDS = ['__schema', '__type', '__typename']
5
7
  DEFAULT_NOT_AUTHENTICATED = ->(field) { raise GraphqlDevise::AuthenticationError, "#{field} field requires authentication" }
6
8
 
7
- def initialize(query: nil, mutation: nil, authenticate_default: true, resource_loaders: [], unauthenticated_proc: DEFAULT_NOT_AUTHENTICATED)
9
+ def initialize(query: nil, mutation: nil, authenticate_default: true, public_introspection: !Rails.env.production?, resource_loaders: [], unauthenticated_proc: DEFAULT_NOT_AUTHENTICATED)
8
10
  @query = query
9
11
  @mutation = mutation
10
12
  @resource_loaders = resource_loaders
11
13
  @authenticate_default = authenticate_default
14
+ @public_introspection = public_introspection
12
15
  @unauthenticated_proc = unauthenticated_proc
13
16
 
14
17
  # Must happen on initialize so operations are loaded before the types are added to the schema on GQL < 1.10
@@ -24,13 +27,12 @@ module GraphqlDevise
24
27
  # Authenticate only root level queries
25
28
  return yield unless event == 'execute_field' && path(trace_data).count == 1
26
29
 
27
- field = traced_field(trace_data)
28
- provided_value = authenticate_option(field, trace_data)
29
- context = set_current_resource(context_from_data(trace_data))
30
+ field = traced_field(trace_data)
31
+ auth_required = authenticate_option(field, trace_data)
32
+ context = context_from_data(trace_data)
30
33
 
31
- if !provided_value.nil?
32
- raise_on_missing_resource(context, field) if provided_value
33
- elsif @authenticate_default
34
+ if auth_required && !(public_introspection && introspection_field?(field))
35
+ context = set_current_resource(context)
34
36
  raise_on_missing_resource(context, field)
35
37
  end
36
38
 
@@ -39,10 +41,13 @@ module GraphqlDevise
39
41
 
40
42
  private
41
43
 
44
+ attr_reader :public_introspection
45
+
42
46
  def set_current_resource(context)
43
- controller = context[:controller]
44
- resource_names = Array(context[:resource_name])
45
- context[:current_resource] = resource_names.find do |resource_name|
47
+ controller = context[:controller]
48
+ resource_names = Array(context[:resource_name])
49
+
50
+ context[:current_resource] ||= resource_names.find do |resource_name|
46
51
  unless Devise.mappings.key?(resource_name)
47
52
  raise(
48
53
  GraphqlDevise::Error,
@@ -88,11 +93,13 @@ module GraphqlDevise
88
93
  end
89
94
 
90
95
  def authenticate_option(field, trace_data)
91
- if trace_data[:context]
96
+ auth_required = if trace_data[:context]
92
97
  field.metadata[:authenticate]
93
98
  else
94
99
  field.graphql_definition.metadata[:authenticate]
95
100
  end
101
+
102
+ auth_required.nil? ? @authenticate_default : auth_required
96
103
  end
97
104
 
98
105
  def reconfigure_warden!
@@ -107,6 +114,10 @@ module GraphqlDevise
107
114
  resource_loader.call(@query, @mutation)
108
115
  end
109
116
  end
117
+
118
+ def introspection_field?(field)
119
+ INTROSPECTION_FIELDS.include?(field.name)
120
+ end
110
121
  end
111
122
  end
112
123
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GraphqlDevise
4
- VERSION = '0.13.4'.freeze
4
+ VERSION = '0.14.2'.freeze
5
5
  end
@@ -2,9 +2,10 @@
2
2
 
3
3
  class DummySchema < GraphQL::Schema
4
4
  use GraphqlDevise::SchemaPlugin.new(
5
- query: Types::QueryType,
6
- mutation: Types::MutationType,
7
- resource_loaders: [
5
+ query: Types::QueryType,
6
+ mutation: Types::MutationType,
7
+ public_introspection: true,
8
+ resource_loaders: [
8
9
  GraphqlDevise::ResourceLoader.new(
9
10
  'User',
10
11
  only: [
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutations
4
+ class ResetAdminPasswordWithToken < GraphqlDevise::Mutations::UpdatePasswordWithToken
5
+ field :authenticatable, Types::AdminType, null: false
6
+
7
+ def resolve(reset_password_token:, **attrs)
8
+ super do |admin|
9
+ controller.sign_in(admin)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -39,6 +39,8 @@ DeviseTokenAuth.setup do |config|
39
39
 
40
40
  config.default_confirm_success_url = 'https://google.com'
41
41
 
42
+ config.redirect_whitelist = ['https://google.com']
43
+
42
44
  # By default we will use callbacks for single omniauth.
43
45
  # It depends on fields like email, provider and uid.
44
46
  # config.default_callbacks = true
@@ -15,7 +15,8 @@ Rails.application.routes.draw do
15
15
  authenticatable_type: Types::CustomAdminType,
16
16
  skip: [:sign_up, :check_password_token],
17
17
  operations: {
18
- confirm_account: Resolvers::ConfirmAdminAccount
18
+ confirm_account: Resolvers::ConfirmAdminAccount,
19
+ update_password_with_token: Mutations::ResetAdminPasswordWithToken
19
20
  },
20
21
  at: '/api/v1/admin/graphql_auth'
21
22
  )
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails_helper'
4
+
5
+ RSpec.describe 'Users controller specs' do
6
+ include_context 'with graphql schema test'
7
+
8
+ let(:schema) { DummySchema }
9
+ let(:user) { create(:user, :confirmed) }
10
+ let(:field) { 'privateField' }
11
+ let(:public_message) { 'Field does not require authentication' }
12
+ let(:private_message) { 'Field will always require authentication' }
13
+ let(:private_error) do
14
+ {
15
+ message: "#{field} field requires authentication",
16
+ extensions: { code: 'AUTHENTICATION_ERROR' }
17
+ }
18
+ end
19
+
20
+ describe 'publicField' do
21
+ let(:query) do
22
+ <<-GRAPHQL
23
+ query {
24
+ publicField
25
+ }
26
+ GRAPHQL
27
+ end
28
+
29
+ context 'when using a regular schema' do
30
+ it 'does not require authentication' do
31
+ expect(response[:data][:publicField]).to eq(public_message)
32
+ end
33
+ end
34
+ end
35
+
36
+ describe 'privateField' do
37
+ let(:query) do
38
+ <<-GRAPHQL
39
+ query {
40
+ privateField
41
+ }
42
+ GRAPHQL
43
+ end
44
+
45
+ context 'when using a regular schema' do
46
+ context 'when user is authenticated' do
47
+ let(:resource) { user }
48
+
49
+ it 'allows to perform the query' do
50
+ expect(response[:data][:privateField]).to eq(private_message)
51
+ end
52
+
53
+ context 'when using a SchemaUser' do
54
+ let(:resource) { create(:schema_user, :confirmed) }
55
+
56
+ it 'allows to perform the query' do
57
+ expect(response[:data][:privateField]).to eq(private_message)
58
+ end
59
+ end
60
+ end
61
+ end
62
+
63
+ context 'when using an interpreter schema' do
64
+ let(:schema) { InterpreterSchema }
65
+
66
+ context 'when user is authenticated' do
67
+ let(:resource) { user }
68
+
69
+ it 'allows to perform the query' do
70
+ expect(response[:data][:privateField]).to eq(private_message)
71
+ end
72
+ end
73
+ end
74
+ end
75
+
76
+ describe 'user' do
77
+ let(:user_data) { { email: user.email, id: user.id } }
78
+ let(:query) do
79
+ <<-GRAPHQL
80
+ query {
81
+ user(id: #{user.id}) {
82
+ id
83
+ email
84
+ }
85
+ }
86
+ GRAPHQL
87
+ end
88
+
89
+ context 'when using a regular schema' do
90
+ context 'when user is authenticated' do
91
+ let(:resource) { user }
92
+
93
+ it 'allows to perform the query' do
94
+ expect(response[:data][:user]).to match(**user_data)
95
+ end
96
+ end
97
+ end
98
+
99
+ context 'when using an interpreter schema' do
100
+ let(:schema) { InterpreterSchema }
101
+
102
+ context 'when user is authenticated' do
103
+ let(:resource) { user }
104
+
105
+ it 'allows to perform the query' do
106
+ expect(response[:data][:user]).to match(**user_data)
107
+ end
108
+ end
109
+
110
+ context 'when user is not authenticated' do
111
+ # Interpreter schema fields are public unless specified otherwise (plugin setting)
112
+ it 'allows to perform the query' do
113
+ expect(response[:data][:user]).to match(**user_data)
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end