diff --git a/.dockerignore b/.dockerignore index 0245e9501..9abd6e233 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,6 @@ # Ignore bundler config. config/database.yml +config/auth_provider.yml # Ignore database files. postgresql diff --git a/.gitignore b/.gitignore index 27cdf7522..d5b342488 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ # Ignore application configurations /config/application.yml /config/database.yml +/config/auth_provider.yml .env *.DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index e9b77392a..1b7e51365 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,8 +22,10 @@ - Fill the holes in the logical sequence of invoices references with nil invoices - Updated the invoices chaining method with a more flexible model - Fix a bug: broken display after a plan category was deleted -- [TODO DEPLOY] `rails fablab:restore_order_number` THEN `rails fablab:fix_references` - Fix a security issue: updated json5 to 2.2.2 to fix [CVE-2022-46175](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2022-46175) +- [TODO DEPLOY] `\curl -sSL https://raw.githubusercontent.com/sleede/fab-manager/master/scripts/mount-auth-provider.sh | bash` +- [TODO DEPLOY] `rails fablab:auth:write_provider` +- [TODO DEPLOY] `rails fablab:restore_order_number` THEN `rails fablab:fix_references` ## v5.9.1 2023 March 22 diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 05a2eb171..128fa7c71 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -2,13 +2,12 @@ # Devise controller for handling client sessions class SessionsController < Devise::SessionsController - def new - active_provider = AuthProvider.active - if active_provider.providable_type != DatabaseProvider.name - redirect_post "/users/auth/#{active_provider.strategy_name}", params: { authenticity_token: form_authenticity_token } - else + active_provider = Rails.configuration.auth_provider + if active_provider.providable_type == 'DatabaseProvider' super + else + redirect_post "/users/auth/#{active_provider.strategy_name}", params: { authenticity_token: form_authenticity_token } end end end diff --git a/app/controllers/users/omniauth_callbacks_controller.rb b/app/controllers/users/omniauth_callbacks_controller.rb index d01ae980f..d22a6bebf 100644 --- a/app/controllers/users/omniauth_callbacks_controller.rb +++ b/app/controllers/users/omniauth_callbacks_controller.rb @@ -5,7 +5,7 @@ class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController require 'sso_logger' logger = SsoLogger.new - active_provider = AuthProvider.active + active_provider = Rails.configuration.auth_provider define_method active_provider.strategy_name do logger.info "[Users::OmniauthCallbacksController##{active_provider.strategy_name}] initiated" if request.env['omniauth.params'].blank? @@ -18,7 +18,7 @@ class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController logger.debug 'trying to create a new user' # If the username is mapped, we just check its uniqueness as it would break the postgresql # unique constraint otherwise. If the name is not unique, another unique is generated - if active_provider.sso_fields.include?('user.username') + if active_provider.db.sso_fields.include?('user.username') logger.debug 'the username was already in use, generating a new one' @user.username = generate_unique_username(@user.username) end @@ -26,7 +26,7 @@ class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController # unique random string, because: # - if it is the same user, his email will be filled from the SSO when he merge his accounts # - if it is not the same user, this will prevent the raise of PG::UniqueViolation - if active_provider.sso_fields.include?('user.email') && email_exists?(@user.email) + if active_provider.db.sso_fields.include?('user.email') && email_exists?(@user.email) logger.debug 'the email was already in use, marking it as duplicate' old_mail = @user.email @user.email = "<#{old_mail}>#{Devise.friendly_token}-duplicate" @@ -46,13 +46,13 @@ class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController @user.email = User.find(@user.id).email end end + # For users imported from the SSO, we consider the SSO as a source of trust so the email is automatically validated + @user.confirmed_at = Time.current if active_provider.db.sso_fields.include?('user.email') && !email_exists?(@user.email) # We BYPASS THE VALIDATION because, in case of a new user, we want to save him anyway, we'll ask him later to complete his profile (on first login). # In case of an existing user, we trust the SSO validation as we want the SSO to have authority on users management and policy. logger.debug 'saving the user' - unless @user.save(validate: false) - logger.error "unable to save the user, an error occurred : #{@user.errors.full_messages.join(', ')}" - end + logger.error "unable to save the user, an error occurred : #{@user.errors.full_messages.join(', ')}" unless @user.save(validate: false) logger.debug 'signing-in the user and redirecting' sign_in_and_redirect @user, event: :authentication # this will throw if @user is not activated @@ -77,7 +77,6 @@ class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController raise e end end - end private diff --git a/app/models/auth_provider.rb b/app/models/auth_provider.rb index 126164938..756982709 100644 --- a/app/models/auth_provider.rb +++ b/app/models/auth_provider.rb @@ -11,6 +11,10 @@ class AuthProvider < ApplicationRecord def name 'DatabaseProvider::SimpleAuthProvider' end + + def strategy_name + "database-#{name.downcase.parameterize}" + end end PROVIDABLE_TYPES = %w[DatabaseProvider OAuth2Provider OpenIdConnectProvider].freeze diff --git a/app/models/concerns/single_sign_on_concern.rb b/app/models/concerns/single_sign_on_concern.rb index 1a78aa43d..a3804e332 100644 --- a/app/models/concerns/single_sign_on_concern.rb +++ b/app/models/concerns/single_sign_on_concern.rb @@ -7,8 +7,8 @@ module SingleSignOnConcern included do # enable OmniAuth authentication only if needed - devise :omniauthable, omniauth_providers: [AuthProvider.active.strategy_name.to_sym] unless - AuthProvider.active.providable_type == DatabaseProvider.name + devise :omniauthable, omniauth_providers: [Rails.configuration.auth_provider.strategy_name.to_sym] unless + Rails.configuration.auth_provider.providable_type == 'DatabaseProvider' ## Retrieve the requested data in the User and user's Profile tables ## @param sso_mapping {String} must be of form 'user._field_' or 'profile._field_'. Eg. 'user.email' @@ -39,7 +39,7 @@ module SingleSignOnConcern ## link the current user to the given provider (omniauth attributes hash) ## and remove the auth_token to mark his account as "migrated" def link_with_omniauth_provider(auth) - active_provider = AuthProvider.active + active_provider = Rails.configuration.auth_provider raise SecurityError, 'The identity provider does not match the activated one' if active_provider.strategy_name != auth.provider if User.where(provider: auth.provider, uid: auth.uid).size.positive? @@ -104,7 +104,7 @@ module SingleSignOnConcern def from_omniauth(auth) logger = SsoLogger.new logger.debug "[User::from_omniauth] initiated with parameter #{auth}" - active_provider = AuthProvider.active + active_provider = Rails.configuration.auth_provider raise SecurityError, 'The identity provider does not match the activated one' if active_provider.strategy_name != auth.provider where(provider: auth.provider, uid: auth.uid).first_or_create.tap do |user| diff --git a/app/models/open_id_connect_provider.rb b/app/models/open_id_connect_provider.rb index e91095edc..26989cc32 100644 --- a/app/models/open_id_connect_provider.rb +++ b/app/models/open_id_connect_provider.rb @@ -16,20 +16,4 @@ class OpenIdConnectProvider < ApplicationRecord validates :display, inclusion: { in: %w[page popup touch wap], allow_nil: true } validates :prompt, inclusion: { in: %w[none login consent select_account], allow_nil: true } validates :client_auth_method, inclusion: { in: %w[basic jwks] } - - def scope - self[:scope]&.join(' ') - end - - def config - OpenIdConnectProvider.columns.map(&:name).filter { |n| !n.start_with?('client__') && n != 'profile_url' }.map do |n| - [n, send(n)] - end.push(['client_options', client_config]).to_h - end - - def client_config - OpenIdConnectProvider.columns.map(&:name).filter { |n| n.start_with?('client__') }.to_h do |n| - [n.sub('client__', ''), send(n)] - end - end end diff --git a/app/views/auth_provider/provider.json.jbuilder b/app/views/auth_provider/provider.json.jbuilder new file mode 100644 index 000000000..135f082a5 --- /dev/null +++ b/app/views/auth_provider/provider.json.jbuilder @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +json.partial! 'api/auth_providers/auth_provider', auth_provider: provider + +# OAuth 2.0 + +if provider.providable_type == 'OAuth2Provider' + json.providable_attributes do + json.extract! provider.providable, :id, :base_url, :token_endpoint, :authorization_endpoint, :profile_url, :client_id, :client_secret, + :scopes + end +end + +if provider.providable_type == 'OpenIdConnectProvider' + json.providable_attributes do + json.extract! provider.providable, :id, :issuer, :discovery, :client_auth_method, :scope, :response_type, :response_mode, + :display, :prompt, :send_scope_to_token_endpoint, :client__identifier, :client__secret, :client__authorization_endpoint, + :client__token_endpoint, :client__userinfo_endpoint, :client__jwks_uri, :client__end_session_endpoint, :profile_url, + :post_logout_redirect_uri, :uid_field, :client__redirect_uri, :client__scheme, :client__host, :client__port + end +end + diff --git a/config/application.rb b/config/application.rb index fedd0f80e..44874c4d5 100644 --- a/config/application.rb +++ b/config/application.rb @@ -76,6 +76,9 @@ class FabManager::Application < Rails::Application # disable ANSI color escape codes in active_record if NO_COLOR is defined. config.colorize_logging = ENV['NO_COLOR'] ? false : true + require 'provider_config' + config.auth_provider = ProviderConfig.new + FabManager.activate_plugins! config.action_view.sanitized_allowed_tags = %w[a acronym hr pre table b strong i em li ul ol h1 h2 h3 h4 h5 h6 blockquote br cite sub sup ins p diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index 743ce14bd..c2f2faceb 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -227,17 +227,23 @@ Devise.setup do |config| # Add a new OmniAuth provider. Check the wiki for more information on setting # up on your models and hooks. # config.omniauth :github, 'APP_ID', 'APP_SECRET', :scope => 'user,public_repo' - Rails.application.reloader.to_prepare do - active_provider = AuthProvider.active - if active_provider.providable_type == OAuth2Provider.name + active_provider = Rails.configuration.auth_provider + unless active_provider.nil? + # noinspection RubyCaseWithoutElseBlockInspection + case active_provider.providable_type + when 'OAuth2Provider' require_relative '../../lib/omni_auth/oauth2' - config.omniauth OmniAuth::Strategies::SsoOauth2Provider.name.to_sym, + config.omniauth active_provider.strategy_name.to_sym, active_provider.providable.client_id, - active_provider.providable.client_secret - elsif active_provider.providable_type == OpenIdConnectProvider.name + active_provider.providable.client_secret, + strategy_class: OmniAuth::Strategies::SsoOauth2Provider + + when 'OpenIdConnectProvider' require_relative '../../lib/omni_auth/openid_connect' - config.omniauth OmniAuth::Strategies::SsoOpenidConnectProvider.name.to_sym, - active_provider.providable.config + config.omniauth active_provider.strategy_name.to_sym, + active_provider.oidc_config.merge( + strategy_class: OmniAuth::Strategies::SsoOpenidConnectProvider + ) end end diff --git a/config/routes.rb b/config/routes.rb index 496db7a04..4f9872af2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -4,7 +4,7 @@ require 'sidekiq_unique_jobs/web' require 'sidekiq-scheduler/web' Rails.application.routes.draw do - if AuthProvider.active.providable_type == DatabaseProvider.name + if Rails.configuration.auth_provider.providable_type == 'DatabaseProvider' # with local authentication we do not use omniAuth so we must differentiate the config devise_for :users, controllers: { registrations: 'registrations', sessions: 'sessions', confirmations: 'confirmations', passwords: 'passwords' diff --git a/db/seeds.rb b/db/seeds.rb index 2e5883c0f..93ef4ab10 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -249,6 +249,9 @@ unless DatabaseProvider.count.positive? provider.providable = db_provider provider.status = 'active' provider.save + + require 'provider_config' + ProviderConfig.write_active_provider end end diff --git a/lib/omni_auth/strategies/sso_oauth2_provider.rb b/lib/omni_auth/strategies/sso_oauth2_provider.rb index 2f4e8892d..2fc3feb75 100644 --- a/lib/omni_auth/strategies/sso_oauth2_provider.rb +++ b/lib/omni_auth/strategies/sso_oauth2_provider.rb @@ -5,76 +5,74 @@ require 'jsonpath' require 'sso_logger' require_relative '../data_mapping/mapper' -module OmniAuth::Strategies - # Authentication strategy provided trough oAuth 2.0 - class SsoOauth2Provider < OmniAuth::Strategies::OAuth2 - include OmniAuth::DataMapping::Mapper +# Authentication strategy provided trough oAuth 2.0 +class OmniAuth::Strategies::SsoOauth2Provider < OmniAuth::Strategies::OAuth2 + include OmniAuth::DataMapping::Mapper - def self.active_provider - active_provider = AuthProvider.active - if active_provider.providable_type != OAuth2Provider.name - raise "Trying to instantiate the wrong provider: Expected OAuth2Provider, received #{active_provider.providable_type}" - end - - active_provider + def self.active_provider + active_provider = Rails.configuration.auth_provider + if active_provider.providable_type != 'OAuth2Provider' + raise "Trying to instantiate the wrong provider: Expected OAuth2Provider, received #{active_provider.providable_type}" end - # Strategy name. - option :name, active_provider.strategy_name + active_provider + end - option :client_options, - site: active_provider.providable.base_url, - authorize_url: active_provider.providable.authorization_endpoint, - token_url: active_provider.providable.token_endpoint + # Strategy name. + option :name, active_provider.strategy_name - def authorize_params - super.tap do |params| - params[:scope] = OmniAuth::Strategies::SsoOauth2Provider.active_provider.providable.scopes - end - end + option :client_options, + site: active_provider.providable.base_url, + authorize_url: active_provider.providable.authorization_endpoint, + token_url: active_provider.providable.token_endpoint - def callback_url - url = Rails.application.config.action_controller.default_url_options - "#{url[:protocol]}://#{url[:host]}#{script_name}#{callback_path}" - end - - uid { parsed_info[:'user.uid'] } - - info do - { - mapping: parsed_info - } - end - - extra do - { - raw_info: raw_info - } - end - - # retrieve data from various url, querying each only once - def raw_info - logger = SsoLogger.new - - @raw_info ||= {} - logger.debug "[raw_info] @raw_infos = #{@raw_info&.to_json}" - unless @raw_info.size.positive? - OmniAuth::Strategies::SsoOauth2Provider.active_provider.auth_provider_mappings.each do |mapping| - logger.debug "mapping = #{mapping&.to_json}" - next if @raw_info.key?(mapping.api_endpoint.to_sym) - - logger.debug "api_endpoint = #{mapping.api_endpoint.to_sym}" - logger.debug "access_token = #{access_token&.to_json}" - logger.debug "token get = #{access_token.get(mapping.api_endpoint)}" - logger.debug "parsed = #{access_token.get(mapping.api_endpoint).parsed}" - @raw_info[mapping.api_endpoint.to_sym] = access_token.get(mapping.api_endpoint).parsed - end - end - @raw_info - end - - def parsed_info - mapped_info(OmniAuth::Strategies::SsoOauth2Provider.active_provider.auth_provider_mappings, raw_info) + def authorize_params + super.tap do |params| + params[:scope] = OmniAuth::Strategies::SsoOauth2Provider.active_provider.providable.scopes end end + + def callback_url + url = Rails.application.config.action_controller.default_url_options + "#{url[:protocol]}://#{url[:host]}#{script_name}#{callback_path}" + end + + uid { parsed_info[:'user.uid'] } + + info do + { + mapping: parsed_info + } + end + + extra do + { + raw_info: raw_info + } + end + + # retrieve data from various url, querying each only once + def raw_info + logger = SsoLogger.new + + @raw_info ||= {} + logger.debug "[raw_info] @raw_infos = #{@raw_info&.to_json}" + unless @raw_info.size.positive? + OmniAuth::Strategies::SsoOauth2Provider.active_provider.auth_provider_mappings.each do |mapping| + logger.debug "mapping = #{mapping&.to_json}" + next if @raw_info.key?(mapping.api_endpoint.to_sym) + + logger.debug "api_endpoint = #{mapping.api_endpoint.to_sym}" + logger.debug "access_token = #{access_token&.to_json}" + logger.debug "token get = #{access_token.get(mapping.api_endpoint)}" + logger.debug "parsed = #{access_token.get(mapping.api_endpoint).parsed}" + @raw_info[mapping.api_endpoint.to_sym] = access_token.get(mapping.api_endpoint).parsed + end + end + @raw_info + end + + def parsed_info + mapped_info(OmniAuth::Strategies::SsoOauth2Provider.active_provider.auth_provider_mappings, raw_info) + end end diff --git a/lib/omni_auth/strategies/sso_openid_connect_provider.rb b/lib/omni_auth/strategies/sso_openid_connect_provider.rb index 5f6b0b3bf..aa3c2c2c2 100644 --- a/lib/omni_auth/strategies/sso_openid_connect_provider.rb +++ b/lib/omni_auth/strategies/sso_openid_connect_provider.rb @@ -3,34 +3,32 @@ require 'omniauth_openid_connect' require_relative '../data_mapping/mapper' -module OmniAuth::Strategies - # Authentication strategy provided trough OpenID Connect - class SsoOpenidConnectProvider < OmniAuth::Strategies::OpenIDConnect - include OmniAuth::DataMapping::Mapper +# Authentication strategy provided trough OpenID Connect +class OmniAuth::Strategies::SsoOpenidConnectProvider < OmniAuth::Strategies::OpenIDConnect + include OmniAuth::DataMapping::Mapper - def self.active_provider - active_provider = AuthProvider.active - if active_provider.providable_type != OpenIdConnectProvider.name - raise "Trying to instantiate the wrong provider: Expected OpenIdConnectProvider, received #{active_provider.providable_type}" - end - - active_provider + def self.active_provider + active_provider = Rails.configuration.auth_provider + if active_provider.providable_type != 'OpenIdConnectProvider' + raise "Trying to instantiate the wrong provider: Expected OpenIdConnectProvider, received #{active_provider.providable_type}" end - # Strategy name. - option :name, active_provider.strategy_name + active_provider + end - info do - { - mapping: parsed_info - } - end + # Strategy name. + option :name, active_provider.strategy_name - def parsed_info - mapped_info( - OmniAuth::Strategies::SsoOpenidConnectProvider.active_provider.auth_provider_mappings, - user_info: user_info.raw_attributes - ) - end + info do + { + mapping: parsed_info + } + end + + def parsed_info + mapped_info( + OmniAuth::Strategies::SsoOpenidConnectProvider.active_provider.auth_provider_mappings, + user_info: user_info.raw_attributes + ) end end diff --git a/lib/provider_config.rb b/lib/provider_config.rb new file mode 100644 index 000000000..385cdd065 --- /dev/null +++ b/lib/provider_config.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +# Deals with the yml file keeping the configuration of the current authentication provider +class ProviderConfig + def initialize + @config = YAML.safe_load_file('config/auth_provider.yml').with_indifferent_access if File.exist?('config/auth_provider.yml') + end + + def db + AuthProvider.find(@config[:id]) + end + + def oidc_config + return nil unless @config[:providable_type] == 'OpenIdConnectProvider' + + (@config[:providable_attributes].keys.filter { |n| !n.start_with?('client__') && n != 'profile_url' }.map do |n| + val = @config[:providable_attributes][n] + val.join(' ') if n == 'scope' + [n, val] + end).push( + ['client_options', @config[:providable_attributes].keys.filter { |n| n.start_with?('client__') }.to_h do |n| + [n.sub('client__', ''), @config[:providable_attributes][n]] + end] + ).to_h + end + + def method_missing(method, *args) + return map_value(@config[method]) if @config.key?(method) + + return map_value(@config["#{method}_attributes"]) if @config.key?("#{method}_attributes") + + super + end + + def respond_to_missing?(name) + @config.key?(name) || @config.key("#{name}_attributes") + end + + def self.write_active_provider + data = ApplicationController.render( + template: 'auth_provider/provider', + locals: { provider: AuthProvider.active }, + handlers: [:jbuilder], + formats: [:json] + ) + file_path = Rails.root.join('config/auth_provider.yml') + File.open(file_path, File::WRONLY | File::CREAT) do |file| + file.write(JSON.parse(data).to_yaml) + end + end + + private + + def map_value(item) + return Struct.new(*item.symbolize_keys.keys).new(*item.values) if item.is_a?(Hash) + + return item.map { |v| map_value(v) } if item.is_a?(Array) + + item + end +end diff --git a/lib/tasks/fablab/auth.rake b/lib/tasks/fablab/auth.rake index 6e4e58ad3..adb20cc99 100644 --- a/lib/tasks/fablab/auth.rake +++ b/lib/tasks/fablab/auth.rake @@ -41,6 +41,10 @@ namespace :fablab do User.all.each(&:generate_auth_migration_token) end + # write the configuration to file + require 'provider_config' + ProviderConfig.write_active_provider + # ask the user to restart the application next if Rails.env.test? @@ -71,5 +75,11 @@ namespace :fablab do task current: :environment do puts "Current active authentication provider: #{AuthProvider.active.name}" end + + desc 'write the provider config to a configuration file' + task write_provider: :environment do + require 'provider_config' + ProviderConfig.write_active_provider + end end end diff --git a/scripts/mount-auth-provider.sh b/scripts/mount-auth-provider.sh new file mode 100644 index 000000000..cdf063ea3 --- /dev/null +++ b/scripts/mount-auth-provider.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash + +yq() { + docker run --rm -i -v "${PWD}:/workdir" --user "$UID" mikefarah/yq:4 "$@" +} + +config() +{ + echo -ne "Checking user... " + if [[ "$(whoami)" != "root" ]] && ! groups | grep docker + then + echo "Please add your current user to the docker group OR run this script as root." + echo "current user is not allowed to use docker, exiting..." + exit 1 + fi + SERVICE="$(yq eval '.services.*.image | select(. == "sleede/fab-manager*") | path | .[-2]' docker-compose.yml)" + echo -e "\n" +} + +add_mount() +{ + if [[ ! $(yq eval ".services.$SERVICE.volumes.[] | select (. == \"*auth_provider.yml\")" docker-compose.yml) ]]; then + # change docker-compose.yml permissions for fix yq can't modify file issue + chmod 666 docker-compose.yml + yq -i eval ".services.$SERVICE.volumes += [\"\./config/auth_provider.yml:/usr/src/app/auth_provider.yml\"]" docker-compose.yml + chmod 644 docker-compose.yml + fi +} + +proceed() +{ + config + add_mount +} + +proceed "$@" diff --git a/setup/docker-compose.yml b/setup/docker-compose.yml index 14535a251..480326d2f 100644 --- a/setup/docker-compose.yml +++ b/setup/docker-compose.yml @@ -19,6 +19,7 @@ services: - ./log:/var/log/supervisor - ./plugins:/usr/src/app/plugins - ./accounting:/usr/src/app/accounting + - ./config/auth_provider.yml:/usr/src/app/auth_provider.yml depends_on: - postgres - redis