graphql_devise 0.13.4 → 0.14.2

Sign up to get free protection for your applications and to get access to all the features.
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