decidim-msad 0.22.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 (32) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE-AGPLv3.txt +661 -0
  3. data/README.md +476 -0
  4. data/Rakefile +17 -0
  5. data/app/controllers/decidim/msad/omniauth_callbacks_controller.rb +178 -0
  6. data/app/controllers/decidim/msad/sessions_controller.rb +58 -0
  7. data/app/controllers/decidim/msad/verification/authorizations_controller.rb +19 -0
  8. data/config/locales/en.yml +27 -0
  9. data/config/locales/fi.yml +26 -0
  10. data/config/locales/sv.yml +26 -0
  11. data/lib/decidim/msad.rb +198 -0
  12. data/lib/decidim/msad/authentication.rb +4 -0
  13. data/lib/decidim/msad/authentication/authenticator.rb +229 -0
  14. data/lib/decidim/msad/authentication/errors.rb +21 -0
  15. data/lib/decidim/msad/engine.rb +112 -0
  16. data/lib/decidim/msad/mail_interceptors.rb +9 -0
  17. data/lib/decidim/msad/mail_interceptors/generated_recipients_interceptor.rb +25 -0
  18. data/lib/decidim/msad/test/runtime.rb +38 -0
  19. data/lib/decidim/msad/verification.rb +5 -0
  20. data/lib/decidim/msad/verification/engine.rb +43 -0
  21. data/lib/decidim/msad/verification/manager.rb +17 -0
  22. data/lib/decidim/msad/verification/metadata_collector.rb +39 -0
  23. data/lib/decidim/msad/version.rb +8 -0
  24. data/lib/generators/decidim/msad/install_generator.rb +126 -0
  25. data/lib/generators/templates/msad_initializer.rb +74 -0
  26. data/lib/generators/templates/msad_initializer_test.rb +5 -0
  27. data/lib/omniauth/msad/metadata.rb +46 -0
  28. data/lib/omniauth/msad/settings.rb +9 -0
  29. data/lib/omniauth/msad/test.rb +10 -0
  30. data/lib/omniauth/msad/test/certificate_generator.rb +51 -0
  31. data/lib/omniauth/strategies/msad.rb +187 -0
  32. metadata +116 -0
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "authentication/authenticator"
4
+ require_relative "authentication/errors"
@@ -0,0 +1,229 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module Msad
5
+ module Authentication
6
+ class Authenticator
7
+ include ActiveModel::Validations
8
+
9
+ def initialize(organization, oauth_hash)
10
+ @organization = organization
11
+ @oauth_hash = oauth_hash
12
+ @new_user = false
13
+ end
14
+
15
+ def verified_email
16
+ @verified_email ||= begin
17
+ if oauth_data[:info][:email]
18
+ oauth_data[:info][:email]
19
+ else
20
+ domain = ::Decidim::Msad.auto_email_domain || organization.host
21
+ "msad-#{person_identifier_digest}@#{domain}"
22
+ end
23
+ end
24
+ end
25
+
26
+ def user_params_from_oauth_hash
27
+ return nil if oauth_data.empty?
28
+ return nil if user_identifier.blank?
29
+
30
+ {
31
+ provider: oauth_data[:provider],
32
+ uid: user_identifier,
33
+ name: oauth_data[:info][:name],
34
+ # The nickname is automatically "parametrized" by Decidim core from
35
+ # the name string, i.e. it will be in correct format.
36
+ nickname: oauth_data[:info][:nickname] || oauth_data[:info][:name],
37
+ oauth_signature: user_signature,
38
+ avatar_url: oauth_data[:info][:image],
39
+ raw_data: oauth_hash
40
+ }
41
+ end
42
+
43
+ # Validate gets run very early in the authentication flow as it's the
44
+ # first method to call before anything else is done. The purpose of this
45
+ # method is to check that the authentication data returned by the AD
46
+ # federation service is valid and contains all the information that we
47
+ # would expect. Therefore, it "validates" that the authentication can
48
+ # be performed.
49
+ def validate!
50
+ raise ValidationError, "No SAML data provided" unless saml_attributes
51
+
52
+ actual_attributes = saml_attributes.attributes
53
+ actual_attributes.delete("fingerprint")
54
+ raise ValidationError, "No SAML data provided" if actual_attributes.blank?
55
+
56
+ data_blank = actual_attributes.all? { |_k, val| val.blank? }
57
+ raise ValidationError, "Invalid SAML data" if data_blank
58
+ raise ValidationError, "Invalid person dentifier" if person_identifier_digest.blank?
59
+
60
+ # Check if there is already an existing identity which is bound to an
61
+ # existing user record. If the identity is not found or the user
62
+ # record bound to that identity no longer exists, the signed in user
63
+ # is a new user.
64
+ id = ::Decidim::Identity.find_by(
65
+ organization: organization,
66
+ provider: oauth_data[:provider],
67
+ uid: user_identifier
68
+ )
69
+ @new_user = id ? id.user.blank? : true
70
+
71
+ true
72
+ end
73
+
74
+ # User is only identified in case they were already logged in during the
75
+ # authentication flow. This can happen in case the service allows public
76
+ # registrations and authorization through MSAD is enabled in the user's
77
+ # profile. This adds a new identity to an existing user unless the
78
+ # identity is already bound to another user profile which would happen
79
+ # e.g. in the following situation:
80
+ #
81
+ # - The user registered to the service through OmniAuth for the first
82
+ # time using this OmniAuth identity.
83
+ # - Next time they came to the service, they created a new user account
84
+ # in Decidim using the registration form with another email address.
85
+ # - Now, they sign in to the service using the manually created account.
86
+ # - They go to the authorization view to authorize themselves through
87
+ # MSAD (which adds the authorization metadata information that might
88
+ # be required in order to perform actions).
89
+ # - Now the person has two accounts, one of which is already bound to a
90
+ # user's OmniAuth identity.
91
+ # - There is a conflict because we cannot bind the same identity to two
92
+ # different user profiles (which could lead to thefts).
93
+ # - This method will notice it and the OmniAuth login information will
94
+ # not be stored to the user's authorization metadata.
95
+ def identify_user!(user)
96
+ identity = user.identities.find_by(
97
+ organization: organization,
98
+ provider: oauth_data[:provider],
99
+ uid: user_identifier
100
+ )
101
+ return identity if identity
102
+
103
+ # Check that the identity is not already bound to another user.
104
+ id = ::Decidim::Identity.find_by(
105
+ organization: organization,
106
+ provider: oauth_data[:provider],
107
+ uid: user_identifier
108
+ )
109
+
110
+ raise IdentityBoundToOtherUserError if id
111
+
112
+ user.identities.create!(
113
+ organization: organization,
114
+ provider: oauth_data[:provider],
115
+ uid: user_identifier
116
+ )
117
+ end
118
+
119
+ # The authorize_user! method will be performed when the sign in attempt
120
+ # has been verified and the user has been identified or alternatively a
121
+ # new identity has been created to them. The possibly configured SAML
122
+ # attributes are stored against the authorization making it possible to
123
+ # add action authorizer conditions based on the information passed from
124
+ # AD. E.g. some processes might be only limited to specific users within
125
+ # the organization belonging to a specific group.
126
+ def authorize_user!(user)
127
+ authorization = ::Decidim::Authorization.find_by(
128
+ name: "msad_identity",
129
+ unique_id: user_signature
130
+ )
131
+ if authorization
132
+ raise AuthorizationBoundToOtherUserError if authorization.user != user
133
+ else
134
+ authorization = ::Decidim::Authorization.find_or_initialize_by(
135
+ name: "msad_identity",
136
+ user: user
137
+ )
138
+ end
139
+
140
+ authorization.attributes = {
141
+ unique_id: user_signature,
142
+ metadata: authorization_metadata
143
+ }
144
+ authorization.save!
145
+
146
+ # This will update the "granted_at" timestamp of the authorization
147
+ # which will postpone expiration on re-authorizations in case the
148
+ # authorization is set to expire (by default it will not expire).
149
+ authorization.grant!
150
+
151
+ authorization
152
+ end
153
+
154
+ # Keeps the user data in sync with the federation server after
155
+ # everything else is done and the user is just aboud to be redirected
156
+ # further to the next page after the successful login. This is called on
157
+ # every successful login callback request.
158
+ def update_user!(user)
159
+ user_changed = false
160
+ if user.email != verified_email
161
+ user_changed = true
162
+ user.email = verified_email
163
+ user.skip_reconfirmation!
164
+ end
165
+ user.newsletter_notifications_at = Time.now if user_newsletter_subscription?(user)
166
+
167
+ user.save! if user_changed
168
+ end
169
+
170
+ protected
171
+
172
+ attr_reader :organization, :oauth_hash
173
+
174
+ def user_newsletter_subscription?(user)
175
+ return false unless @new_user
176
+
177
+ # Return if newsletter subscriptions are not configured
178
+ return false unless ::Decidim::Msad.registration_newsletter_subscriptions
179
+
180
+ user.newsletter_notifications_at.nil?
181
+ end
182
+
183
+ def oauth_data
184
+ @oauth_data ||= oauth_hash.slice(:provider, :uid, :info)
185
+ end
186
+
187
+ def saml_attributes
188
+ @saml_attributes ||= oauth_hash[:extra][:raw_info]
189
+ end
190
+
191
+ def user_identifier
192
+ @user_identifier ||= oauth_data[:uid]
193
+ end
194
+
195
+ # Create a unique signature for the user that will be used for the
196
+ # granted authorization.
197
+ def user_signature
198
+ @user_signature ||= ::Decidim::OmniauthRegistrationForm.create_signature(
199
+ oauth_data[:provider],
200
+ user_identifier
201
+ )
202
+ end
203
+
204
+ def metadata_collector
205
+ @metadata_collector ||= ::Decidim::Msad::Verification::Manager.metadata_collector_for(
206
+ saml_attributes
207
+ )
208
+ end
209
+
210
+ # Data that is stored against the authorization "permanently" (i.e. as
211
+ # long as the authorization is valid).
212
+ def authorization_metadata
213
+ metadata_collector.metadata
214
+ end
215
+
216
+ # Digested format of the person's identifier to be used in the
217
+ # auto-generated emails. This is used so that the actual identifier is not
218
+ # revealed directly to the end user.
219
+ def person_identifier_digest
220
+ return if user_identifier.blank?
221
+
222
+ @person_identifier_digest ||= Digest::MD5.hexdigest(
223
+ "MSAD:#{user_identifier}:#{Rails.application.secrets.secret_key_base}"
224
+ )
225
+ end
226
+ end
227
+ end
228
+ end
229
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module Msad
5
+ module Authentication
6
+ class Error < StandardError; end
7
+
8
+ class AuthorizationBoundToOtherUserError < Error; end
9
+ class IdentityBoundToOtherUserError < Error; end
10
+
11
+ class ValidationError < Error
12
+ attr_reader :validation_key
13
+
14
+ def initialize(msg = nil, validation_key = :invalid_data)
15
+ @validation_key = validation_key
16
+ super(msg)
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module Msad
5
+ class Engine < ::Rails::Engine
6
+ isolate_namespace Decidim::Msad
7
+
8
+ routes do
9
+ devise_scope :user do
10
+ # Manually map the SAML omniauth routes for Devise because the default
11
+ # routes are mounted by core Decidim. This is because we want to map
12
+ # these routes to the local callbacks controller instead of the
13
+ # Decidim core.
14
+ # See: https://git.io/fjDz1
15
+ match(
16
+ "/users/auth/msad",
17
+ to: "omniauth_callbacks#passthru",
18
+ as: "user_msad_omniauth_authorize",
19
+ via: [:get, :post]
20
+ )
21
+
22
+ match(
23
+ "/users/auth/msad/callback",
24
+ to: "omniauth_callbacks#msad",
25
+ as: "user_msad_omniauth_callback",
26
+ via: [:get, :post]
27
+ )
28
+
29
+ # Add the SLO and SPSLO paths to be able to pass these requests to
30
+ # OmniAuth.
31
+ match(
32
+ "/users/auth/msad/slo",
33
+ to: "sessions#slo",
34
+ as: "user_msad_omniauth_slo",
35
+ via: [:get, :post]
36
+ )
37
+
38
+ match(
39
+ "/users/auth/msad/spslo",
40
+ to: "sessions#spslo",
41
+ as: "user_msad_omniauth_spslo",
42
+ via: [:get, :post]
43
+ )
44
+
45
+ # Manually map the sign out path in order to control the sign out
46
+ # flow through OmniAuth when the user signs out from the service.
47
+ # In these cases, the user needs to be also signed out from the AD
48
+ # federation server which is handled by the OmniAuth strategy.
49
+ match(
50
+ "/users/sign_out",
51
+ to: "sessions#destroy",
52
+ as: "destroy_user_session",
53
+ via: [:delete, :post]
54
+ )
55
+
56
+ # This is the callback route after a returning from a successful sign
57
+ # out request through OmniAuth.
58
+ match(
59
+ "/users/slo_callback",
60
+ to: "sessions#slo_callback",
61
+ as: "slo_callback_user_session",
62
+ via: [:get]
63
+ )
64
+ end
65
+ end
66
+
67
+ initializer "decidim_msad.mount_routes", before: :add_routing_paths do
68
+ # Mount the engine routes to Decidim::Core::Engine because otherwise
69
+ # they would not get mounted properly. Note also that we need to prepend
70
+ # the routes in order for them to override Decidim's own routes for the
71
+ # "msad" authentication.
72
+ Decidim::Core::Engine.routes.prepend do
73
+ mount Decidim::Msad::Engine => "/"
74
+ end
75
+ end
76
+
77
+ initializer "decidim_msad.setup", before: "devise.omniauth" do
78
+ next unless Decidim::Msad.configured?
79
+
80
+ # Configure the SAML OmniAuth strategy for Devise
81
+ ::Devise.setup do |config|
82
+ config.omniauth(
83
+ :msad,
84
+ Decidim::Msad.omniauth_settings
85
+ )
86
+ end
87
+
88
+ # Customized version of Devise's OmniAuth failure app in order to handle
89
+ # the failures properly. Without this, the failure requests would end
90
+ # up in an ActionController::InvalidAuthenticityToken exception.
91
+ devise_failure_app = OmniAuth.config.on_failure
92
+ OmniAuth.config.on_failure = proc do |env|
93
+ if env["PATH_INFO"] =~ %r{^/users/auth/msad(/.*)?}
94
+ env["devise.mapping"] = ::Devise.mappings[:user]
95
+ Decidim::Msad::OmniauthCallbacksController.action(
96
+ :failure
97
+ ).call(env)
98
+ else
99
+ # Call the default for others.
100
+ devise_failure_app.call(env)
101
+ end
102
+ end
103
+ end
104
+
105
+ initializer "decidim_msad.mail_interceptors" do
106
+ ActionMailer::Base.register_interceptor(
107
+ MailInterceptors::GeneratedRecipientsInterceptor
108
+ )
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module Msad
5
+ module MailInterceptors
6
+ autoload :GeneratedRecipientsInterceptor, "decidim/msad/mail_interceptors/generated_recipients_interceptor"
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module Msad
5
+ module MailInterceptors
6
+ # Prevents sending emails to the auto-generated email addresses.
7
+ class GeneratedRecipientsInterceptor
8
+ def self.delivering_email(message)
9
+ return unless Decidim::Msad.auto_email_domain
10
+
11
+ # Regexp to match the auto-generated emails
12
+ regexp = /^msad-[a-z0-9]{32}@#{Decidim::Msad.auto_email_domain}$/
13
+
14
+ # Remove the auto-generated email from the message recipients
15
+ message.to = message.to.reject { |email| email =~ regexp } if message.to
16
+ message.cc = message.cc.reject { |email| email =~ regexp } if message.cc
17
+ message.bcc = message.bcc.reject { |email| email =~ regexp } if message.bcc
18
+
19
+ # Prevent delivery in case there are no recipients on the email
20
+ message.perform_deliveries = false if message.to.empty?
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "webmock"
4
+
5
+ module Decidim
6
+ module Msad
7
+ module Test
8
+ class Runtime
9
+ # Ability to stub the requests already in the control class
10
+ include WebMock::API
11
+
12
+ def self.initializer(&block)
13
+ @block = block
14
+ end
15
+
16
+ def self.initialize
17
+ new.instance_initialize(&@block)
18
+ end
19
+
20
+ def self.load_app
21
+ engine_spec_dir = File.join(Dir.pwd, "spec")
22
+
23
+ require "#{Decidim::Dev.dummy_app_path}/config/environment"
24
+
25
+ Dir["#{engine_spec_dir}/shared/**/*.rb"].each { |f| require f }
26
+
27
+ require "paper_trail/frameworks/rspec"
28
+
29
+ require "decidim/dev/test/spec_helper"
30
+ end
31
+
32
+ def instance_initialize
33
+ yield self if block_given?
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "verification/metadata_collector"
4
+ require_relative "verification/manager"
5
+ require_relative "verification/engine"