Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • depau/discourse-saml
  • jackv/discourse-saml
2 results
Show changes
Commits on Source (141)
Showing with 911 additions and 180 deletions
2.8.0.beta9: 851f6cebe3fdd48019660b236a447abb6ddf9c89
2.8.0.beta8: 8002759dacc3c71ecf06fd33f112c84b14b6a59a
2.8.0.beta5: c08ae1e0d301a5f0d264930fa068cdbeb5735ecd
2.7.8: c08ae1e0d301a5f0d264930fa068cdbeb5735ecd
{
"extends": "eslint-config-discourse"
}
name: Discourse Plugin
on:
push:
branches:
- main
pull_request:
jobs:
ci:
uses: discourse/.github/.github/workflows/discourse-plugin.yml@v1
auto_generated
gems
node_modules
/gems
/auto_generated
{}
inherit_gem:
rubocop-discourse: stree-compat.yml
--print-width=100
--plugins=plugin/trailing_comma
module.exports = {
plugins: ["ember-template-lint-plugin-discourse"],
extends: "discourse:recommended",
};
# frozen_string_literal: true
source "https://rubygems.org"
group :development do
gem "rubocop-discourse"
gem "syntax_tree"
end
GEM
remote: https://rubygems.org/
specs:
ast (2.4.2)
json (2.6.2)
parallel (1.22.1)
parser (3.1.2.1)
ast (~> 2.4.1)
prettier_print (1.2.0)
rainbow (3.1.1)
regexp_parser (2.6.0)
rexml (3.2.5)
rubocop (1.36.0)
json (~> 2.3)
parallel (~> 1.10)
parser (>= 3.1.2.1)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0)
rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.20.1, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 1.4.0, < 3.0)
rubocop-ast (1.21.0)
parser (>= 3.1.1.0)
rubocop-discourse (3.0)
rubocop (>= 1.1.0)
rubocop-rspec (>= 2.0.0)
rubocop-rspec (2.13.2)
rubocop (~> 1.33)
ruby-progressbar (1.11.0)
syntax_tree (5.1.0)
prettier_print (>= 1.2.0)
unicode-display_width (2.3.0)
PLATFORMS
arm64-darwin-20
ruby
x86_64-darwin-18
x86_64-darwin-19
x86_64-darwin-20
x86_64-linux
DEPENDENCIES
rubocop-discourse
syntax_tree
BUNDLED WITH
2.3.10
......@@ -18,4 +18,3 @@ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
### discourse-saml
> ⚠ Discourse has successfully integrated with SAML for many enterprises, but SAML integration is often complex, error prone, and typically requires customization / changes for that organization's _specific implementation_ of SAML. This work is best undertaken by software developers familiar with Discourse. We are highly familiar with Discourse, and available to do that work [on an enterprise hosting plan](https://discourse.org/buy).
### About
A Discourse Plugin to enable authentication via SAML
Setting up your idp:
The entity-id should be: http://example.com
The consumer assertion service url should be: https://example.com/auth/saml/callback
The entity-id should be: `http://example.com`
The consumer assertion service url should be: `https://example.com/auth/saml/callback`
You may need to set your idp to send an extra custom attribute 'screenName', that will become the users id.
For idp-initated SSO, use the following URL:
https://example.com/auth/saml/callback
`https://example.com/auth/saml/callback`
### Configuration
For Docker based installations:
Add the following settings to your `app.yml` file in the Environment Settings section:
```
## Saml plugin setting
DISCOURSE_SAML_TARGET_URL: https://idpvendor.com/saml/login/
DISCOURSE_SAML_CERT_FINGERPRINT: "43:BB:DA:FF..."
#DISCOURSE_SAML_REQUEST_METHOD: post
#DISCOURSE_SAML_FULL_SCREEN_LOGIN: true
DISCOURSE_SAML_CERT: "-----BEGIN CERTIFICATE-----
DISCOURSE_SAML_CERT: "-----BEGIN CERTIFICATE-----
...
-----END CERTIFICATE-----"
```
......@@ -35,14 +38,57 @@ Add the following settings to your `discourse.conf` file:
- `saml_target_url`
### Convering an RSA Key to a PEM
If the idp has an RSA key split up as modulus and exponent, this javascript library makes
it easy to convert to pem:
### Group sync
- `DISCOURSE_SAML_SYNC_GROUPS`: Sync groups. Defaults to false.
- `DISCOURSE_SAML_GROUPS_ATTRIBUTE`: SAML attribute to use for group sync. Defaults to `memberOf`
- `DISCOURSE_SAML_GROUPS_FULLSYNC`: Should the assigned groups be completely synced including adding AND removing groups based on the IDP? Defaults to false. If set to true, `DISCOURSE_SAML_SYNC_GROUPS_LIST` and SAML attribute `groups_to_add`/`groups_to_remove` are not used.
- `DISCOURSE_SAML_GROUPS_LDAP_LEAFCN`: If your IDP transmits `cn=groupname,cn=groups,dc=example,dc=com` you can set this to true to use only `groupname`. This is useful if you want to keep the standard group name length of Discourse (20 characters).
- `DISCOURSE_SAML_SYNC_GROUPS_LIST`: Groups mentioned in this list are synced if they are referenced by the IDP (in `memberOf` SAML attribue). Any other groups will not be removed/updated.
### Other Supported settings
- `DISCOURSE_SAML_SP_CERTIFICATE`: SAML Service Provider Certificate
- `DISCOURSE_SAML_SP_PRIVATE_KEY`: SAML Service Provider Private Key
- `DISCOURSE_SAML_AUTHN_REQUESTS_SIGNED`: defaults to false
- `DISCOURSE_SAML_WANT_ASSERTIONS_SIGNED`: defaults to false
- `DISCOURSE_SAML_LOGOUT_REQUESTS_SIGNED`: defaults to false
- `DISCOURSE_SAML_LOGOUT_RESPONSES_SIGNED`: defaults to false
- `DISCOURSE_SAML_NAME_IDENTIFIER_FORMAT`: defaults to "urn:oasis:names:tc:SAML:2.0:protocol"
- `DISCOURSE_SAML_DEFAULT_EMAILS_VALID`: defaults to true
- `DISCOURSE_SAML_VALIDATE_EMAIL_FIELDS`: defaults to blank. This setting accepts pipe separated group names that are supplied in `memberOf` attribute in SAML payload. If the group name specified in the value matches that from `memberOf` attribute than the `email_valid` is set to `true`, otherwise it defaults to `false`. This setting overrides `DISCOURSE_SAML_DEFAULT_EMAILS_VALID`.
- `DISCOURSE_SAML_BUTTON_TITLE`: 'with SAML'
- `DISCOURSE_SAML_TITLE`: 'SAML'
- `DISCOURSE_SAML_SYNC_MODERATOR`: defaults to false. If set to `true` user get moderator role if SAML attribute `isModerator` (or attribute specified by `DISCOURSE_SAML_MODERATOR_ATTRIBUTE`) is 1 or true.
- `DISCOURSE_SAML_MODERATOR_ATTRIBUTE`: defaults to `isModerator`
- `DISCOURSE_SAML_SYNC_ADMIN`: defaults to false. If set to `true` user get admin role if SAML attribute `isAdmin` (or attribute specified by `DISCOURSE_SAML_ADMIN_ATTRIBUTE`) is 1 or true.
- `DISCOURSE_SAML_ADMIN_ATTRIBUTE`: defaults to `isAdmin`
- `DISCOURSE_SAML_SYNC_TRUST_LEVEL`: defaults to false. If set to `true` user's trust level is set to the SAML attribute `trustLevel` (or attribute specified by `DISCOURSE_SAML_TRUST_LEVEL_ATTRIBUTE`) which needs to be between 1 and 4.
- `DISCOURSE_SAML_TRUST_LEVEL_ATTRIBUTE`: defaults to `trustLevel`
### Converting an RSA Key to a PEM
If the idp has an RSA key split up as modulus and exponent, this javascript library makes it easy to convert to pem:
https://www.npmjs.com/package/rsa-pem-from-mod-exp
### Moving from environment variables to Site Settings
With the Environment variables set, run this snippet in the rails console:
```ruby
SiteSetting.defaults.all.keys.each do |k|
next if !k.to_s.start_with?("saml_")
if val = GlobalSetting.try(k)
puts "Setting #{k} to #{val} in the database"
SiteSetting.add_override!(k, val)
end
end;
SiteSetting.saml_enabled = true
```
Then remove the environment variables and restart the server. The plugin will now be using site settings which can be modified in the admin UI.
### License
MIT
en:
js:
login:
saml:
name: "SAML"
title: with SAML
admin_js:
admin:
site_settings:
categories:
saml: SAML
en:
login:
use_saml_auth: "Please use the company SSO to login with your account."
site_settings:
saml_enabled: Enable SAML authentication
saml_target_url: Target URL of the SAML Identity Provider (required)
saml_slo_target_url: Target URL for SAML Single Log Out
saml_name_identifier_format: If provided, will request a specific NameID (UID) format from the identity provider.
saml_cert: X.509 public certificate of the SAML identity provider (either this, or saml_cert_fingerprint, are required)
saml_cert_fingerprint: The X.509 public certificate fingerprint of the SAML identity provider (either this, or `saml_cert`, are required)
saml_cert_fingerprint_algorithm: Which algorithm should be used for SAML certificate fingerprinting?
saml_cert_multi: A secondary X.509 public certificate of the SAML identity provider. Useful during certificate rotations
saml_request_method: The HTTP method used when directing the user to the Identity Provider
saml_sp_certificate: SAML Service Provider X.509 certificate. Used to sign messages once enabled via the `saml_*_signed` settings"
saml_sp_private_key: SAML Service Provider X.509 private key. Used to sign messages once enabled via the `saml_*_signed` settings"
saml_authn_requests_signed: Enable Service Provider signatures for AuthNRequest
saml_want_assertions_signed: Enable Service Provider signatures for Assertions
saml_logout_requests_signed: Enable Service Provider signatures for SP-initiated logout
saml_logout_responses_signed: Enable Service Provider signatures for IDP-initiated logout responses
saml_request_attributes: A list of additional attributes which should be fetched from the service provider. `email`, `name`, `first_name`, and `last_name` are always fetched.
saml_attribute_statements: Custom mappings of fields to their source SAML attributes. In the format `field:samlAttr1,samlAttr2`.
saml_use_attributes_uid: Use the 'uid' attribute as the unique user identifier instead of the default `name_id` field.
saml_skip_email_validation: Skip syntax validation of emails from the SAML IDP
saml_validate_email_fields: If any of these values are present in the `memberOf` attribute, then the email should be considered valid/verified
saml_default_emails_valid: "Consider SAML emails to be verified? Warning: this should only be `true` if you trust the IDP to verify email ownership"
saml_clear_username: Ignore the username from the SAML result
saml_omit_username: Prevent the user from changing the SAML username during signup
saml_auto_create_account: Skip the registration popup during signup with a SAML account
saml_sync_groups: Synchronize SAML groups with Discourse
saml_groups_fullsync: Should the assigned groups be completely synced including adding AND removing groups based on the IDP? Defaults to false. If set to true, `saml_sync_groups_list` and SAML attribute `groups_to_add`/`groups_to_remove` are not used.
saml_groups_attribute: The SAML attribute which contains group names
saml_groups_ldap_leafcn: If your IDP transmits `cn=groupname,cn=groups,dc=example,dc=com` you can set this to true to use only `groupname`. This is useful if you want to keep the standard group name length of Discourse (20 characters).
saml_sync_groups_list: If provided, these are the only Discourse groups which will have their membership controlled by SAML. If blank, all groups_to_add/groups_to_remove are used.
saml_user_field_statements: If provided, user fields will be set based on SAML attributes. Each entry should be in the format `saml_attribute_name:discourse_field_id`
saml_sync_email: On every login, override the user's email using the SAML value. Works the same as the `auth_overrides_email` setting, but is specific to SAML logins.
saml_sync_moderator: Sync moderator status from SAML result?
saml_moderator_attribute: The SAML attribute which contains the moderator boolean
saml_sync_admin: Sync admin status from SAML result?
saml_admin_attribute: The SAML attribute which contains the admin boolean
saml_sync_trust_level: Set user trust level from SAML result
saml_trust_level_attribute: The SAML attribute which contains the trust level integer
saml_sync_locale: Set user locale from SAML result
saml_locale_attribute: The SAML attribute which contains the locale name
saml_forced_domains: Users with email addresses on these domains will be forced to use the SAML flow. They will be blocked from other login methods.
saml_log_auth: Store raw data about authentications in the `plugin_store_rows` database table.
saml_debug_auth: Enable debug logging to `/logs`
saml_base_url: Override the base URL for the Service Provider. Defaults to the forum base URL.
saml:
saml_enabled: false
saml_target_url: ""
saml_slo_target_url: ""
saml_name_identifier_format: ""
saml_cert:
default: ""
textarea: true
saml_cert_fingerprint: ""
saml_cert_fingerprint_algorithm:
type: enum
default: SHA1
choices:
- SHA1
- SHA256
- SHA384
- SHA512
saml_cert_multi:
default: ""
textarea: true
saml_request_method:
type: enum
default: GET
choices:
- GET
- POST
saml_sp_certificate:
default: ""
textarea: true
saml_sp_private_key:
default: ""
textarea: true
saml_authn_requests_signed: false
saml_want_assertions_signed: false
saml_logout_requests_signed: false
saml_logout_responses_signed: false
saml_request_attributes:
type: list
default: ""
saml_attribute_statements:
type: list
default: ""
saml_use_attributes_uid: false
saml_skip_email_validation: false
saml_validate_email_fields:
type: list
default: ""
saml_default_emails_valid: true
saml_clear_username: false
saml_omit_username: false
saml_auto_create_account: false
saml_sync_groups: false
saml_groups_fullsync: false
saml_groups_attribute: "memberOf"
saml_groups_ldap_leafcn: false
saml_sync_groups_list:
type: list
default: ""
saml_user_field_statements:
type: list
default: ""
saml_sync_email: false
saml_sync_moderator: false
saml_moderator_attribute: "isModerator"
saml_sync_admin: false
saml_admin_attribute: "isAdmin"
saml_sync_trust_level: false
saml_trust_level_attribute: "trustLevel"
saml_sync_locale: false
saml_locale_attribute: "locale"
saml_forced_domains:
type: list
default: ""
saml_log_auth: false
saml_debug_auth: false
saml_base_url: ""
# frozen_string_literal: true
class MigrateSamlUserInfo < ActiveRecord::Migration[6.1]
def up
execute <<~SQL
INSERT INTO user_associated_accounts (
provider_name,
provider_uid,
user_id,
info,
last_used,
created_at,
updated_at
) SELECT
'saml',
uid,
user_id,
json_build_object('email', email, 'name', name),
updated_at,
created_at,
updated_at
FROM oauth2_user_infos
WHERE provider = 'saml'
ON CONFLICT DO NOTHING
SQL
end
def down
raise ActiveRecord::IrreversibleMigration
end
end
# frozen_string_literal: true
class ::DiscourseSaml::SamlOmniauthStrategy < OmniAuth::Strategies::SAML
option :request_method, "GET"
def request_phase
if options[:request_method] == "POST"
with_settings do |settings|
settings.compress_request = false # Compression used by default for Redirect binding, not POST
authn_request = OneLogin::RubySaml::Authrequest.new
params = authn_request.create_params(settings, additional_params_for_authn_request)
destination = settings.idp_sso_service_url
render_auto_submitted_form(destination: destination, params: params)
end
else
super
end
end
def callback_phase
if request.request_method.downcase.to_sym == :post && !request.params["SameSite"] &&
request.params["SAMLResponse"]
env[Rack::RACK_SESSION_OPTIONS][:skip] = true # Do not set any session cookies. They'll override our SameSite ones
# Make browser re-issue the request in a same-site context so we get cookies
# For this particular action, we explicitly **want** cross-site requests to include session cookies
render_auto_submitted_form(
destination: callback_url,
params: {
"SAMLResponse" => request.params["SAMLResponse"],
"SameSite" => "1",
},
)
else
super
end
end
private
def render_auto_submitted_form(destination:, params:)
submit_script_url =
UrlHelper.absolute(
"#{Discourse.base_path}/plugins/discourse-saml/javascripts/submit-form-on-load.js",
GlobalSetting.cdn_url,
)
inputs = params.map { |key, value| <<~HTML }.join("\n")
<input type="hidden" name="#{CGI.escapeHTML(key)}" value="#{CGI.escapeHTML(value)}"/>
HTML
html = <<~HTML
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">
<body>
<noscript>
<p>
<strong>Note:</strong> Since your browser does not support JavaScript,
you must press the Continue button once to proceed.
</p>
</noscript>
<form action="#{CGI.escapeHTML(destination)}" method="post">
<div>
#{inputs}
</div>
<noscript>
<div>
<input type="submit" value="Continue"/>
</div>
</noscript>
</form>
<script src="#{CGI.escapeHTML(submit_script_url)}"></script>
</body>
</html>
HTML
r = Rack::Response.new(html, 200, { "content-type" => "text/html" })
r.finish
end
end
# frozen_string_literal: true
class SamlAuthenticator < ::Auth::ManagedAuthenticator
def name
"saml"
end
def attribute_name_format(type = "basic")
"urn:oasis:names:tc:SAML:2.0:attrname-format:#{type}"
end
def setting(key)
# In almost all circumstances, `name` is `saml`.
# However, some other plugins choose to re-use this Authenticator class
# with a different `name`. This helper lets them have their own settings,
# which automatically fall back to the `saml_` defaults.
::DiscourseSaml.setting(key, prefer_prefix: "#{name}_")
end
def request_attributes
attrs = "email|name|first_name|last_name"
custom_attrs = setting(:request_attributes)
attrs = "#{attrs}|#{custom_attrs}" if custom_attrs.present?
attrs
.split("|")
.uniq
.map { |name| { name: name, name_format: attribute_name_format, friendly_name: name } }
end
def attribute_statements
result = {}
statements =
"name:fullName,name|email:email,mail|first_name:first_name,firstname,firstName|last_name:last_name,lastname,lastName|nickname:screenName"
custom_statements = setting(:attribute_statements)
statements = "#{statements}|#{custom_statements}" if custom_statements.present?
statements
.split("|")
.map do |statement|
attrs = statement.split(":", 2)
next if attrs.count != 2
(result[attrs[0]] ||= []) << attrs[1].split(",")
result[attrs[0]].flatten!
end
result
end
def register_middleware(omniauth)
omniauth.provider ::DiscourseSaml::SamlOmniauthStrategy,
name: name,
setup: lambda { |env| setup_strategy(env["omniauth.strategy"]) }
end
def setup_strategy(strategy)
strategy.options.deep_merge!(
issuer: SamlAuthenticator.saml_base_url,
idp_sso_target_url: setting(:target_url),
idp_slo_target_url: setting(:slo_target_url).presence,
slo_default_relay_state: SamlAuthenticator.saml_base_url,
idp_cert_fingerprint: setting(:cert_fingerprint).presence,
idp_cert_fingerprint_algorithm: setting(:cert_fingerprint_algorithm),
idp_cert: setting(:cert).presence,
idp_cert_multi: idp_cert_multi,
request_attributes: request_attributes,
attribute_statements: attribute_statements,
assertion_consumer_service_url: SamlAuthenticator.saml_base_url + "/auth/#{name}/callback",
single_logout_service_url: SamlAuthenticator.saml_base_url + "/auth/#{name}/slo",
name_identifier_format: setting(:name_identifier_format).presence,
request_method: (setting(:request_method)&.downcase == "post") ? "POST" : "GET",
certificate: setting(:sp_certificate).presence,
private_key: setting(:sp_private_key).presence,
security: {
authn_requests_signed: !!setting(:authn_requests_signed),
want_assertions_signed: !!setting(:want_assertions_signed),
logout_requests_signed: !!setting(:logout_requests_signed),
logout_responses_signed: !!setting(:logout_responses_signed),
signature_method: XMLSecurity::Document::RSA_SHA256,
},
idp_slo_session_destroy:
proc do |env, session|
user = CurrentUser.lookup_from_env(env)
if user
user.user_auth_tokens.destroy_all
user.logged_out
end
end,
)
end
# Only match by the NameID
def match_by_email
false
end
def match_by_username
false
end
def is_anonymous?(email)
email.start_with?("anonymous+") && email.end_with?("@rev.ng")
end
def primary_email_verified?(auth_token)
attributes = OneLogin::RubySaml::Attributes.new(auth_token.extra&.[](:raw_info) || {})
email = attributes.single("email")
return false if is_anonymous?(email)
email_verified = attributes.single("emailVerified")
return email_verified == "true" if attributes.include?("emailVerified")
group_attribute = setting(:groups_attribute)
if setting(:validate_email_fields).present? && attributes.multi(group_attribute).present?
validate_email_fields = setting(:validate_email_fields).split("|").map(&:downcase)
member_of = attributes.multi(group_attribute).map { |g| g.downcase.split(",") }.flatten
(validate_email_fields & member_of).present? ? true : false
else
setting(:default_emails_valid)
end
end
def after_authenticate(auth)
info = auth.info
extra_data = auth.extra || {}
attributes = extra_data[:raw_info] || OneLogin::RubySaml::Attributes.new
log("after_authenticate: auth: #{auth.inspect}")
log("after_authenticate: attributes: #{attributes.inspect}")
log("after_authenticate: extra_data: #{extra_data.inspect}")
log("after_authenticate: uid: #{attributes.single("uid")}, #{auth[:uid]}")
auth[:uid] = attributes.single("uid") || auth[:uid] if setting(:use_attributes_uid)
uid = auth[:uid]
auth.info[:username] = attributes.single("username")
auth.info[:nickname] = attributes.single("username")
auth.extra = { "raw_info" => attributes.attributes }
result = super
if setting(:log_auth)
::PluginStore.set("saml", "#{name}_last_auth", auth.inspect)
::PluginStore.set("saml", "#{name}_last_auth_raw_info", attributes.inspect)
::PluginStore.set("saml", "#{name}_last_auth_extra", extra_data.inspect)
end
if setting(:debug_auth)
data = { uid: uid, info: info, attributes: attributes }
log("#{name}_auth: #{data.inspect}")
end
result.skip_email_validation = true if setting(:skip_email_validation)
email = attributes.single("email")
if not is_anonymous?(email)
if result.user.blank?
result.username = "" if setting(:clear_username)
result.user = auto_create_account(result, uid) if setting(:auto_create_account) &&
result.email_valid
else
user = result.user
sync_groups(user, attributes, info)
sync_custom_fields(user, attributes, info)
sync_moderator(user, attributes)
sync_admin(user, attributes)
sync_trust_level(user, attributes)
sync_locale(user, attributes)
end
else
result.failed = true
result.failed_reason = "Anonymous users cannot access Discourse. " +
"Convert your account to a regular account to continue."
end
result.overrides_username = setting(:omit_username)
result.overrides_email = setting(:sync_email)
result.overrides_name = true
result
end
def log(info)
Rails.logger.warn("SAML Debugging: #{info}") if setting(:debug_auth)
end
def after_create_account(user, auth)
super
uaa =
UserAssociatedAccount.find_by(
provider_name: auth.extra_data[:provider],
provider_uid: auth.extra_data[:uid],
)
info = OmniAuth::AuthHash::InfoHash.new(uaa.info)
attributes = OneLogin::RubySaml::Attributes.new(uaa.extra&.[]("raw_info") || {})
sync_groups(user, attributes, info)
sync_moderator(user, attributes)
sync_admin(user, attributes)
sync_trust_level(user, attributes)
sync_custom_fields(user, attributes, info)
sync_locale(user, attributes)
end
def auto_create_account(result, uid)
try_email = result.email.presence
return if User.find_by_email(try_email).present?
# Use a mutex here to counter SAML responses that are sent at the same time and the same email payload
DistributedMutex.synchronize("discourse_saml_#{try_email}") do
user_params = {
primary_email: UserEmail.new(email: try_email, primary: true),
name: resolve_name(result.name, result.username, result.email),
username: resolve_username(result.username, result.name, result.email, uid),
active: true,
}
user = User.create!(user_params)
session_data = result.session_data
after_create_result = Auth::Result.from_session_data(session_data, user: user)
after_create_account(user, after_create_result)
user
end
end
def sync_groups(user, attributes, info)
return unless setting(:sync_groups).present?
groups_fullsync = setting(:groups_fullsync)
raw_group_list = attributes.multi(setting(:groups_attribute)) || []
user_group_list = raw_group_list.map { |g| g.downcase.split(",") }.flatten
if setting(:groups_ldap_leafcn).present?
# Change cn=groupname,cn=groups,dc=example,dc=com to groupname
user_group_list = user_group_list.map { |group| group.split("=", 2).last }
end
if groups_fullsync
user_has_groups = user.groups.where(automatic: false).pluck(:name).map(&:downcase)
groups_to_add = user_group_list - user_has_groups
groups_to_remove = user_has_groups - user_group_list if user_has_groups.present?
else
total_group_list = setting(:sync_groups_list).split("|").map(&:downcase)
groups_to_add = info["groups_to_add"] || attributes.multi("groups_to_add")&.join(",") || ""
groups_to_add = groups_to_add.downcase.split(",")
groups_to_add += user_group_list
groups_to_remove =
info["groups_to_remove"] || attributes.multi("groups_to_remove")&.join(",") || ""
groups_to_remove = groups_to_remove.downcase.split(",")
if total_group_list.present?
groups_to_add = total_group_list & groups_to_add
removable_groups = groups_to_remove.dup
groups_to_remove = total_group_list - groups_to_add
groups_to_remove &= removable_groups if removable_groups.present?
end
end
return if user_group_list.blank? && groups_to_add.blank? && groups_to_remove.blank?
Group
.where("LOWER(name) IN (?) AND NOT automatic", groups_to_add)
.each { |group| group.add user }
Group
.where("LOWER(name) IN (?) AND NOT automatic", groups_to_remove)
.each { |group| group.remove user }
end
def sync_custom_fields(user, attributes, info)
return if user.blank?
request_attributes.each do |attr|
key = attr[:name]
val = info[key] || attributes.multi(key)&.join(",")
user.custom_fields["#{name}_#{key}"] = val if val.present?
end
sync_user_fields(user, attributes, info)
user.save_custom_fields
end
def sync_user_fields(user, attributes, info)
statements = setting(:user_field_statements) || ""
statements
.split("|")
.each do |statement|
key, field_id = statement.split(":")
next if key.blank? || field_id.blank?
val = info[key] || attributes.multi(key)&.join(",")
user.custom_fields["user_field_#{field_id}"] = val if val.present?
end
end
def sync_moderator(user, attributes)
return unless setting(:sync_moderator)
roles = attributes.multi("roles") || []
is_moderator = roles.include?("discourse-moderator") or roles.include?("discourse-admin")
return if user.moderator == is_moderator
user.moderator = is_moderator
user.save
end
def sync_admin(user, attributes)
return unless setting(:sync_admin)
roles = attributes.multi("roles") || []
is_admin = roles.include?("discourse-admin")
return if user.admin == is_admin
user.admin = is_admin
user.save
end
def sync_trust_level(user, attributes)
return unless setting(:sync_trust_level)
trust_level_attribute = setting(:trust_level_attribute) || "trustLevel"
level = attributes.single(trust_level_attribute).to_i
return unless level.between?(1, 4)
if user.manual_locked_trust_level != level
user.manual_locked_trust_level = level
user.save
end
return if user.trust_level == level
user.change_trust_level!(level, log_action_for: user)
end
def sync_locale(user, attributes)
return unless setting(:sync_locale)
locale_attribute = setting(:locale_attribute) || "locale"
locale = attributes.single(locale_attribute)
return unless LocaleSiteSetting.valid_value?(locale)
if user.locale != locale
user.locale = locale
user.save
end
end
def enabled?
# Checking target_url global setting for backwards compatibility
# (the plugin used to be enabled-by-default)
setting(:enabled) || !!GlobalSetting.try("#{name}_target_url")
end
def can_connect_existing_user?
false
end
def can_revoke?
false
end
def self.saml_base_url
DiscourseSaml.setting(:base_url).presence || Discourse.base_url
end
private
def idp_cert_multi
return unless setting(:cert_multi).present?
certificates = setting(:cert_multi).split("|")
certificates.push(setting(:cert)) if setting(:cert).present?
{ signing: certificates, encryption: [] }
end
def resolve_name(name, username, email)
return name if name.present?
suggester_input = username.presence
suggester_input ||= email if SiteSetting.use_email_for_username_and_name_suggestions
User.suggest_name(suggester_input)
end
def resolve_username(username, name, email, uid)
username
end
end
{
"name": "discourse-saml",
"version": "1.0.0",
"repository": "https://github.com/discourse/discourse-saml",
"author": "Discourse",
"license": "MIT",
"devDependencies": {
"eslint-config-discourse": "^3.1.0"
}
}
# frozen_string_literal: true
# name: discourse-saml
# about: SAML Auth Provider
# version: 0.1
# author: Robin Ward
require_dependency 'auth/oauth2_authenticator'
gem 'macaddr', '1.0.0'
gem 'uuid', '2.3.7'
gem 'ruby-saml', '1.3.1'
gem "omniauth-saml", '1.6.0'
request_method = GlobalSetting.try(:saml_request_method) || 'get'
class SamlAuthenticator < ::Auth::OAuth2Authenticator
def register_middleware(omniauth)
omniauth.provider :saml,
:name => 'saml',
:issuer => Discourse.base_url,
:idp_sso_target_url => GlobalSetting.saml_target_url,
:idp_cert_fingerprint => GlobalSetting.try(:saml_cert_fingerprint),
:idp_cert => GlobalSetting.try(:saml_cert),
:attribute_statements => { :nickname => ['screenName'] },
:assertion_consumer_service_url => Discourse.base_url + "/auth/saml/callback",
:custom_url => (GlobalSetting.try(:saml_request_method) == 'post') ? "/discourse_saml" : nil
end
def after_authenticate(auth)
result = Auth::Result.new
# version: 1.0
# author: Discourse Team
# url: https://github.com/discourse/discourse-saml
# transpile_js: true
gem "macaddr", "1.0.0"
gem "uuid", "2.3.7"
gem "rexml", "3.2.5"
gem "ruby-saml", "1.13.0"
gem "omniauth-saml", "1.9.0"
enabled_site_setting :saml_enabled if !GlobalSetting.try("saml_target_url")
on(:before_session_destroy) do |data|
next if !DiscourseSaml.setting(:slo_target_url).present?
data[:redirect_url] = Discourse.base_path + "/auth/saml/spslo"
end
if GlobalSetting.try(:saml_log_auth)
::PluginStore.set("saml", "saml_last_auth", auth.inspect)
::PluginStore.set("saml", "saml_last_auth_raw_info", auth.extra[:raw_info].inspect)
::PluginStore.set("saml", "saml_last_auth_extra", auth.extra.inspect)
end
module ::DiscourseSaml
def self.enabled?
# Legacy - we only check the enabled site setting
# if the environment-variables are **not** present
!!GlobalSetting.try("saml_target_url") || SiteSetting.saml_enabled
end
uid = auth[:uid]
result.name = auth[:info].name || uid
result.username = uid
if auth.extra.present? && auth.extra[:raw_info].present?
result.username = auth.extra[:raw_info].attributes['screenName'].try(:first) || uid
def self.setting(key, prefer_prefix: "saml_")
if prefer_prefix == "saml_"
SiteSetting.get("saml_#{key}")
else
GlobalSetting.try("#{prefer_prefix}#{key}") || SiteSetting.get("saml_#{key}")
end
end
if GlobalSetting.try(:saml_use_uid) && auth.extra.present? && auth.extra[:raw_info].present?
result.username = auth.extra[:raw_info].attributes['uid'].try(:first) || uid
end
def self.is_saml_forced_domain?(email)
return if !enabled?
return if !DiscourseSaml.setting(:forced_domains).present?
return if email.blank?
result.email = auth[:info].email || uid
result.email_valid = true
DiscourseSaml
.setting(:forced_domains)
.split(/[,|]/)
.each { |domain| return true if email.end_with?("@#{domain}") }
if result.respond_to?(:skip_email_validation) && GlobalSetting.try(:saml_skip_email_validation)
result.skip_email_validation = true
end
false
end
end
current_info = ::PluginStore.get("saml", "saml_user_#{uid}")
if current_info
result.user = User.where(id: current_info[:user_id]).first
after_initialize do
if !!GlobalSetting.try("saml_target_url")
# Configured via environment variables. Hide all the site settings
# from the UI to avoid confusion
SiteSetting.defaults.all.keys.each do |k|
next if !k.to_s.start_with?("saml_")
SiteSetting.hidden_settings << k
end
end
result.user ||= User.where(email: Email.downcase(result.email)).first
if GlobalSetting.try(:saml_clear_username) && result.user.blank?
result.username = ''
# "SAML Forced Domains" - Prevent login via email
on(:before_email_login) do |user|
if ::DiscourseSaml.is_saml_forced_domain?(user.email)
raise Discourse::InvalidAccess.new(nil, nil, custom_message: "login.use_saml_auth")
end
end
if GlobalSetting.try(:saml_omit_username) && result.user.blank?
result.omit_username = true
# "SAML Forced Domains" - Prevent login via regular username/password
module ::DiscourseSaml::SessionControllerExtensions
def login_error_check(user)
if ::DiscourseSaml.is_saml_forced_domain?(user.email)
return { error: I18n.t("login.use_saml_auth") }
end
super
end
sync_groups(result.user, auth) unless result.user.blank?
result.extra_data = { saml_user_id: uid }
result
end
::SessionController.prepend(::DiscourseSaml::SessionControllerExtensions)
def after_create_account(user, auth)
::PluginStore.set("saml", "saml_user_#{auth[:extra_data][:saml_user_id]}", {user_id: user.id })
sync_groups(user, auth)
# "SAML Forced Domains" - Prevent login via other omniauth strategies
class ::DiscourseSaml::ForcedSamlError < StandardError
end
def self.sync_groups(auth)
return unless GlobalSetting.try(:saml_sync_groups) && GlobalSetting.try(:saml_sync_groups_list) && auth.extra.present? && auth.extra[:raw_info].present?
total_group_list = GlobalSetting.try(:saml_sync_groups_list).split('|')
user_group_list = auth.extra[:raw_info].attributes['memberOf']
groups_to_add = Group.where(name: total_group_list & user_group_list)
groups_to_add.each do |group|
group.add result.user
end
groups_to_remove = Group.where(name: total_group_list - user_group_list)
groups_to_remove.each do |group|
group.remove result.user
on(:after_auth) do |authenticator, result|
next if authenticator.name == "saml"
if [result.user&.email, result.email].any? { |e| ::DiscourseSaml.is_saml_forced_domain?(e) }
raise ::DiscourseSaml::ForcedSamlError
end
end
Users::OmniauthCallbacksController.rescue_from(::DiscourseSaml::ForcedSamlError) do
flash[:error] = I18n.t("login.use_saml_auth")
render("failure")
end
end
if request_method == 'post'
after_initialize do
module ::DiscourseSaml
class Engine < ::Rails::Engine
engine_name "discourse_saml"
isolate_namespace DiscourseSaml
end
end
class DiscourseSaml::DiscourseSamlController < ::ApplicationController
skip_before_filter :check_xhr
def index
authn_request = OneLogin::RubySaml::Authrequest.new
metadata_url = GlobalSetting.try(:saml_metadata_url)
settings = nil
if metadata_url
idp_metadata_parser = OneLogin::RubySaml::IdpMetadataParser.new
settings = idp_metadata_parser.parse_remote(metadata_url)
settings.idp_sso_target_url = GlobalSetting.saml_target_url
settings.idp_cert ||= GlobalSetting.try(:saml_cert)
else
settings = OneLogin::RubySaml::Settings.new(:idp_sso_target_url => GlobalSetting.saml_target_url,
:idp_cert_fingerprint => GlobalSetting.try(:saml_cert_fingerprint),
:idp_cert => GlobalSetting.try(:saml_cert))
end
settings.compress_request = false
settings.passive = false
settings.issuer = Discourse.base_url
settings.assertion_consumer_service_url = Discourse.base_url + "/auth/saml/callback"
settings.name_identifier_format = "urn:oasis:names:tc:SAML:2.0:protocol"
saml_params = authn_request.create_params(settings, {})
@saml_req = saml_params['SAMLRequest']
render text: <<-HTML_FORM
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">
<body onload="document.forms[0].submit()">
<noscript>
<p>
<strong>Note:</strong> Since your browser does not support JavaScript,
you must press the Continue button once to proceed.
</p>
</noscript>
<form action="#{GlobalSetting.saml_target_url}" method="post">
<div>
<input type="hidden" name="SAMLRequest" value="#{@saml_req}"/>
</div>
<noscript>
<div>
<input type="submit" value="Continue"/>
</div>
</noscript>
</form>
</body>
</html>
HTML_FORM
end
end
require_relative "lib/discourse_saml/saml_omniauth_strategy"
require_relative "lib/saml_authenticator"
DiscourseSaml::Engine.routes.draw do
get '/' => 'discourse_saml#index'
end
Discourse::Application.routes.append do
mount ::DiscourseSaml::Engine, at: "/discourse_saml"
end
end
end
# Allow GlobalSettings to override the translations
# If the global settings are not provided, will use the `js.login.saml.name` and `js.login.saml.title` translations
name = GlobalSetting.try(:saml_title)
button_title = GlobalSetting.try(:saml_button_title) || GlobalSetting.try(:saml_title)
title = GlobalSetting.try(:saml_title) || "SAML"
button_title = GlobalSetting.try(:saml_button_title) || GlobalSetting.try(:saml_title) || "with SAML"
auth_provider :title => button_title,
:authenticator => SamlAuthenticator.new('saml'),
:message => "Authorizing with #{title} (make sure pop up blockers are not enabled)",
:frame_width => 600,
:frame_height => 380,
:background_color => '#003366',
:full_screen_login => GlobalSetting.try(:saml_full_screen_login) || false,
:custom_url => request_method == 'post' ? "/discourse_saml" : nil
auth_provider title: button_title, pretty_name: name, authenticator: SamlAuthenticator.new