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.
- checksums.yaml +4 -4
- data/.coveralls.yml +1 -0
- data/.gitlab-ci.yml +109 -0
- data/Appraisals +39 -5
- data/CHANGELOG.md +47 -0
- data/README.md +20 -9
- 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 +1 -1
- data/lib/graphql_devise/concerns/controller_methods.rb +7 -1
- 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/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/graphql/user_queries_spec.rb +118 -0
- 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/schema_test.rb +14 -0
- metadata +25 -9
- data/.travis.yml +0 -79
data/graphql_devise.gemspec
CHANGED
@@ -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.
|
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.
|
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:
|
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
|
|
@@ -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
|
)
|
@@ -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
|