graphql_devise 0.13.6 → 0.15.0

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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/.circleci/config.yml +118 -0
  3. data/Appraisals +39 -5
  4. data/CHANGELOG.md +64 -6
  5. data/README.md +135 -50
  6. data/Rakefile +2 -1
  7. data/app/controllers/graphql_devise/concerns/additional_controller_methods.rb +72 -0
  8. data/app/controllers/graphql_devise/concerns/set_user_by_token.rb +5 -27
  9. data/app/controllers/graphql_devise/graphql_controller.rb +1 -1
  10. data/app/helpers/graphql_devise/mailer_helper.rb +2 -2
  11. data/app/models/graphql_devise/concerns/additional_model_methods.rb +21 -0
  12. data/app/models/graphql_devise/concerns/model.rb +6 -9
  13. data/app/views/graphql_devise/mailer/reset_password_instructions.html.erb +7 -1
  14. data/config/locales/en.yml +1 -0
  15. data/docs/usage/reset_password_flow.md +90 -0
  16. data/graphql_devise.gemspec +2 -2
  17. data/lib/generators/graphql_devise/install_generator.rb +1 -1
  18. data/lib/graphql_devise.rb +20 -6
  19. data/lib/graphql_devise/concerns/controller_methods.rb +3 -3
  20. data/lib/graphql_devise/default_operations/mutations.rb +10 -6
  21. data/lib/graphql_devise/mount_method/operation_preparer.rb +6 -6
  22. data/lib/graphql_devise/mount_method/operation_preparers/custom_operation_preparer.rb +6 -4
  23. data/lib/graphql_devise/mount_method/operation_preparers/default_operation_preparer.rb +6 -4
  24. data/lib/graphql_devise/mount_method/operation_preparers/{resource_name_setter.rb → resource_klass_setter.rb} +4 -4
  25. data/lib/graphql_devise/mutations/send_password_reset_with_token.rb +37 -0
  26. data/lib/graphql_devise/mutations/update_password_with_token.rb +38 -0
  27. data/lib/graphql_devise/resolvers/confirm_account.rb +1 -1
  28. data/lib/graphql_devise/resource_loader.rb +26 -11
  29. data/lib/graphql_devise/schema_plugin.rb +35 -16
  30. data/lib/graphql_devise/version.rb +1 -1
  31. data/spec/dummy/app/controllers/api/v1/graphql_controller.rb +13 -2
  32. data/spec/dummy/app/graphql/dummy_schema.rb +4 -3
  33. data/spec/dummy/app/graphql/mutations/reset_admin_password_with_token.rb +13 -0
  34. data/spec/dummy/config/routes.rb +4 -2
  35. data/spec/dummy/db/migrate/20200623003142_create_schema_users.rb +0 -1
  36. data/spec/dummy/db/schema.rb +0 -1
  37. data/spec/generators/graphql_devise/install_generator_spec.rb +1 -1
  38. data/spec/graphql/user_queries_spec.rb +120 -0
  39. data/spec/requests/graphql_controller_spec.rb +12 -11
  40. data/spec/requests/mutations/send_password_reset_with_token_spec.rb +78 -0
  41. data/spec/requests/mutations/update_password_with_token_spec.rb +119 -0
  42. data/spec/requests/queries/check_password_token_spec.rb +1 -1
  43. data/spec/requests/queries/introspection_query_spec.rb +149 -0
  44. data/spec/requests/user_controller_spec.rb +29 -9
  45. data/spec/services/mount_method/operation_preparer_spec.rb +5 -5
  46. data/spec/services/mount_method/operation_preparers/custom_operation_preparer_spec.rb +5 -5
  47. data/spec/services/mount_method/operation_preparers/default_operation_preparer_spec.rb +5 -5
  48. data/spec/services/mount_method/operation_preparers/{resource_name_setter_spec.rb → resource_klass_setter_spec.rb} +6 -6
  49. data/spec/services/resource_loader_spec.rb +5 -5
  50. data/spec/support/contexts/graphql_request.rb +11 -3
  51. data/spec/support/contexts/schema_test.rb +14 -0
  52. metadata +31 -14
  53. data/.travis.yml +0 -79
@@ -4,19 +4,21 @@ module GraphqlDevise
4
4
  module MountMethod
5
5
  module OperationPreparers
6
6
  class CustomOperationPreparer
7
- def initialize(selected_keys:, custom_operations:, mapping_name:)
7
+ def initialize(selected_keys:, custom_operations:, model:)
8
8
  @selected_keys = selected_keys
