# frozen_string_literal: true #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License version 3. # # OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: # Copyright (C) 2006-2013 Jean-Philippe Lang # Copyright (C) 2010-2013 the ChiliProject Team # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. # # See COPYRIGHT and LICENSE files for more details. #++ ## # If OPENPROJECT_AUTH__SOURCE__SSO_HEADER and OPENPROJECT_AUTH__SOURCE__SSO_SECRET are # configured OpenProject will login the user given in the HTTP header with the given name # together with the secret in the form of `login:$secret`. module AuthSourceSSO def find_current_user user = super # Do nothing if sso disabled return user unless auth_source_sso_enabled? # Return super auth if SSO already in progress return user if sso_in_progress! # Get the header-provided login value login = read_sso_login if login.present? perform_header_sso login, user elsif header_optional? user else handle_sso_failure! nil end end def perform_header_sso(login, user) # Log out the current user if the login does not match logged_user = match_sso_with_logged_user(login, user) # Return the logged in user if matches # but remember it came from auth_source_sso if logged_user.present? session[:user_from_auth_header] = true return logged_user end Rails.logger.debug { "Starting header-based auth source SSO for #{header_name}='#{op_auth_header_value}'" } # Try to find an existing, or autocreate a new user for onthefly ldap connections user = LdapAuthSource.find_user(login) handle_sso_for! user, login end def match_sso_with_logged_user(login, user) return if user.nil? return user if user.login.casecmp?(login) Rails.logger.warn { "Header-based auth source SSO user changed from #{user.login} to #{login}. Re-authenticating" } ::Users::LogoutService.new(controller: self).call!(user) nil end def read_sso_login get_validated_login! op_auth_header_value end def sso_config @sso_config ||= OpenProject::Configuration.auth_source_sso.try(:with_indifferent_access) end def auth_source_sso_enabled? header_name.present? end def op_auth_header_value String(request.headers[header_name]) end def header_name sso_config && sso_config[:header].to_s end def header_secret sso_config && sso_config[:secret].to_s end def header_optional? sso_config && sso_config[:optional] end def header_slo_url sso_config && sso_config[:logout_url] end def get_validated_login!(value) login, valid_secret = extract_from_header(value) unless valid_secret Rails.logger.error("Secret contained in auth source SSO header #{header_name} is not valid.") return nil end unless login.present? Rails.logger.error("Provided SSO header #{header_name} is empty or not valid.") return nil end login end def extract_from_header(value) if header_secret.present? valid_secret = value.end_with?(":#{header_secret}") login = value.gsub(/:#{Regexp.escape(header_secret)}\z/, "") [login, valid_secret] else [value, true] end end def find_user_from_auth_source(login) User .by_login(login) .where.not(ldap_auth_source_id: nil) .first end def build_user_from_auth_source(login) attrs = LdapAuthSource.get_user_attributes(login) return unless attrs call = Users::SetAttributesService .new(model: User.new, user: User.system, contract_class: Users::CreateContract) .call(attrs.merge(login:)) user = call.result call.on_failure do logger.error "Tried to create user '#{login}' from external auth source but failed: #{call.message}" end user end def sso_in_progress! sso_failure_in_progress! || session[:auth_source_registration] || session[:authenticated_user_id] end def sso_failure_in_progress! failure = session[:auth_source_sso_failure] if failure if failure[:ttl] > 0 session[:auth_source_sso_failure] = failure.merge(ttl: failure[:ttl] - 1) else session.delete :auth_source_sso_failure nil end end end def sso_login_failed?(user) user.nil? || user.new_record? || !(user.active? || user.invited?) end def handle_sso_for!(user, login) if sso_login_failed?(user) handle_sso_failure!(login:) else # valid user # If a user is invited, ensure it gets activated activated = user.invited? activate_user_if_invited! user handle_sso_success user, activated end end def handle_sso_success(user, just_activated) session[:user_from_auth_header] = true # remember the back_url so we can redirect to the original request session[:back_url] = request.fullpath successful_authentication(user, reset_stages: true, just_registered: just_activated) end def activate_user_if_invited!(user) return unless user.invited? user.active! end def perform_post_logout(prev_session, previous_user) if prev_session[:user_from_auth_header] && header_slo_url.present? redirect_to(header_slo_url, allow_other_host: true) else super end end def handle_sso_failure!(login: nil) session[:auth_source_sso_failure] = { login:, back_url: request.base_url + request.original_fullpath, ttl: 1 } redirect_to sso_failure_path end end