Files
openproject/lib_static/open_project/authentication.rb
T
Jan Sandbrink 508c8bbad7 Always respond in Bearer method for WWW-Authenticate header
The intention of this change is to always respond in the metadata-rich
version of the header that indicates things like the required scope and
the URL of the resource_metadata endpoint, which was previously hidden
and only visible if clients used a non-standard HTTP request header.

semantically it's probably the preferable version of the header by now
anyways, because:

* all APIs accept some kind of Bearer token, not all of them accept Basic auth
* Even API tokens can now be passed as Bearer tokens

Practically the Basic auth header also caused unintended browser pop-ups when the frontend
code didn't include the correct request header to avoid the Basic auth offer, this now can't
happen anymore, since the Basic auth version of the header is only returned, if the client actively
tried to authenticate through Basic auth.
2026-02-10 09:02:07 +01:00

291 lines
11 KiB
Ruby

# frozen_string_literal: true
# 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.
require "open_project/authentication/manager"
module OpenProject
##
# OpenProject uses Warden strategies for request authentication.
module Authentication
class << self
##
# Registers a given Warden strategy to be used for authentication.
#
# @param [Symbol] Name under which the strategy can be referred to.
# @param [Class] The strategy class.
# @param [String] The authentication scheme implemented by this strategy.
# Used in the WWW-Authenticate header in 401 responses.
def add_strategy(name, clazz, auth_scheme)
Warden::Strategies.add name, clazz
info = Manager.auth_scheme auth_scheme
info.strategies << name
end
##
# Updates the used warden strategies for a given scope. The strategies will be tried
# in the order they are set here. Plugins can call this to add or remove strategies.
# For available scopes please refer to `OpenProject::Authentication::Scope`.
#
# @param [Symbol] scope The scope for which to update the used warden strategies.
# @param [Hash] opts Options for that scope.
# @option opts [Boolean] :store Indicates whether the user should be stored in the session
# for this scope. Optional. If not given, the current store
# flag for this strategy will remain unchanged what ever it is.
# @option opts [String] :realm The WWW-Authenticate realm used for authentication challenges
# for this scope. The default value ()
#
# @yield [strategies] A block returning the strategies to be used for this scope.
# @yieldparam [Set] strategies The strategies currently used by this scope. May be empty.
# @yieldreturn [Set] The strategies to be used by this scope.
def update_strategies(scope, opts = {}, &)
raise ArgumentError, "invalid scope: #{scope}" unless Scope.values.include? scope
config = Manager.scope_config scope
config.update!(opts, &)
end
##
# Allows to handle an authentication failure with a custom response.
#
# @param [Symbol] scope The scope for which to set the custom failure handler. Optional.
# If omitted the default failure handler is set.
#
# @yield [failure_handler] A block returning a custom failure response.
# @yieldparam [Warden::Proxy] warden Warden instance giving access to the would-be
# result message and headers.
# @yieldparam [Hash] warden_options Warden options including the scope of the failed
# strategy and the attempted request path.
# @yieldreturn [Array] A rack standard response such as `[401, {}, ['unauthenticated']]`.
def handle_failure(scope: nil, &block)
Manager.failure_handlers[scope] = block
end
end
##
# This module is only there to declare all used scopes. Technically a scope can be an
# arbitrary symbol. But we declare them here not to lose sight of them.
#
# Plugins can declare new scopes by declaring new constants in this module.
module Scope
API_V3 = :api_v3
SCIM_V2 = :scim_v2
MCP_SCOPE = :mcp
class << self
def values
constants.map do |name|
const_get name
end
end
end
end
module Stage
class Entry
include OpenProject::StaticRouting::UrlHelpers
attr_reader :identifier
def initialize(identifier, path, run_after_activation, active)
@identifier = identifier
@path = path
@run_after_activation = run_after_activation
@active = active
end
def path
if @path.respond_to?(:call)
instance_exec &@path
else
@path
end
end
def run_after_activation?
@run_after_activation
end
def active?
@active.call
end
end
class << self
include OpenProject::StaticRouting::UrlHelpers
##
# Registers a new authentication stage which will be triggered after the
# user has been authenticated through the core and before they are actually logged in.
#
# With a plugin registering an extra stage the login flow would look as follows:
#
# :|--------------------|>-------------------|>----------------|:
# Password Auth Extra Stage (2FA) Complete Login
#
# { core }{ 2FA plugin }{ core }
#
# Only in the final complete login stage will the user's session be reset and
# the current_user set to the successfully authenticated user. Until then the
# initially authenticated user will be stored in the intermediate session
# as `authenticated_user_id`.
#
# Any stage has to be completed by redirecting back to `Stage.complete_path`.
# If the stage fails it may handle displaying the failure itself. If not it can
# redirect to `Stage.failure_path` to show a generic failure page which will show
# any flash errors.
#
# Example calls:
#
# OpenProject::Authentication::Stage
# .register :security_question, '/users/security_question'
#
# OpenProject::Authentication::Stage
# .register(:security_question) { security_question_path } # using url helper
#
# @param identifier [Symbol] Used to tell the stages apart.
# @param path [String] Path to redirect to for the stage to start.
# @param run_after_activation [Boolean] If true the stage will also be run just after
# a user was registered and activated. This only
# makes sense if the extra stage is possible at
# that point yet.
# @param active [Block] A block returning true (default) if this stage is active.
# @param before [Symbol] Identifier before which to insert this stage. Stage will be
# appended to the end if no such identifier is registered.
# Cannot be used with `after`.
# @param after [Symbol] Identifier after which to insert this stage. The stage will be
# appended to the end if no such identifier is registered.
# Cannot be used with `before`.
#
# @yield [path_provider] A block returning a path to redirect to. Is evaluated in the
# context of a controller giving access to URL helpers.
def register(
identifier,
path = nil,
run_after_activation: false,
active: -> { true },
before: nil,
after: nil,
&block
)
if stages.detect { |s| s.identifier == identifier }
Rails.logger.warn "Trying to register stage (#{identifier}) that exists already."
return
end
stage = Entry.new identifier, path || block, run_after_activation, active
i = stages.index { |s| s.identifier == (before || after) }
if i
stages.insert i + (after ? 1 : 0), stage
else
stages << stage
end
end
def deregister(identifier)
stages.reject! { |s| s.identifier == identifier }
end
##
# Contains 3-tuples of stage identifier, run-after-activation flag and
# the block to be executed to start the stage.
def stages
@stages ||= []
end
def find_all(identifiers)
identifiers
.filter_map { |ident| stages.find { |st| st.identifier == ident } }
end
def complete_path(identifier, session:, back_url: nil)
stage_success_path stage: identifier, secret: Hash(session[:stage_secrets])[identifier]
end
def failure_path(identifier)
stage_failure_path stage: identifier
end
end
end
##
# Options used in the WWW-Authenticate header returned to the user
# in case authentication failed (401).
module WWWAuthenticate
module_function
def default_realm
"OpenProject API"
end
def scope_realm(scope = nil)
Manager.scope_config(scope).realm || default_realm
end
def response_header(scope: nil, error: nil, error_description: nil)
header = %{Bearer realm="#{scope_realm(scope)}", resource_metadata="#{resource_metadata}"}
header << %{, scope="#{escape_string scope}"} if scope
if error
header << %{, error="#{escape_string error}"}
header << %{, error_description="#{escape_string error_description}"} if error_description
end
header
end
def escape_string(string)
string.to_s.dump[1..-2]
end
def resource_metadata
OpenProject::StaticRouting::StaticRouter.new.url_helpers.protected_resource_metadata_url
end
end
# Prepended to the warden basic auth strategy, so when a client already tries to use Basic auth (but fails), they
# will receive a Basic WWW-Authenticate header.
module AuthHeaders
include WWWAuthenticate
# #scope available from Warden::Strategies::BasicAuth
def auth_scheme
"Basic"
end
def realm
scope_realm scope
end
end
end
end
Warden::Strategies::BasicAuth.prepend OpenProject::Authentication::AuthHeaders