9
9
  @custom_operations = custom_operations
10
- @mapping_name = mapping_name
10
+ @model = model
11
11
  end
12
12
 
13
13
  def call
14
+ mapping_name = GraphqlDevise.to_mapping_name(@model)
15
+
14
16
  @custom_operations.slice(*@selected_keys).each_with_object({}) do |(action, operation), result|
15
- mapped_action = "#{@mapping_name}_#{action}"
17
+ mapped_action = "#{mapping_name}_#{action}"
16
18
 
17
19
  result[mapped_action.to_sym] = [
18
20
  OperationPreparers::GqlNameSetter.new(mapped_action),
19
- OperationPreparers::ResourceNameSetter.new(@mapping_name)
21
+ OperationPreparers::ResourceKlassSetter.new(@model)
20
22
  ].reduce(operation) { |prepared_operation, preparer| preparer.call(prepared_operation) }
21
23
  end
22
24
  end
@@ -4,23 +4,25 @@ module GraphqlDevise
4
4
  module MountMethod
5
5
  module OperationPreparers
6
6
  class DefaultOperationPreparer
7
- def initialize(selected_operations:, custom_keys:, mapping_name:, preparer:)
7
+ def initialize(selected_operations:, custom_keys:, model:, preparer:)
8
8
  @selected_operations = selected_operations
9
9
  @custom_keys = custom_keys
10
- @mapping_name = mapping_name
10
+ @model = model
11
11
  @preparer = preparer
12
12
  end
13
13
 
14
14
  def call
15
+ mapping_name = GraphqlDevise.to_mapping_name(@model)
16
+
15
17
  @selected_operations.except(*@custom_keys).each_with_object({}) do |(action, operation_info), result|
16
- mapped_action = "#{@mapping_name}_#{action}"
18
+ mapped_action = "#{mapping_name}_#{action}"
17
19
  operation = operation_info[:klass]
18
20
  options = operation_info.except(:klass)
19
21
 
20
22
  result[mapped_action.to_sym] = [
21
23
  OperationPreparers::GqlNameSetter.new(mapped_action),
22
24
  @preparer,
23
- OperationPreparers::ResourceNameSetter.new(@mapping_name)
25
+ OperationPreparers::ResourceKlassSetter.new(@model)
24
26
  ].reduce(child_class(operation)) do |prepared_operation, preparer|
25
27
  preparer.call(prepared_operation, **options)
26
28
  end
@@ -3,13 +3,13 @@
3
3
  module GraphqlDevise
4
4
  module MountMethod
5
5
  module OperationPreparers
6
- class ResourceNameSetter
7
- def initialize(name)
8
- @name = name
6
+ class ResourceKlassSetter
7
+ def initialize(klass)
8
+ @klass = klass
9
9
  end
10
10
 
11
11
  def call(operation, **)
12
- operation.instance_variable_set(:@resource_name, @name)
12
+ operation.instance_variable_set(:@resource_klass, @klass)
13
13
 
14
14
  operation
15
15
  end
@@ -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
@@ -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
@@ -32,7 +32,7 @@ module GraphqlDevise
32
32
  end
33
33
 
34
34
  controller.redirect_to(redirect_to_link)
35
- { authenticatable: resource }
35
+ resource
36
36
  else
37
37
  raise_user_error(I18n.t('graphql_devise.confirmations.invalid_token'))
38
38
  end
@@ -10,12 +10,27 @@ module GraphqlDevise
10
10
  end
11
11
 
12
12
  def call(query, mutation)
13
- mapping_name = @resource.to_s.underscore.tr('/', '_').to_sym
14
-
15
13
  # clean_options responds to all keys defined in GraphqlDevise::MountMethod::SUPPORTED_OPTIONS
16
14
  clean_options = GraphqlDevise::MountMethod::OptionSanitizer.new(@options).call!
17
15
 
18
- return clean_options if GraphqlDevise.resource_mounted?(mapping_name) && @routing
16
+ model = if @resource.is_a?(String)
17
+ ActiveSupport::Deprecation.warn(<<-DEPRECATION.strip_heredoc, caller)
18
+ Providing a String as the model you want to mount is deprecated and will be removed in a future version of
19
+ this gem. Please use the actual model constant instead.
20
+
21
+ EXAMPLE
22
+
23
+ GraphqlDevise::ResourceLoader.new(User) # instead of GraphqlDevise::ResourceLoader.new('User')
24
+
25
+ mount_graphql_devise_for User # instead of mount_graphql_devise_for 'User'
26
+ DEPRECATION
27
+ @resource.constantize
28
+ else
29
+ @resource
30
+ end
31
+
32
+ # Necesary when mounting a resource via route file as Devise forces the reloading of routes
33
+ return clean_options if GraphqlDevise.resource_mounted?(model) && @routing
19
34
 
