diff --git a/Gemfile b/Gemfile index 210e2ea6..b7c58550 100644 --- a/Gemfile +++ b/Gemfile @@ -67,6 +67,10 @@ end # Windows does not include zoneinfo files, so bundle the tzinfo-data gem gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby] +gem "ruby-saml", "~> 1.15" +gem "devise_saml_authenticatable", "~> 1.9" +gem "redis", "~> 5.0" # Redis client for Ruby +gem "redis-actionpack", "~> 5.3" # Redis session store for ActionPack gem "rubocop-rails-omakase", require: false, group: [ :development ] gem 'simplecov', require: false, group: :test diff --git a/Gemfile.lock b/Gemfile.lock index 7dd755ac..ac2d90cc 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -70,9 +70,9 @@ GEM public_suffix (>= 2.0.2, < 6.0) ast (2.4.2) bcrypt (3.1.20) - bootsnap (1.17.1) + bootsnap (1.18.3) msgpack (~> 1.2) - brakeman (6.1.1) + brakeman (6.1.2) racc builder (3.2.4) bundler-audit (0.9.1) @@ -91,6 +91,7 @@ GEM marcel (~> 1.0.0) mime-types (~> 3.0) concurrent-ruby (1.2.3) + connection_pool (2.4.1) crass (1.0.6) date (3.3.4) devise (4.9.3) @@ -106,17 +107,20 @@ GEM devise_ldap_authenticatable (0.8.7) devise (>= 3.4.1) net-ldap (>= 0.16.0) + devise_saml_authenticatable (1.9.1) + devise (> 2.0.0) + ruby-saml (~> 1.7) docile (1.4.0) - doorkeeper (5.6.8) + doorkeeper (5.6.9) railties (>= 5) doorkeeper-i18n (5.2.7) doorkeeper (>= 5.2) - dotenv (2.8.1) - dotenv-rails (2.8.1) - dotenv (= 2.8.1) - railties (>= 3.2) + dotenv (3.0.2) + dotenv-rails (3.0.2) + dotenv (= 3.0.2) + railties (>= 6.1) erubi (1.12.0) - factory_bot (6.4.5) + factory_bot (6.4.6) activesupport (>= 5.0.0) factory_bot_rails (6.4.3) factory_bot (~> 6.4) @@ -145,12 +149,12 @@ GEM method_source (1.0.0) mime-types (3.5.2) mime-types-data (~> 3.2015) - mime-types-data (3.2023.1205) + mime-types-data (3.2024.0206) mini_magick (4.12.0) mini_mime (1.1.5) - minitest (5.21.2) + minitest (5.22.2) msgpack (1.7.2) - net-imap (0.4.9.1) + net-imap (0.4.10) date net-protocol net-ldap (0.19.0) @@ -170,7 +174,7 @@ GEM parser (3.3.0.5) ast (~> 2.4.1) racc - pg (1.5.4) + pg (1.5.5) public_suffix (5.0.4) puma (5.6.8) nio4r (~> 2.0) @@ -180,6 +184,8 @@ GEM rack (2.2.8) rack-cors (2.0.1) rack (>= 2.0.0) + rack-session (1.0.2) + rack (< 3) rack-test (2.1.0) rack (>= 1.3) rails (7.0.8) @@ -215,6 +221,19 @@ GEM zeitwerk (~> 2.5) rainbow (3.1.1) rake (13.1.0) + redis (5.1.0) + redis-client (>= 0.17.0) + redis-actionpack (5.4.0) + actionpack (>= 5, < 8) + redis-rack (>= 2.1.0, < 4) + redis-store (>= 1.1.0, < 2) + redis-client (0.20.0) + connection_pool + redis-rack (3.0.0) + rack-session (>= 0.2.0) + redis-store (>= 1.2, < 2) + redis-store (1.10.0) + redis (>= 4, < 6) regexp_parser (2.9.0) responders (3.1.1) actionpack (>= 5.2) @@ -250,7 +269,10 @@ GEM rubocop-performance rubocop-rails ruby-progressbar (1.13.0) - ruby-vips (2.2.0) + ruby-saml (1.16.0) + nokogiri (>= 1.13.10) + rexml + ruby-vips (2.2.1) ffi (~> 1.12) simplecov (0.22.0) docile (~> 1.1) @@ -269,7 +291,7 @@ GEM websocket-driver (0.7.6) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) - zeitwerk (2.6.12) + zeitwerk (2.6.13) PLATFORMS arm64-darwin-23 @@ -287,6 +309,7 @@ DEPENDENCIES devise-i18n devise-security devise_ldap_authenticatable + devise_saml_authenticatable (~> 1.9) doorkeeper doorkeeper-i18n dotenv-rails @@ -299,9 +322,12 @@ DEPENDENCIES rack-cors rails (~> 7.0.3) rails-i18n + redis (~> 5.0) + redis-actionpack (~> 5.3) rubocop rubocop-rails rubocop-rails-omakase + ruby-saml (~> 1.15) simplecov tzinfo-data diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 05f0a2a6..a84aa516 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -8,8 +8,12 @@ class ApplicationController < ActionController::API except: %i[info check_uuid password_forgotten change_password] def info - client_app = Doorkeeper::Application.find_by(uid: params["client_id"], secret: params["client_secret"]) - render json: { valid: client_app.present?, auth: ENV['ENABLE_AUTHENTICATION'].present? } + client_app = Doorkeeper::Application.find_by(uid: params['client_id'], secret: params['client_secret']) + render json: { + valid: client_app.present?, + auth: ENV['ENABLE_AUTHENTICATION'].present?, + sso_enabled: ENV['ENABLE_SSO'].present? ? ActiveModel::Type::Boolean.new.cast(ENV['ENABLE_SSO']) : false + } end protected diff --git a/app/controllers/pias_controller.rb b/app/controllers/pias_controller.rb index 055c2f6c..41d49320 100644 --- a/app/controllers/pias_controller.rb +++ b/app/controllers/pias_controller.rb @@ -18,7 +18,8 @@ def index res = [] # check if user is technical else his pias pias = if ENV['ENABLE_AUTHENTICATION'].blank? || current_user.is_technical_admin - Pia.all + Pia.eager_load(:user_pias) + .all else policy_scope(Pia) end diff --git a/app/controllers/saml_controller.rb b/app/controllers/saml_controller.rb new file mode 100644 index 00000000..d5ae5add --- /dev/null +++ b/app/controllers/saml_controller.rb @@ -0,0 +1,103 @@ +class SamlController < Doorkeeper::TokensController + # skip_before_action :doorkeeper_authorize! + + def metadata + meta = OneLogin::RubySaml::Metadata.new + render xml: meta.generate(settings, true) + end + + def sso + request = OneLogin::RubySaml::Authrequest.new + redirect_to(request.create(settings), allow_other_host: true) + end + + def consume + response = OneLogin::RubySaml::Response.new(params[:SAMLResponse], settings:) + + if response.is_valid? + email = response.name_id + session[:nameid] = response.name_id + user = User.find_by("LOWER(email) = ?", email.strip.downcase) + if user + user.unlock_access! + else + password = [*'0'..'9', *'a'..'z', *'A'..'Z', *'!'..'?'].sample(16).join + user = User.create!(email:, password:, password_confirmation: password) + user.is_user = true + user.unlock_access! + user.save + end + sign_in(:user, user) + + doorkeeper_app = Doorkeeper::Application.first + access_token = Doorkeeper::AccessToken.find_or_create_for( + application: doorkeeper_app, + resource_owner: user.id, + scopes: Doorkeeper::OAuth::Scopes.from_array(%w[public]) + ) + + # redirect_to frontrnd + redirect_to "#{ENV['SSO_FRONTEND_REDIRECTION']}/#/?sso_token=#{access_token.token}", allow_other_host: true + else + logger.info "Response Invalid. Errors: #{response.errors}" + @errors = response.errors + redirect_to root_path + end + end + + def logout + logout_request = OneLogin::RubySaml::Logoutrequest.new + session[:transaction_id] = logout_request.uuid + + logger.info "New SP SLO for User ID: '#{session[:nameid]}', Transaction ID: '#{session[:transaction_id]}'" + + settings.name_identifier_value = session[:nameid] if settings.name_identifier_value.nil? + + redirect_to(logout_request.create(settings), allow_other_host: true) + end + + # Handle the SLO response from the IdP + # GET /saml/slo + def slo + logout_response = if session.has_key? :transaction_id + OneLogin::RubySaml::Logoutresponse.new(params[:SAMLResponse], settings, + matches_request_id: session[:transaction_id]) + else + OneLogin::RubySaml::Logoutresponse.new(params[:SAMLResponse], settings) + end + logger.info "LogoutResponse is: #{logout_response}" + + # Validate the SAML Logout Response + if !logout_response.validate + logger.error 'The SAML Logout Response is invalid' + else + # Actually log out this session + logger.info "SLO completed for '#{session[:nameid]}'" + session[:nameid] = nil + session[:transaction_id] = nil + + redirect_to ENV['SSO_FRONTEND_REDIRECTION'], allow_other_host: true + end + end + + private + + def settings + settings = OneLogin::RubySaml::Settings.new + url_base = "#{request.protocol}#{request.host_with_port}" + + # settings.soft = true + settings.issuer = "#{url_base}/saml/metadata" + settings.assertion_consumer_service_url = "#{url_base}/saml/acs" + settings.assertion_consumer_logout_service_url = "#{url_base}/saml/slo" + + # IdP section + settings.idp_entity_id = ENV['IDP_ENTITY_ID'] + settings.idp_sso_target_url = ENV['IDP_SSO_TARGET_URL'] + settings.idp_slo_target_url = ENV['IDP_SLO_TARGET_URL'] + settings.idp_cert = ENV['IDP_CERT'] + settings.name_identifier_format = 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress' + + settings + end +end diff --git a/app/models/user.rb b/app/models/user.rb index f640deb8..f6cc258e 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -26,7 +26,7 @@ class User < ApplicationRecord dependent: :destroy def validate_login_uniqueness - errors.add(:login, :taken) if User.where(login: login).where.not(id: id).exists? + errors.add(:login, :taken) if User.where(login:).where.not(id:).exists? end def check_ldap_email diff --git a/app/policies/pia_policy.rb b/app/policies/pia_policy.rb index 478e6060..5ec2d552 100644 --- a/app/policies/pia_policy.rb +++ b/app/policies/pia_policy.rb @@ -43,9 +43,11 @@ def initialize(user, scope) def resolve if user.present? && user.is_functional_admin? - scope.all + scope.eager_load(:user_pias) + .all else - scope.joins(:user_pias).merge(UserPia.where(user_id: user.id)) + scope.eager_load(:user_pias) + .merge(UserPia.where(user_id: user.id)) end end end diff --git a/config/application.rb b/config/application.rb index ccc4cf0b..7e68df5c 100644 --- a/config/application.rb +++ b/config/application.rb @@ -1,4 +1,4 @@ -require_relative "boot" +require_relative 'boot' require 'rails' # Pick the frameworks you want: @@ -18,7 +18,6 @@ # you've limited to :test, :development, or :production. Bundler.require(*Rails.groups) - module PiaBack class Application < Rails::Application # Initialize configuration defaults for originally generated Rails version. @@ -38,7 +37,7 @@ class Application < Rails::Application config.api_only = true # set the default locale to French - config.i18n.default_locale = ENV.fetch("DEFAULT_LOCALE", :en) + config.i18n.default_locale = ENV.fetch('DEFAULT_LOCALE', :en) # if a locale isn't found fall back to this default locale config.i18n.fallbacks = true # set the possible locales to English and Brazilian-Portuguese @@ -50,5 +49,7 @@ class Application < Rails::Application config.action_view.sanitized_allowed_tags = tags_allowed attributes_allowed = ENV['SANITIZED_ALLOWED_ATTRIBUTES'] ? ENV['SANITIZED_ALLOWED_ATTRIBUTES'].split(' ') : [] config.action_view.sanitized_allowed_attributes = attributes_allowed + + config.secret_key_base = Rails.application.credentials.secret_key_base end end diff --git a/config/environments/development.rb b/config/environments/development.rb index 3d6b0736..638e796d 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -1,4 +1,4 @@ -require "active_support/core_ext/integer/time" +require 'active_support/core_ext/integer/time' Rails.application.configure do # Settings specified here will take precedence over those in config/application.rb. @@ -19,10 +19,10 @@ # Enable/disable caching. By default caching is disabled. # Run rails dev:cache to toggle caching. - if Rails.root.join("tmp/caching-dev.txt").exist? + if Rails.root.join('tmp/caching-dev.txt').exist? config.cache_store = :memory_store config.public_file_server.headers = { - "Cache-Control" => "public, max-age=#{2.days.to_i}" + 'Cache-Control' => "public, max-age=#{2.days.to_i}" } else config.action_controller.perform_caching = false @@ -53,7 +53,6 @@ # Highlight code that triggered database queries in logs. config.active_record.verbose_query_logs = true - # Raises error for missing translations. # config.i18n.raise_on_missing_translations = true @@ -62,4 +61,5 @@ # Uncomment if you wish to allow Action Cable access from any origin. # config.action_cable.disable_request_forgery_protection = true + config.hosts << ENV.fetch('RAILS_CONFIG_HOSTS', 'localhost:3000') end diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index 9d676df9..da55dfa6 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -58,7 +58,7 @@ # session. If you need permissions, you should implement that in a before filter. # You can also supply a hash where the value is a boolean determining whether # or not authentication should be aborted when the value is not present. - # config.authentication_keys = [:email] + config.authentication_keys = [:email] # Configure parameters from the request object used for authentication. Each entry # given should be a request method and it will automatically be passed to the @@ -320,4 +320,21 @@ # When set to false, does not sign a user in automatically after their password is # changed. Defaults to true, so a user is signed in automatically after changing a password. # config.sign_in_after_change_password = true + + # # config.saml_default_user_key = :email # or whatever attribute you want to use as the user identifier + # config.saml_create_user = true # Automatically create users + # config.saml_update_user = true # Update user attributes after login + # config.saml_configure do |saml| + # url_base = "#{ENV['SAML_URL_BASE']}" + # saml.issuer = "#{url_base}/saml/metadata" + # saml.assertion_consumer_service_url = "#{url_base}/saml/acs" + # saml.assertion_consumer_logout_service_url = "#{url_base}/saml/logout" + + # # IdP section + # saml.idp_entity_id = ENV['IDP_ENTITY_ID'] + # saml.idp_sso_target_url = ENV['IDP_SSO_TARGET_URL'] + # saml.idp_slo_target_url = ENV['IDP_SLO_TARGET_URL'] + # saml.idp_cert = ENV['IDP_CERT'] + # saml.name_identifier_format = 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress' + # end end diff --git a/config/initializers/session_store.rb b/config/initializers/session_store.rb new file mode 100644 index 00000000..83ce548f --- /dev/null +++ b/config/initializers/session_store.rb @@ -0,0 +1,12 @@ +Rails.application.config.session_store :redis_store, + url: "#{ENV.fetch('REDIS_URL', 'redis://127.0.0.1:6379')}/0/session", + expire_after: 30.days, + key: "_piaback_session_#{Rails.env}", + domain: ENV.fetch("DOMAIN_NAME", "localhost"), + threadsafe: true, + secure: Rails.env.production?, + same_site: :lax, + httponly: true + +Rails.application.config.middleware.use ActionDispatch::Cookies +Rails.application.config.middleware.use Rails.application.config.session_store, Rails.application.config.session_options diff --git a/config/routes.rb b/config/routes.rb index ee3aef3a..2359daa1 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -35,4 +35,10 @@ resources :knowledge_bases do resources :knowledges end + + get '/saml/metadata', to: 'saml#metadata' + get '/saml/sso', to: 'saml#sso' + get '/saml/logout', to: 'saml#logout' + post '/saml/acs', to: 'saml#consume' + get '/saml/slo', to: 'saml#slo' end