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,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module Msad
5
+ module Verification
6
+ # This is an engine that performs user authorization.
7
+ class Engine < ::Rails::Engine
8
+ isolate_namespace Decidim::Msad::Verification
9
+
10
+ paths["db/migrate"] = nil
11
+ paths["lib/tasks"] = nil
12
+
13
+ routes do
14
+ resource :authorizations, only: [:new], as: :authorization
15
+
16
+ root to: "authorizations#new"
17
+ end
18
+
19
+ initializer "decidim_msad.verification_workflow", after: :load_config_initializers do
20
+ next unless Decidim::Msad.configured?
21
+
22
+ # We cannot use the name `:msad` for the verification workflow
23
+ # because otherwise the route namespace (decidim_msad) would
24
+ # conflict with the main engine controlling the authentication flows.
25
+ # The main problem that this would bring is that the root path for
26
+ # this engine would not be found.
27
+ Decidim::Verifications.register_workflow(:msad_identity) do |workflow|
28
+ workflow.engine = Decidim::Msad::Verification::Engine
29
+
30
+ Decidim::Msad::Verification::Manager.configure_workflow(workflow)
31
+ end
32
+ end
33
+
34
+ def load_seed
35
+ # Enable the `:msad_identity` authorization
36
+ org = Decidim::Organization.first
37
+ org.available_authorizations << :msad_identity
38
+ org.save!
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module Msad
5
+ module Verification
6
+ class Manager
7
+ def self.configure_workflow(workflow)
8
+ Decidim::Msad.workflow_configurator.call(workflow)
9
+ end
10
+
11
+ def self.metadata_collector_for(saml_attributes)
12
+ Decidim::Msad.metadata_collector_class.new(saml_attributes)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module Msad
5
+ module Verification
6
+ class MetadataCollector
7
+ def initialize(saml_attributes)
8
+ @saml_attributes = saml_attributes
9
+ end
10
+
11
+ def metadata
12
+ return nil unless Decidim::Msad.metadata_attributes.is_a?(Hash)
13
+ return nil if Decidim::Msad.metadata_attributes.blank?
14
+
15
+ collect.delete_if { |_k, v| v.nil? }
16
+ end
17
+
18
+ protected
19
+
20
+ attr_reader :saml_attributes
21
+
22
+ def collect
23
+ Decidim::Msad.metadata_attributes.map do |key, defs|
24
+ value = begin
25
+ case defs
26
+ when Hash
27
+ saml_attributes.public_send(defs[:type], defs[:name])
28
+ when String
29
+ saml_attributes.single(defs)
30
+ end
31
+ end
32
+
33
+ [key, value]
34
+ end.to_h
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module Msad
5
+ VERSION = "0.22.0"
6
+ DECIDIM_VERSION = "~> 0.22.0"
7
+ end
8
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+
5
+ module Decidim
6
+ module Msad
7
+ module Generators
8
+ class InstallGenerator < Rails::Generators::Base
9
+ source_root File.expand_path("../../templates", __dir__)
10
+
11
+ desc "Creates a Devise initializer and copy locale files to your application."
12
+
13
+ class_option(
14
+ :dummy_cert,
15
+ desc: "Defines whether to create a dummy certificate for localhost.",
16
+ type: :boolean,
17
+ default: false
18
+ )
19
+
20
+ class_option(
21
+ :test_initializer,
22
+ desc: "Copies the test initializer instead of the actual one (for test dummy app).",
23
+ type: :boolean,
24
+ default: false,
25
+ hide: true
26
+ )
27
+
28
+ def copy_initializer
29
+ if options[:test_initializer]
30
+ copy_file "msad_initializer_test.rb", "config/initializers/msad.rb"
31
+ else
32
+ copy_file "msad_initializer.rb", "config/initializers/msad.rb"
33
+ end
34
+ end
35
+
36
+ def enable_authentication
37
+ secrets_path = Rails.application.root.join("config", "secrets.yml")
38
+ secrets = YAML.safe_load(File.read(secrets_path), [], [], true)
39
+
40
+ if secrets["default"]["omniauth"]["msad"]
41
+ say_status :identical, "config/secrets.yml", :blue
42
+ else
43
+ mod = SecretsModifier.new(secrets_path)
44
+ final = mod.modify
45
+
46
+ target_path = Rails.application.root.join("config", "secrets.yml")
47
+ File.open(target_path, "w") { |f| f.puts final }
48
+
49
+ say_status :insert, "config/secrets.yml", :green
50
+ end
51
+ end
52
+
53
+ class SecretsModifier
54
+ def initialize(filepath)
55
+ @filepath = filepath
56
+ end
57
+
58
+ def modify
59
+ self.inside_config = false
60
+ self.inside_omniauth = false
61
+ self.config_branch = nil
62
+ @final = ""
63
+
64
+ @empty_line_count = 0
65
+ File.readlines(filepath).each do |line|
66
+ if line =~ /^$/
67
+ @empty_line_count += 1
68
+ next
69
+ else
70
+ handle_line line
71
+ insert_empty_lines
72
+ end
73
+
74
+ @final += line
75
+ end
76
+ insert_empty_lines
77
+
78
+ @final
79
+ end
80
+
81
+ private
82
+
83
+ attr_accessor :filepath, :empty_line_count, :inside_config, :inside_omniauth, :config_branch
84
+
85
+ def handle_line(line)
86
+ if inside_config && line =~ /^ omniauth:/
87
+ self.inside_omniauth = true
88
+ elsif inside_omniauth && (line =~ /^( )?[a-z]+/ || line =~ /^#.*/)
89
+ inject_msad_config
90
+ self.inside_omniauth = false
91
+ end
92
+
93
+ return unless line =~ /^[a-z]+/
94
+
95
+ # A new root configuration block starts
96
+ self.inside_config = false
97
+ self.inside_omniauth = false
98
+
99
+ branch = line[/^(default|development|test):/, 1]
100
+ if branch
101
+ self.inside_config = true
102
+ self.config_branch = branch.to_sym
103
+ end
104
+ end
105
+
106
+ def insert_empty_lines
107
+ @final += "\n" * empty_line_count
108
+ @empty_line_count = 0
109
+ end
110
+
111
+ def inject_msad_config
112
+ @final += " msad:\n"
113
+ case config_branch
114
+ when :development, :test
115
+ @final += " enabled: true\n"
116
+ else
117
+ @final += " enabled: false\n"
118
+ end
119
+ @final += " metadata_url:\n"
120
+ @final += " icon: account-login\n"
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ Decidim::Msad.configure do |config|
4
+ # Define the IdP metadata URL through the secrets
5
+ config.idp_metadata_url = Rails.application.secrets.omniauth[:msad][:metadata_url]
6
+
7
+ # Define the service provider entity ID:
8
+ # config.sp_entity_id = "https://www.example.org/users/auth/msad/metadata"
9
+ # Or define it in your application configuration and apply it here:
10
+ # config.sp_entity_id = Rails.application.config.msad_entity_id
11
+ # Enable automatically assigned emails
12
+ config.auto_email_domain = "example.org"
13
+
14
+ # Subscribe new users automatically to newsletters (default false).
15
+ #
16
+ # IMPORANT NOTE:
17
+ # Legally it should be always a user's own decision if the want to subscribe
18
+ # to any newsletters or not. Before enabling this, make sure you have your
19
+ # legal basis covered for enabling it. E.g. for internal instances within
20
+ # organizations, it should be generally acceptable but please confirm that
21
+ # from the legal department first!
22
+ # config.registration_newsletter_subscriptions = true
23
+
24
+ # Configure the SAML attributes that will be stored in the user's
25
+ # authorization metadata.
26
+ # config.metadata_attributes = {
27
+ # display_name: "http://schemas.microsoft.com/identity/claims/displayname",
28
+ # given_name: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname",
29
+ # surname: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname",
30
+ # birthday: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/dateofbirth",
31
+ # postal_code: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/postalcode",
32
+ # mobile_phone: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/mobilephone",
33
+ # groups: { name: "http://schemas.microsoft.com/ws/2008/06/identity/claims/groups", type: :multi }
34
+ # }
35
+
36
+ # You can define extra service provider metadata that will show up in the
37
+ # metadata URL.
38
+ # config.sp_metadata = [
39
+ # {
40
+ # name: "Organization",
41
+ # children: [
42
+ # {
43
+ # name: "OrganizationName",
44
+ # attributes: { "xml:lang" => "en-US" },
45
+ # content: "Acme"
46
+ # },
47
+ # {
48
+ # name: "OrganizationDisplayName",
49
+ # attributes: { "xml:lang" => "en-US" },
50
+ # content: "Acme Corporation"
51
+ # },
52
+ # {
53
+ # name: "OrganizationURL",
54
+ # attributes: { "xml:lang" => "en-US" },
55
+ # content: "https://en.wikipedia.org/wiki/Acme_Corporation"
56
+ # }
57
+ # ]
58
+ # },
59
+ # {
60
+ # name: "ContactPerson",
61
+ # attributes: { "contactType" => "technical" },
62
+ # children: [
63
+ # {
64
+ # name: "GivenName",
65
+ # content: "John Doe"
66
+ # },
67
+ # {
68
+ # name: "EmailAddress",
69
+ # content: "jdoe@acme.org"
70
+ # }
71
+ # ]
72
+ # }
73
+ # ]
74
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "decidim/msad/test/runtime"
4
+
5
+ Decidim::Msad::Test::Runtime.initialize
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OmniAuth
4
+ module MSAD
5
+ class Metadata < OneLogin::RubySaml::Metadata
6
+ def generate(settings, pretty_print = false)
7
+ metadata_signed = settings.security.delete(:metadata_signed)
8
+ settings.security[:metadata_signed] = false
9
+
10
+ meta_xml = super(settings)
11
+ meta_doc = XMLSecurity::Document.new(meta_xml)
12
+ add_tags_to(meta_doc.root, settings.sp_metadata) if settings.sp_metadata
13
+
14
+ sign_document!(meta_doc, settings) if metadata_signed
15
+ return meta_doc.write("", 1) if pretty_print
16
+
17
+ meta_doc.to_s
18
+ end
19
+
20
+ private
21
+
22
+ def add_tags_to(parent, tags)
23
+ tags.each do |tag|
24
+ element = parent.add_element "md:#{tag[:name]}", tag[:attributes]
25
+ element.text = tag[:content] if tag[:content]
26
+ add_tags_to(element, tag[:children]) if tag[:children].is_a?(Array)
27
+ end
28
+ end
29
+
30
+ def sign_document!(document, settings)
31
+ return unless settings.security[:metadata_signed]
32
+ return unless settings.private_key
33
+ return unless settings.certificate
34
+
35
+ # embed signature
36
+ private_key = settings.get_sp_key
37
+ document.sign_document(
38
+ private_key,
39
+ cert,
40
+ settings.security[:signature_method],
41
+ settings.security[:digest_method]
42
+ )
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OmniAuth
4
+ module MSAD
5
+ class Settings < OneLogin::RubySaml::Settings
6
+ attr_accessor :sp_metadata
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test/certificate_generator"
4
+
5
+ module OmniAuth
6
+ module Msad
7
+ module Test
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OmniAuth
4
+ module Msad
5
+ module Test
6
+ class CertificateGenerator
7
+ def private_key
8
+ @private_key ||= OpenSSL::PKey::RSA.new(2048)
9
+ end
10
+
11
+ def certificate
12
+ @certificate ||= begin
13
+ public_key = private_key.public_key
14
+
15
+ subject = "/C=FI/O=Test/OU=Test/CN=Test"
16
+
17
+ cert = OpenSSL::X509::Certificate.new
18
+ cert.subject = cert.issuer = OpenSSL::X509::Name.parse(subject)
19
+ cert.not_before = Time.now
20
+ cert.not_after = Time.now + 365 * 24 * 60 * 60
21
+ cert.public_key = public_key
22
+ cert.serial = 0x0
23
+ cert.version = 2
24
+
25
+ inject_certificate_extensions(cert)
26
+
27
+ cert.sign(private_key, OpenSSL::Digest::SHA1.new)
28
+
29
+ cert
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def inject_certificate_extensions(cert)
36
+ ef = OpenSSL::X509::ExtensionFactory.new
37
+ ef.subject_certificate = cert
38
+ ef.issuer_certificate = cert
39
+ cert.extensions = [
40
+ ef.create_extension("basicConstraints", "CA:TRUE", true),
41
+ ef.create_extension("subjectKeyIdentifier", "hash")
42
+ ]
43
+ cert.add_extension ef.create_extension(
44
+ "authorityKeyIdentifier",
45
+ "keyid:always,issuer:always"
46
+ )
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "omniauth-saml"
4
+ require "omniauth/msad/metadata"
5
+ require "omniauth/msad/settings"
6
+
7
+ module OmniAuth
8
+ module Strategies
9
+ class MSAD < SAML
10
+ # The IdP metadata URL.
11
+ option :idp_metadata_url, nil
12
+
13
+ # These are the requested attributes that could be defined for the
14
+ # metadata. However, these can be already defined at the federation side,
15
+ # so they are not generally needed with AD based federations.
16
+ option :request_attributes, []
17
+
18
+ # Maps the SAML attributes to OmniAuth info schema:
19
+ # https://github.com/omniauth/omniauth/wiki/Auth-Hash-Schema#schema-10-and-later
20
+ option(
21
+ :attribute_statements,
22
+ name: %w(http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name),
23
+ email: %w(http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress),
24
+ first_name: %w(http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname),
25
+ last_name: %w(http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname),
26
+ nickname: %w(http://schemas.microsoft.com/identity/claims/displayname)
27
+ )
28
+
29
+ option(:sp_metadata, [])
30
+
31
+ info do
32
+ found_attributes = options.attribute_statements.map do |key, values|
33
+ attribute = find_attribute_by(values)
34
+ [key, attribute]
35
+ end
36
+ info_hash = Hash[found_attributes]
37
+
38
+ # The name attribute is overridden if the first name and last name are
39
+ # defined because otherwise it could be the principal name which is not
40
+ # a user readable name as expected by OmniAuth.
41
+ name = "#{info_hash["first_name"]} #{info_hash["last_name"]}".strip
42
+ info_hash["name"] = name unless name.blank?
43
+
44
+ info_hash
45
+ end
46
+
47
+ def initialize(app, *args, &block)
48
+ super
49
+
50
+ # Add the request attributes to the options.
51
+ options[:sp_name_qualifier] = options[:sp_entity_id] if options[:sp_name_qualifier].nil?
52
+
53
+ # Remove the nil options from the origianl options array that will be
54
+ # defined by the MSAD options
55
+ [
56
+ :idp_name_qualifier,
57
+ :name_identifier_format,
58
+ :security
59
+ ].each do |key|
60
+ options.delete(key) if options[key].nil?
61
+ end
62
+
63
+ # Add the MSAD options to the local options, most of which are fetched
64
+ # from the metadata. The options array is the one that gets priority in
65
+ # case it overrides some of the metadata or locally defined option
66
+ # values.
67
+ @options = OmniAuth::Strategy::Options.new(
68
+ msad_options.merge(options)
69
+ )
70
+ end
71
+
72
+ # This method can be used externally to fetch information about the
73
+ # response, e.g. in case of failures.
74
+ def response_object
75
+ return nil unless request.params["SAMLResponse"]
76
+
77
+ with_settings do |settings|
78
+ response = OneLogin::RubySaml::Response.new(
79
+ request.params["SAMLResponse"],
80
+ options_for_response_object.merge(settings: settings)
81
+ )
82
+ response.attributes["fingerprint"] = settings.idp_cert_fingerprint
83
+ response
84
+ end
85
+ end
86
+
87
+ private
88
+
89
+ def msad_options
90
+ idp_metadata_parser = OneLogin::RubySaml::IdpMetadataParser.new
91
+
92
+ # Returns OneLogin::RubySaml::Settings prepopulated with idp metadata
93
+ settings = begin
94
+ begin
95
+ idp_metadata_parser.parse_remote_to_hash(
96
+ options.idp_metadata_url,
97
+ true
98
+ )
99
+ rescue ::URI::InvalidURIError
100
+ # Allow the OmniAuth strategy to be configured with empty settings
101
+ # in order to provide the metadata URL even when the authentication
102
+ # endpoint is not configured.
103
+ {}
104
+ end
105
+ end
106
+
107
+ # Define the security settings as there are some defaults that need to be
108
+ # modified
109
+ security_defaults = OneLogin::RubySaml::Settings::DEFAULTS[:security]
110
+ settings[:security] = security_defaults.merge(
111
+ authn_requests_signed: options.certificate.present?,
112
+ want_assertions_signed: true,
113
+ digest_method: XMLSecurity::Document::SHA256,
114
+ signature_method: XMLSecurity::Document::RSA_SHA256
115
+ )
116
+
117
+ # Add some extra information that is necessary for correctly formatted
118
+ # logout requests.
119
+ settings[:idp_name_qualifier] = settings[:idp_entity_id]
120
+
121
+ if !options.name_identifier_format.blank?
122
+ # If the name identifier format has been configured, use that instead
123
+ # of the IdP metadata value. Otherwise the first format available in
124
+ # the IdP metadata would be used.
125
+ settings[:name_identifier_format] = options.name_identifier_format
126
+ elsif settings[:name_identifier_format].blank?
127
+ # If the name identifier format is not defined in the IdP metadata,
128
+ # add the persistent format to the SP metadata.
129
+ settings[:name_identifier_format] = "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent"
130
+ end
131
+
132
+ settings
133
+ end
134
+
135
+ def with_settings
136
+ options[:assertion_consumer_service_url] ||= callback_url
137
+ yield OmniAuth::MSAD::Settings.new(options)
138
+ end
139
+
140
+ # Customize the metadata class in order to add custom nodes to the
141
+ # metadata.
142
+ def other_phase_for_metadata
143
+ with_settings do |settings|
144
+ response = OmniAuth::MSAD::Metadata.new
145
+
146
+ add_request_attributes_to(settings) if options.request_attributes.length.positive?
147
+
148
+ Rack::Response.new(
149
+ response.generate(settings),
150
+ 200,
151
+ "Content-Type" => "application/xml"
152
+ ).finish
153
+ end
154
+ end
155
+
156
+ # End the local user session BEFORE sending the logout request to the
157
+ # identity provider.
158
+ def other_phase_for_spslo
159
+ return super unless options.idp_slo_target_url
160
+
161
+ with_settings do |settings|
162
+ # Some session variables are needed when generating the logout request
163
+ request = generate_logout_request(settings)
164
+ # Destroy the local user session
165
+ options[:idp_slo_session_destroy].call @env, session
166
+ # Send the logout request to the identity provider
167
+ redirect(request)
168
+ end
169
+ end
170
+
171
+ # Overridden to disable passing the relay state with a request parameter
172
+ # which is possible in the default implementation.
173
+ def slo_relay_state
174
+ state = super
175
+
176
+ # Ensure that we are only using the relay states to redirect the user
177
+ # within the current website. This forces the relay state to always
178
+ # start with a single forward slash character (/).
179
+ return "/" unless state =~ %r{^/[^/].*}
180
+
181
+ state
182
+ end
183
+ end
184
+ end
185
+ end
186
+
187
+ OmniAuth.config.add_camelization "msad", "MSAD"