20
35
  validate_options!(clean_options)
21
36
 
@@ -23,7 +38,7 @@ module GraphqlDevise
23
38
  "Types::#{@resource}Type".safe_constantize ||
24
39
  GraphqlDevise::Types::AuthenticatableType
25
40
 
26
- prepared_mutations = prepare_mutations(mapping_name, clean_options, authenticatable_type)
41
+ prepared_mutations = prepare_mutations(model, clean_options, authenticatable_type)
27
42
 
28
43
  if prepared_mutations.any? && mutation.blank?
29
44
  raise GraphqlDevise::Error, 'You need to provide a mutation type unless all mutations are skipped'
@@ -33,7 +48,7 @@ module GraphqlDevise
33
48
  mutation.field(action, mutation: prepared_mutation, authenticate: false)
34
49
  end
35
50
 
36
- prepared_resolvers = prepare_resolvers(mapping_name, clean_options, authenticatable_type)
51
+ prepared_resolvers = prepare_resolvers(model, clean_options, authenticatable_type)
37
52
 
38
53
  if prepared_resolvers.any? && query.blank?
39
54
  raise GraphqlDevise::Error, 'You need to provide a query type unless all queries are skipped'
@@ -43,17 +58,17 @@ module GraphqlDevise
43
58
  query.field(action, resolver: resolver, authenticate: false)
44
59
  end
45
60
 
46
- GraphqlDevise.add_mapping(mapping_name, @resource)
47
- GraphqlDevise.mount_resource(mapping_name) if @routing
61
+ GraphqlDevise.add_mapping(GraphqlDevise.to_mapping_name(@resource).to_sym, @resource)
62
+ GraphqlDevise.mount_resource(model) if @routing
48
63
 
49
64
  clean_options
50
65
  end
51
66
 
52
67
  private
53
68
 
54
- def prepare_resolvers(mapping_name, clean_options, authenticatable_type)
69
+ def prepare_resolvers(model, clean_options, authenticatable_type)
55
70
  GraphqlDevise::MountMethod::OperationPreparer.new(
56
- mapping_name: mapping_name,
71
+ model: model,
57
72
  custom: clean_options.operations,
58
73
  additional_operations: clean_options.additional_queries,
59
74
  preparer: GraphqlDevise::MountMethod::OperationPreparers::ResolverTypeSetter.new(authenticatable_type),
@@ -63,9 +78,9 @@ module GraphqlDevise
63
78
  ).call
64
79
  end
65
80
 
