graphql_devise 0.13.5 → 0.14.3

Sign up to get free protection for your applications and to get access to all the features.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/.circleci/config.yml +118 -0
  3. data/Appraisals +39 -5
  4. data/CHANGELOG.md +57 -6
  5. data/README.md +27 -7
  6. data/Rakefile +2 -1
  7. data/app/controllers/graphql_devise/graphql_controller.rb +1 -1
  8. data/app/views/graphql_devise/mailer/reset_password_instructions.html.erb +7 -1
  9. data/config/locales/en.yml +2 -1
  10. data/docs/usage/reset_password_flow.md +90 -0
  11. data/graphql_devise.gemspec +2 -2
  12. data/lib/graphql_devise/concerns/controller_methods.rb +6 -0
  13. data/lib/graphql_devise/default_operations/mutations.rb +10 -6
  14. data/lib/graphql_devise/mutations/resend_confirmation.rb +2 -0
  15. data/lib/graphql_devise/mutations/send_password_reset.rb +2 -0
  16. data/lib/graphql_devise/mutations/send_password_reset_with_token.rb +37 -0
  17. data/lib/graphql_devise/mutations/sign_up.rb +1 -3
  18. data/lib/graphql_devise/mutations/update_password_with_token.rb +38 -0
  19. data/lib/graphql_devise/resolvers/check_password_token.rb +1 -0
  20. data/lib/graphql_devise/resolvers/confirm_account.rb +2 -0
  21. data/lib/graphql_devise/schema_plugin.rb +22 -11
  22. data/lib/graphql_devise/version.rb +1 -1
  23. data/spec/dummy/app/controllers/api/v1/graphql_controller.rb +2 -2
  24. data/spec/dummy/app/graphql/dummy_schema.rb +4 -3
  25. data/spec/dummy/app/graphql/mutations/reset_admin_password_with_token.rb +13 -0
  26. data/spec/dummy/config/initializers/devise_token_auth.rb +2 -0
  27. data/spec/dummy/config/routes.rb +2 -1
  28. data/spec/dummy/db/migrate/20200623003142_create_schema_users.rb +0 -1
  29. data/spec/dummy/db/schema.rb +0 -1
  30. data/spec/graphql/user_queries_spec.rb +118 -0
  31. data/spec/requests/graphql_controller_spec.rb +12 -11
  32. data/spec/requests/mutations/additional_mutations_spec.rb +0 -1
  33. data/spec/requests/mutations/resend_confirmation_spec.rb +16 -1
  34. data/spec/requests/mutations/send_password_reset_spec.rb +16 -1
  35. data/spec/requests/mutations/send_password_reset_with_token_spec.rb +78 -0
  36. data/spec/requests/mutations/sign_up_spec.rb +19 -1
  37. data/spec/requests/mutations/update_password_with_token_spec.rb +119 -0
  38. data/spec/requests/queries/check_password_token_spec.rb +16 -1
  39. data/spec/requests/queries/confirm_account_spec.rb +17 -2
  40. data/spec/requests/queries/introspection_query_spec.rb +149 -0
  41. data/spec/requests/user_controller_spec.rb +9 -9
  42. data/spec/support/contexts/graphql_request.rb +12 -4
  43. data/spec/support/contexts/schema_test.rb +14 -0
  44. metadata +26 -11
  45. data/.travis.yml +0 -79
