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.
- checksums.yaml +4 -4
- data/.circleci/config.yml +118 -0
- data/Appraisals +39 -5
- data/CHANGELOG.md +57 -6
- data/README.md +27 -7
- data/Rakefile +2 -1
- data/app/controllers/graphql_devise/graphql_controller.rb +1 -1
- data/app/views/graphql_devise/mailer/reset_password_instructions.html.erb +7 -1
- data/config/locales/en.yml +2 -1
- data/docs/usage/reset_password_flow.md +90 -0
- data/graphql_devise.gemspec +2 -2
- data/lib/graphql_devise/concerns/controller_methods.rb +6 -0
- data/lib/graphql_devise/default_operations/mutations.rb +10 -6
- data/lib/graphql_devise/mutations/resend_confirmation.rb +2 -0
- data/lib/graphql_devise/mutations/send_password_reset.rb +2 -0
- data/lib/graphql_devise/mutations/send_password_reset_with_token.rb +37 -0
- data/lib/graphql_devise/mutations/sign_up.rb +1 -3
- data/lib/graphql_devise/mutations/update_password_with_token.rb +38 -0
- data/lib/graphql_devise/resolvers/check_password_token.rb +1 -0
- data/lib/graphql_devise/resolvers/confirm_account.rb +2 -0
- data/lib/graphql_devise/schema_plugin.rb +22 -11
- data/lib/graphql_devise/version.rb +1 -1
- data/spec/dummy/app/controllers/api/v1/graphql_controller.rb +2 -2
- data/spec/dummy/app/graphql/dummy_schema.rb +4 -3
- data/spec/dummy/app/graphql/mutations/reset_admin_password_with_token.rb +13 -0
- data/spec/dummy/config/initializers/devise_token_auth.rb +2 -0
- data/spec/dummy/config/routes.rb +2 -1
- data/spec/dummy/db/migrate/20200623003142_create_schema_users.rb +0 -1
- data/spec/dummy/db/schema.rb +0 -1
- data/spec/graphql/user_queries_spec.rb +118 -0
- data/spec/requests/graphql_controller_spec.rb +12 -11
- data/spec/requests/mutations/additional_mutations_spec.rb +0 -1
- data/spec/requests/mutations/resend_confirmation_spec.rb +16 -1
- data/spec/requests/mutations/send_password_reset_spec.rb +16 -1
- data/spec/requests/mutations/send_password_reset_with_token_spec.rb +78 -0
- data/spec/requests/mutations/sign_up_spec.rb +19 -1
- data/spec/requests/mutations/update_password_with_token_spec.rb +119 -0
- data/spec/requests/queries/check_password_token_spec.rb +16 -1
- data/spec/requests/queries/confirm_account_spec.rb +17 -2
- data/spec/requests/queries/introspection_query_spec.rb +149 -0
- data/spec/requests/user_controller_spec.rb +9 -9
- data/spec/support/contexts/graphql_request.rb +12 -4
- data/spec/support/contexts/schema_test.rb +14 -0
- metadata +26 -11
- 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
|
+
```
|
data/graphql_devise.gemspec
CHANGED
@@ -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.
|
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:
|
15
|
-
logout:
|
16
|
-
sign_up:
|
17
|
-
update_password:
|
18
|
-
|
19
|
-
|
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
|
@@ -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
|
-
|
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
|
@@ -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
|
28
|
-
|
29
|
-
context
|
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 !
|
32
|
-
|
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
|
44
|
-
resource_names
|
45
|
-
|
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
|
|
@@ -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:
|
6
|
-
mutation:
|
7
|
-
|
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
|
data/spec/dummy/config/routes.rb
CHANGED
@@ -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:
|
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
|
data/spec/dummy/db/schema.rb
CHANGED
@@ -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
|