66
- def prepare_mutations(mapping_name, clean_options, authenticatable_type)
81
+ def prepare_mutations(model, clean_options, authenticatable_type)
67
82
  GraphqlDevise::MountMethod::OperationPreparer.new(
68
- mapping_name: mapping_name,
83
+ model: model,
69
84
  custom: clean_options.operations,
70
85
  additional_operations: clean_options.additional_mutations,
71
86
  preparer: GraphqlDevise::MountMethod::OperationPreparers::MutationFieldSetter.new(authenticatable_type),
@@ -2,18 +2,20 @@
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
15
18
  load_fields
16
- reconfigure_warden!
17
19
  end
18
20
 
19
21
  def use(schema_definition)
@@ -24,13 +26,26 @@ module GraphqlDevise
24
26
  # Authenticate only root level queries
25
27
  return yield unless event == 'execute_field' && path(trace_data).count == 1
26
28
 
27
- field = traced_field(trace_data)
28
- provided_value = authenticate_option(field, trace_data)
29
- context = set_current_resource(context_from_data(trace_data))
29
+ field = traced_field(trace_data)
30
+ auth_required = authenticate_option(field, trace_data)
31
+ context = context_from_data(trace_data)
30
32
 
31
- if !provided_value.nil?
32
- raise_on_missing_resource(context, field) if provided_value
33
- elsif @authenticate_default
33
+ if context.key?(:resource_name)
34
+ ActiveSupport::Deprecation.warn(<<-DEPRECATION.strip_heredoc, caller)
35
+ Providing `resource_name` as part of the GQL context, or doing so by using the `graphql_context(resource_name)`
36
+ method on your controller is deprecated and will be removed in a future version of this gem.
37
+ Please use `gql_devise_context` in you controller instead.
38
+
39
+ EXAMPLE
40
+ include GraphqlDevise::Concerns::SetUserByToken
41
+
42
+ DummySchema.execute(params[:query], context: gql_devise_context(User))
43
+ DummySchema.execute(params[:query], context: gql_devise_context(User, Admin))
44
+ DEPRECATION
45
+ end
46
+
47
+ if auth_required && !(public_introspection && introspection_field?(field))
48
+ context = set_current_resource(context)
34
49
  raise_on_missing_resource(context, field)
35
50
  end
36
51
 
@@ -39,10 +54,13 @@ module GraphqlDevise
39
54
 
40
55
  private
41
56
 
57
+ attr_reader :public_introspection
58
+
42
59
  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|
60
+ controller = context[:controller]
61
+ resource_names = Array(context[:resource_name])
62
+
63
+ context[:current_resource] ||= resource_names.find do |resource_name|
46
64
  unless Devise.mappings.key?(resource_name)
47
65
  raise(
48
66
  GraphqlDevise::Error,
@@ -88,16 +106,13 @@ module GraphqlDevise
88
106
  end
89
107
 
90
108
  def authenticate_option(field, trace_data)
91
- if trace_data[:context]
109
+ auth_required = if trace_data[:context]
92
110
  field.metadata[:authenticate]
93
111
  else
94
112
  field.graphql_definition.metadata[:authenticate]
95
113
  end
96
- end
97
114
 
98
- def reconfigure_warden!
99
- Devise.class_variable_set(:@@warden_configured, nil)
100
- Devise.configure_warden!
115
+ auth_required.nil? ? @authenticate_default : auth_required
101
116
  end
102
117
 
103
118
  def load_fields
@@ -107,6 +122,10 @@ module GraphqlDevise
107
122
  resource_loader.call(@query, @mutation)
108
123
  end
109
124
  end
125
+
126
+ def introspection_field?(field)
127
+ INTROSPECTION_FIELDS.include?(field.name)
128
+ end
110
129
  end
111
130
  end
112
131
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GraphqlDevise
4
- VERSION = '0.13.6'.freeze
4
+ VERSION = '0.15.0'.freeze
5
5
  end
@@ -6,19 +6,30 @@ module Api
6
6
  include GraphqlDevise::Concerns::SetUserByToken
7
7
 
8
8
  def graphql
9
- result = DummySchema.execute(params[:query], execute_params(params))
9
+ result = DummySchema.execute(params[:query], **execute_params(params))
10
10
 
11
11
  render json: result unless performed?
12
12
  end
13
13
 
14
14
  def interpreter
15
- render json: InterpreterSchema.execute(params[:query], execute_params(params))
15
+ render json: InterpreterSchema.execute(params[:query], **execute_params(params))
16
16
  end
17
17
 
18
18
  def failing_resource_name
19
19
  render json: DummySchema.execute(params[:query], context: graphql_context([:user, :fail]))
20
20
  end
21
21
 
22
+ def controller_auth
23
+ result = DummySchema.execute(
24
+ params[:query],
25
+ operation_name: params[:operationName],
26
+ variables: ensure_hash(params[:variables]),
27
+ context: gql_devise_context(SchemaUser, User)
28
+ )
29
+
30
+ render json: result unless performed?
31
+ end
32
+
22
33
  private
23
34
 
24
35
  def execute_params(item)
@@ -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
@@ -11,11 +11,12 @@ Rails.application.routes.draw do
11
11
  }
12
12
 
13
13
  mount_graphql_devise_for(
14
- 'Admin',
14
+ Admin,
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
  )
@@ -36,4 +37,5 @@ Rails.application.routes.draw do
36
37
  post '/api/v1/graphql', to: 'api/v1/graphql#graphql'
37
38
  post '/api/v1/interpreter', to: 'api/v1/graphql#interpreter'
38
39
  post '/api/v1/failing', to: 'api/v1/graphql#failing_resource_name'
40
+ post '/api/v1/controller_auth', to: 'api/v1/graphql#controller_auth'
39
41
  end
@@ -41,6 +41,5 @@ class CreateSchemaUsers < ActiveRecord::Migration[6.0]
41
41
  add_index :schema_users, [:uid, :provider], unique: true
42
42
  add_index :schema_users, :reset_password_token, unique: true
43
43
  add_index :schema_users, :confirmation_token, unique: true
44
- add_index :schema_users, :unlock_token, unique: true
45
44
  end
46
45
  end