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.
- checksums.yaml +7 -0
- data/LICENSE-AGPLv3.txt +661 -0
- data/README.md +476 -0
- data/Rakefile +17 -0
- data/app/controllers/decidim/msad/omniauth_callbacks_controller.rb +178 -0
- data/app/controllers/decidim/msad/sessions_controller.rb +58 -0
- data/app/controllers/decidim/msad/verification/authorizations_controller.rb +19 -0
- data/config/locales/en.yml +27 -0
- data/config/locales/fi.yml +26 -0
- data/config/locales/sv.yml +26 -0
- data/lib/decidim/msad.rb +198 -0
- data/lib/decidim/msad/authentication.rb +4 -0
- data/lib/decidim/msad/authentication/authenticator.rb +229 -0
- data/lib/decidim/msad/authentication/errors.rb +21 -0
- data/lib/decidim/msad/engine.rb +112 -0
- data/lib/decidim/msad/mail_interceptors.rb +9 -0
- data/lib/decidim/msad/mail_interceptors/generated_recipients_interceptor.rb +25 -0
- data/lib/decidim/msad/test/runtime.rb +38 -0
- data/lib/decidim/msad/verification.rb +5 -0
- data/lib/decidim/msad/verification/engine.rb +43 -0
- data/lib/decidim/msad/verification/manager.rb +17 -0
- data/lib/decidim/msad/verification/metadata_collector.rb +39 -0
- data/lib/decidim/msad/version.rb +8 -0
- data/lib/generators/decidim/msad/install_generator.rb +126 -0
- data/lib/generators/templates/msad_initializer.rb +74 -0
- data/lib/generators/templates/msad_initializer_test.rb +5 -0
- data/lib/omniauth/msad/metadata.rb +46 -0
- data/lib/omniauth/msad/settings.rb +9 -0
- data/lib/omniauth/msad/test.rb +10 -0
- data/lib/omniauth/msad/test/certificate_generator.rb +51 -0
- data/lib/omniauth/strategies/msad.rb +187 -0
- metadata +116 -0
@@ -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,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
|