@@ -0,0 +1,90 @@
1
+ # Reset Password Flow
2
+ This gem supports two different ways to reset a password on a resource. Each password reset flow has it's own set of
3
+ operations and this document will explain in more detail how to use each.
4
+ The first and most recently implemented flow is preferred as it requires less steps and doesn't require a mutation
5
+ to return a redirect on the response. Flow 2 might be deprecated in the future.
6
+
7
+ ## Flow #1 (Preferred)
8
+ This flow only has two steps. Each step name refers to the operation name you can use in the mount options to skip or override.
9
+
10
+ ### 1. send_password_reset_with_token
11
+ This mutation will send an email to the specified address if it's found on the system. Returns an error if the email is not found. Here's an example assuming the resource used
12
+ for authentication is `User`:
13
+ ```graphql
14
+ mutation {
15
+ userSendPasswordResetWithToken(
16
+ email: "vvega@wallaceinc.com",
17
+ redirectUrl: "https://google.com"
18
+ ) {
19
+ message
20
+ }
21
+ }
22
+ ```
23
+ The email will contain a link to the `redirectUrl` (https://google.com in the example) and append a `reset_password_token` query param. This is the token you will
24
+ need to use in the next step in order to reset the password.
25
+
26
+ ### 2. update_password_with_token
27
+ This mutation uses the token sent on the email to find the resource you are trying to recover.
28
+ All you have to do is send a valid token together with the new password and password confirmation.
29
+ Here's an example assuming the resource used for authentication is `User`:
30
+
31
+ ```graphql
32
+ mutation {
33
+ userUpdatePasswordWithToken(
34
+ resetPasswordToken: "token_here",
35
+ password: "password123",
36
+ passwordConfirmation: "password123"
37
+ ) {
38
+ authenticatable { email }
39
+ credentials { accessToken }
40
+ }
41
+ }
42
+ ```
43
+ The mutation has two fields:
44
+ 1. `authenticatable`: Just like other mutations, returns the actual resource you just recover the password for.
45
+ 1. `credentials`: This is a nullable field. It will only return credentials as if you had just logged
46
+ in into the app if you explicitly say so by overriding the mutation. The docs have more detail
47
+ on how to extend the default behavior of mutations, but
48
+ [here](https://github.com/graphql-devise/graphql_devise/blob/8c7c8a5ff1b35fb026e4c9499c70dc5f90b9187a/spec/dummy/app/graphql/mutations/reset_admin_password_with_token.rb)
49
+ you can find an example mutation on what needs to be done in order for the mutation to return
50
+ credentials after updating the password.
51
+
52
+ ## Flow 2 (Deprecated)
53
+ This was the first flow to be implemented, requires an additional step and also to encode a GQL query in a url, so this is not the preferred method.
54
+ Each step name refers to the operation name you can use in the mount options to skip or override.
55
+
56
+ ### 1. send_password_reset
57
+ This mutation will send an email to the specified address if it's found on the system. Returns an error if the email is not found. Here's an example assuming the resource used
58
+ for authentication is `User`:
59
+ ```graphql
60
+ mutation {
61
+ userSendPasswordReset(
62
+ email: "vvega@wallaceinc.com",
63
+ redirectUrl: "https://google.com"
64
+ ) {
65
+ message
66
+ }
67
+ }
68
+ ```
69
+ The email will contain an encoded GraphQL query that holds the reset token and redirectUrl.
70
+ The query is described in the next step.
71
+
72
+ ### 2. check_password_token
73
+ This query checks the reset password token and if successful changes a column in the DB (`allow_password_change`) to true.
74
+ This change will allow for the next step to update the password without providing the current password.
75
+ Then, this query will redirect to the provided `redirectUrl` with credentials.
76
+
77
+ ### 3. update_password
78
+ This step requires the request to include authentication headers and will allow the user to
79
+ update the password if step 2 was successful.
80
+ Here's an example assuming the resource used for authentication is `User`:
81
+ ```graphql
82
+ mutation {
83
+ userUpdatePassword(
84
+ password: "password123",
85
+ passwordConfirmation: "password123"
86
+ ) {
87
+ authenticatable { email }
88
+ }
89
+ }
90
+ ```
@@ -28,11 +28,11 @@ 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'
35
- spec.add_development_dependency 'coveralls'
35
+ spec.add_development_dependency 'coveralls-ruby', '~> 0.2'
36
36
  spec.add_development_dependency 'factory_bot'
37
37
  spec.add_development_dependency 'faker'
38
38
  spec.add_development_dependency 'generator_spec'
@@ -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
@@ -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.5'.freeze
4
+ VERSION = '0.14.3'.freeze
5
5
  end
@@ -6,13 +6,13 @@ 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
@@ -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
  )
@@ -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
@@ -73,7 +73,6 @@ ActiveRecord::Schema.define(version: 2020_06_23_003142) do
73
73
  t.text "tokens"
74
74
  t.datetime "created_at", precision: 6, null: false
75
75
  t.datetime "updated_at", precision: 6, null: false
76
- t.index "\"unlock_token\"", name: "index_schema_users_on_unlock_token", unique: true
77
76
  t.index ["confirmation_token"], name: "index_schema_users_on_confirmation_token", unique: true
78
77
  t.index ["email"], name: "index_schema_users_on_email", unique: true
79
78
  t.index ["reset_password_token"], name: "index_schema_users_on_reset_password_token", unique: true