mirror of
https://gitlab.com/gitlab-org/gitlab-foss.git
synced 2025-07-25 16:03:48 +00:00
558 lines
20 KiB
Ruby
558 lines
20 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
module Gitlab
|
|
module Auth
|
|
AuthenticationError = Class.new(StandardError)
|
|
MissingTokenError = Class.new(AuthenticationError)
|
|
TokenNotFoundError = Class.new(AuthenticationError)
|
|
ExpiredError = Class.new(AuthenticationError)
|
|
RevokedError = Class.new(AuthenticationError)
|
|
ImpersonationDisabled = Class.new(AuthenticationError)
|
|
UnauthorizedError = Class.new(AuthenticationError)
|
|
|
|
class DpopValidationError < AuthenticationError
|
|
def initialize(msg)
|
|
super("DPoP validation error: #{msg}")
|
|
end
|
|
end
|
|
|
|
class RestrictedLanguageServerClientError < AuthenticationError
|
|
def initialize(msg)
|
|
super("Language server client error: #{msg}")
|
|
end
|
|
end
|
|
|
|
class InsufficientScopeError < AuthenticationError
|
|
attr_reader :scopes
|
|
|
|
def initialize(scopes)
|
|
@scopes = scopes.map { |s| s.try(:name) || s }
|
|
end
|
|
end
|
|
|
|
module AuthFinders
|
|
include Gitlab::Utils::StrongMemoize
|
|
include ActionController::HttpAuthentication::Basic
|
|
include ActionController::HttpAuthentication::Token
|
|
|
|
PRIVATE_TOKEN_HEADER = 'HTTP_PRIVATE_TOKEN'
|
|
PRIVATE_TOKEN_PARAM = :private_token
|
|
JOB_TOKEN_HEADER = 'HTTP_JOB_TOKEN'
|
|
JOB_TOKEN_PARAM = :job_token
|
|
DEPLOY_TOKEN_HEADER = 'HTTP_DEPLOY_TOKEN'
|
|
RUNNER_TOKEN_PARAM = :token
|
|
RUNNER_JOB_TOKEN_PARAM = :token
|
|
PATH_DEPENDENT_FEED_TOKEN_REGEX = /\A#{User::FEED_TOKEN_PREFIX}(\h{64})-(\d+)\z/
|
|
|
|
PARAM_TOKEN_KEYS = [
|
|
PRIVATE_TOKEN_PARAM,
|
|
JOB_TOKEN_PARAM,
|
|
RUNNER_JOB_TOKEN_PARAM
|
|
].map(&:to_s).freeze
|
|
HEADER_TOKEN_KEYS = [
|
|
PRIVATE_TOKEN_HEADER,
|
|
JOB_TOKEN_HEADER,
|
|
DEPLOY_TOKEN_HEADER
|
|
].freeze
|
|
|
|
attr_accessor :current_token
|
|
|
|
# Check the Rails session for valid authentication details
|
|
def find_user_from_warden
|
|
current_request.env['warden']&.authenticate if verified_request?
|
|
end
|
|
|
|
def find_user_from_static_object_token(request_format)
|
|
return unless valid_static_objects_format?(request_format)
|
|
|
|
token = current_request.params[:token].presence || current_request.headers['X-Gitlab-Static-Object-Token'].presence
|
|
return unless token
|
|
|
|
User.find_by_static_object_token(token.to_s) || raise(UnauthorizedError)
|
|
end
|
|
|
|
def find_user_from_feed_token(request_format)
|
|
return unless valid_rss_format?(request_format)
|
|
return if Gitlab::CurrentSettings.disable_feed_token
|
|
|
|
# NOTE: feed_token was renamed from rss_token but both needs to be supported because
|
|
# users might have already added the feed to their RSS reader before the rename
|
|
token = current_request.params[:feed_token].presence || current_request.params[:rss_token].presence
|
|
return unless token
|
|
|
|
find_feed_token_user(token) || raise(UnauthorizedError)
|
|
end
|
|
|
|
def find_user_from_bearer_token
|
|
find_user_from_job_bearer_token || find_user_from_access_token
|
|
end
|
|
|
|
def find_user_from_job_token
|
|
return unless route_authentication_setting[:job_token_allowed]
|
|
|
|
user = find_user_from_job_token_basic_auth if can_authenticate_job_token_basic_auth?
|
|
return user if user
|
|
|
|
find_user_from_job_token_query_params_or_header if can_authenticate_job_token_request?
|
|
end
|
|
|
|
def find_user_from_basic_auth_password
|
|
return unless has_basic_credentials?(current_request)
|
|
|
|
login, password = user_name_and_password(current_request)
|
|
return if ::Gitlab::Auth::CI_JOB_USER == login
|
|
|
|
Gitlab::Auth.find_with_user_password(login.to_s, password.to_s)
|
|
end
|
|
|
|
def find_user_from_lfs_token
|
|
return unless has_basic_credentials?(current_request)
|
|
|
|
login, token = user_name_and_password(current_request)
|
|
user = User.find_by_login(login.to_s)
|
|
|
|
user if user && Gitlab::LfsToken.new(user, nil).token_valid?(token.to_s)
|
|
end
|
|
|
|
def find_user_from_personal_access_token
|
|
return unless access_token
|
|
|
|
validate_and_save_access_token!
|
|
|
|
access_token&.user || raise(UnauthorizedError)
|
|
end
|
|
|
|
# We allow private access tokens with `api` scope to be used by web
|
|
# requests on RSS feeds or ICS files for backwards compatibility.
|
|
# It is also used by GraphQL/API requests.
|
|
# And to allow accessing /archive programatically as it was a big pain point
|
|
# for users https://gitlab.com/gitlab-org/gitlab/-/issues/28978.
|
|
# Used for release downloading as well
|
|
def find_user_from_web_access_token(request_format, scopes: [:api])
|
|
return unless access_token && valid_web_access_format?(request_format)
|
|
|
|
validate_and_save_access_token!(scopes: scopes)
|
|
|
|
::PersonalAccessTokens::LastUsedService.new(access_token).execute
|
|
|
|
access_token.user || raise(UnauthorizedError)
|
|
end
|
|
|
|
def find_user_from_access_token
|
|
return unless access_token
|
|
|
|
validate_and_save_access_token!
|
|
|
|
::PersonalAccessTokens::LastUsedService.new(access_token).execute
|
|
|
|
access_token.user || raise(UnauthorizedError)
|
|
end
|
|
|
|
# This returns a deploy token, not a user since a deploy token does not
|
|
# belong to a user.
|
|
#
|
|
# deploy tokens are accepted with deploy token headers and basic auth headers
|
|
def deploy_token_from_request
|
|
return unless route_authentication_setting[:deploy_token_allowed]
|
|
return unless Gitlab::ExternalAuthorization.allow_deploy_tokens_and_deploy_keys?
|
|
|
|
self.current_token = current_request.env[DEPLOY_TOKEN_HEADER].presence || parsed_oauth_token
|
|
|
|
if has_basic_credentials?(current_request)
|
|
_, self.current_token = user_name_and_password(current_request)
|
|
end
|
|
|
|
deploy_token = DeployToken.active.find_by_token(current_token.to_s)
|
|
@current_authenticated_deploy_token = deploy_token # rubocop:disable Gitlab/ModuleWithInstanceVariables
|
|
|
|
deploy_token
|
|
end
|
|
|
|
def cluster_agent_token_from_authorization_token
|
|
return unless route_authentication_setting[:cluster_agent_token_allowed]
|
|
|
|
# We are migrating from `Gitlab-Agentk-Api-Request` to `Gitlab-Agent-Api-Request` to make it common
|
|
# for all types of agents. Both must be supported until KAS has been updated to use the new header,
|
|
# and then this first lookup can be removed.
|
|
# See https://gitlab.com/gitlab-org/cluster-integration/gitlab-agent/-/issues/711 .
|
|
headers = current_request.headers
|
|
self.current_token = if headers.key?(Gitlab::Kas::INTERNAL_API_AGENTK_REQUEST_HEADER)
|
|
headers[Gitlab::Kas::INTERNAL_API_AGENTK_REQUEST_HEADER]
|
|
else
|
|
headers[Gitlab::Kas::INTERNAL_API_AGENT_REQUEST_HEADER]
|
|
end
|
|
|
|
return unless current_token.present?
|
|
|
|
::Clusters::AgentToken.active.find_by_token(current_token.to_s)
|
|
end
|
|
|
|
def find_runner_from_token
|
|
return unless api_request?
|
|
|
|
token = current_request.params[RUNNER_TOKEN_PARAM].presence
|
|
return unless token
|
|
|
|
::Ci::Runner.find_by_token(token.to_s) || raise(UnauthorizedError)
|
|
end
|
|
|
|
def validate_and_save_access_token!(scopes: [], save_auth_context: true, reset_token: false)
|
|
# return early if we've already authenticated via a job token
|
|
return if @current_authenticated_job.present? # rubocop:disable Gitlab/ModuleWithInstanceVariables
|
|
|
|
# return early if we've already authenticated via a deploy token
|
|
return if @current_authenticated_deploy_token.present? # rubocop:disable Gitlab/ModuleWithInstanceVariables
|
|
|
|
return unless access_token
|
|
|
|
# Originally, we tried to use `reset` here to follow the rubocop rule introduced by
|
|
# gitlab-org/gitlab-foss#60218, but this caused a NoMethodError for OAuth tokens,
|
|
# leading to incident 18980 (see gitlab-com/gl-infra/production#18988).
|
|
# We're using reload instead and disabling the rubocop rule to prevent similar incidents.
|
|
access_token.reload if reset_token # rubocop:disable Cop/ActiveRecordAssociationReload
|
|
|
|
case AccessTokenValidationService.new(access_token, request: request).validate(scopes: scopes)
|
|
when AccessTokenValidationService::INSUFFICIENT_SCOPE
|
|
save_auth_failure_in_application_context(access_token, :insufficient_scope, scopes) if save_auth_context
|
|
raise InsufficientScopeError, scopes
|
|
when AccessTokenValidationService::EXPIRED
|
|
save_auth_failure_in_application_context(access_token, :token_expired, scopes) if save_auth_context
|
|
raise ExpiredError
|
|
when AccessTokenValidationService::REVOKED
|
|
save_auth_failure_in_application_context(access_token, :token_revoked, scopes) if save_auth_context
|
|
revoke_token_family(access_token)
|
|
|
|
raise RevokedError
|
|
when AccessTokenValidationService::IMPERSONATION_DISABLED
|
|
save_auth_failure_in_application_context(access_token, :impersonation_disabled, scopes) if save_auth_context
|
|
raise ImpersonationDisabled
|
|
end
|
|
|
|
save_current_token_in_env
|
|
end
|
|
|
|
def authentication_token_present?
|
|
PARAM_TOKEN_KEYS.intersection(current_request.params.keys).any? ||
|
|
HEADER_TOKEN_KEYS.intersection(current_request.env.keys).any? ||
|
|
parsed_oauth_token.present?
|
|
end
|
|
|
|
private
|
|
|
|
def extract_personal_access_token
|
|
current_request.params[PRIVATE_TOKEN_PARAM].presence ||
|
|
current_request.env[PRIVATE_TOKEN_HEADER].presence ||
|
|
parsed_oauth_token
|
|
end
|
|
|
|
def save_current_token_in_env
|
|
token_info = {
|
|
token_id: access_token.id,
|
|
token_type: access_token.class.to_s,
|
|
token_scopes: access_token.scopes.map(&:to_sym)
|
|
}
|
|
|
|
token_info[:token_application_id] = access_token.application_id if access_token.respond_to?(:application_id)
|
|
|
|
::Current.token_info = token_info
|
|
end
|
|
|
|
def save_auth_failure_in_application_context(access_token, cause, requested_scopes)
|
|
Gitlab::ApplicationContext.push(
|
|
auth_fail_reason: cause.to_s,
|
|
auth_fail_token_id: "#{access_token.class}/#{access_token.id}",
|
|
auth_fail_requested_scopes: requested_scopes.join(' ')
|
|
)
|
|
end
|
|
|
|
def find_user_from_job_bearer_token
|
|
return unless route_authentication_setting[:job_token_allowed]
|
|
|
|
self.current_token = parsed_oauth_token
|
|
return unless current_token
|
|
|
|
job = ::Ci::AuthJobFinder.new(token: current_token).execute
|
|
return unless job
|
|
|
|
@current_authenticated_job = job # rubocop:disable Gitlab/ModuleWithInstanceVariables
|
|
|
|
job.user
|
|
end
|
|
|
|
def route_authentication_setting
|
|
return {} unless respond_to?(:route_setting)
|
|
|
|
route_setting(:authentication) || {}
|
|
end
|
|
|
|
def access_token
|
|
strong_memoize(:access_token) do
|
|
# Kubernetes API OAuth header is not OauthAccessToken or PersonalAccessToken
|
|
# and should be ignored by this method. When the kubernetes API uses a different
|
|
# header, we can remove this guard
|
|
# https://gitlab.com/gitlab-org/gitlab/-/issues/406582
|
|
next if current_request.path.starts_with? "/api/v4/internal/kubernetes/"
|
|
|
|
if try(:namespace_inheritable, :authentication)
|
|
access_token_from_namespace_inheritable
|
|
else
|
|
# The token can be a PAT or an OAuth (doorkeeper) token
|
|
begin
|
|
find_oauth_access_token
|
|
rescue UnauthorizedError
|
|
# It is also possible that a PAT is encapsulated in a `Bearer` OAuth token
|
|
# (e.g. NPM client registry auth). In that case, we rescue UnauthorizedError
|
|
# and try to find a personal access token.
|
|
end || find_personal_access_token
|
|
end
|
|
end
|
|
end
|
|
|
|
def find_personal_access_token
|
|
self.current_token = extract_personal_access_token
|
|
return unless current_token
|
|
|
|
# The runner sends the job token for PUT /api/jobs/:id in the PRIVATE-TOKEN header
|
|
# and the token JSON parameter. Ignore this personal access token so
|
|
# that the job token can be authenticated.
|
|
return if api_request? && current_token.start_with?(::Ci::Build::TOKEN_PREFIX)
|
|
|
|
# Expiration, revocation and scopes are verified in `validate_access_token!`
|
|
PersonalAccessToken.find_by_token(current_token.to_s) || raise(UnauthorizedError)
|
|
end
|
|
|
|
def find_oauth_access_token
|
|
self.current_token = parsed_oauth_token
|
|
return unless current_token
|
|
|
|
# Expiration, revocation and scopes are verified in `validate_access_token!`
|
|
oauth_token = OauthAccessToken.by_token(current_token)
|
|
raise UnauthorizedError unless oauth_token
|
|
|
|
oauth_token.revoke_previous_refresh_token!
|
|
|
|
::Gitlab::Auth::Identity.link_from_oauth_token(oauth_token).tap do |identity|
|
|
raise UnauthorizedError if identity && !identity.valid?
|
|
end
|
|
|
|
oauth_token
|
|
end
|
|
|
|
def find_personal_access_token_from_http_basic_auth
|
|
return unless route_authentication_setting[:basic_auth_personal_access_token]
|
|
return unless has_basic_credentials?(current_request)
|
|
|
|
_username, self.current_token = user_name_and_password(current_request)
|
|
PersonalAccessToken.find_by_token(current_token.to_s)
|
|
end
|
|
|
|
def find_feed_token_user(token)
|
|
token = token.to_s
|
|
find_user_from_path_feed_token(token) || User.find_by_feed_token(token)
|
|
end
|
|
|
|
def find_user_from_path_feed_token(token)
|
|
glft = token.match(PATH_DEPENDENT_FEED_TOKEN_REGEX)
|
|
|
|
return unless glft
|
|
|
|
# make sure that user id uses decimal notation
|
|
user_id = glft[2].to_i(10)
|
|
digest = glft[1]
|
|
|
|
user = User.find_by_id(user_id)
|
|
return unless user
|
|
|
|
feed_token = user.feed_token
|
|
our_digest = OpenSSL::HMAC.hexdigest("SHA256", feed_token, current_request.path)
|
|
|
|
return unless ActiveSupport::SecurityUtils.secure_compare(digest, our_digest)
|
|
|
|
user
|
|
end
|
|
|
|
def can_authenticate_job_token_basic_auth?
|
|
setting = route_authentication_setting[:job_token_allowed]
|
|
Array.wrap(setting).include?(:basic_auth)
|
|
end
|
|
|
|
def can_authenticate_job_token_request?
|
|
setting = route_authentication_setting[:job_token_allowed]
|
|
setting == true || Array.wrap(setting).include?(:request)
|
|
end
|
|
|
|
def find_user_from_job_token_query_params_or_header
|
|
self.current_token = current_request.params[JOB_TOKEN_PARAM].presence ||
|
|
current_request.params[RUNNER_JOB_TOKEN_PARAM].presence ||
|
|
current_request.env[JOB_TOKEN_HEADER].presence
|
|
return unless current_token
|
|
|
|
job = find_valid_running_job_by_token!(current_token.to_s)
|
|
@current_authenticated_job = job # rubocop:disable Gitlab/ModuleWithInstanceVariables
|
|
|
|
job.user
|
|
end
|
|
|
|
def find_user_from_job_token_basic_auth
|
|
return unless has_basic_credentials?(current_request)
|
|
|
|
login, self.current_token = user_name_and_password(current_request)
|
|
return unless login.present? && current_token.present?
|
|
return unless ::Gitlab::Auth::CI_JOB_USER == login
|
|
|
|
job = find_valid_running_job_by_token!(current_token.to_s)
|
|
@current_authenticated_job = job # rubocop:disable Gitlab/ModuleWithInstanceVariables
|
|
|
|
job.user
|
|
end
|
|
|
|
def parsed_oauth_token
|
|
Doorkeeper::OAuth::Token.from_request(current_request, *Doorkeeper.configuration.access_token_methods)
|
|
end
|
|
|
|
# Check if the request is GET/HEAD, or if CSRF token is valid.
|
|
def verified_request?
|
|
Gitlab::RequestForgeryProtection.verified?(current_request.env)
|
|
end
|
|
|
|
def ensure_action_dispatch_request(request)
|
|
ActionDispatch::Request.new(request.env.dup)
|
|
end
|
|
|
|
def current_request
|
|
@current_request ||= ensure_action_dispatch_request(request)
|
|
end
|
|
|
|
def valid_web_access_format?(request_format)
|
|
case request_format
|
|
when :rss
|
|
rss_request?
|
|
when :ics
|
|
ics_request?
|
|
when :api
|
|
api_request?
|
|
when :archive
|
|
archive_request?
|
|
when :download
|
|
download_request?
|
|
end
|
|
end
|
|
|
|
def valid_rss_format?(request_format)
|
|
case request_format
|
|
when :rss
|
|
rss_request?
|
|
when :ics
|
|
ics_request?
|
|
end
|
|
end
|
|
|
|
def valid_static_objects_format?(request_format)
|
|
case request_format
|
|
when :archive
|
|
archive_request?
|
|
when :blob
|
|
blob_request?
|
|
else
|
|
false
|
|
end
|
|
end
|
|
|
|
def rss_request?
|
|
current_request.path.ends_with?('.atom') || current_request.format.atom?
|
|
end
|
|
|
|
def ics_request?
|
|
current_request.path.ends_with?('.ics') || current_request.format.ics?
|
|
end
|
|
|
|
def api_request?
|
|
current_request.path.starts_with?(Gitlab::Utils.append_path(Gitlab.config.gitlab.relative_url_root, '/api/'))
|
|
end
|
|
|
|
def git_request?
|
|
Gitlab::PathRegex.repository_git_route_regex.match?(current_request.path)
|
|
end
|
|
|
|
def git_lfs_request?
|
|
Gitlab::PathRegex.repository_git_lfs_route_regex.match?(current_request.path)
|
|
end
|
|
|
|
def git_or_lfs_request?
|
|
git_request? || git_lfs_request?
|
|
end
|
|
|
|
def archive_request?
|
|
current_request.path.include?('/-/archive/')
|
|
end
|
|
|
|
def download_request?
|
|
current_request.path.include?('/downloads/')
|
|
end
|
|
|
|
def blob_request?
|
|
current_request.path.include?('/raw/')
|
|
end
|
|
|
|
def find_valid_running_job_by_token!(token)
|
|
::Ci::AuthJobFinder.new(token: token).execute.tap do |job|
|
|
raise UnauthorizedError unless job
|
|
end
|
|
end
|
|
|
|
def revoke_token_family(token)
|
|
return unless access_token_rotation_request?
|
|
|
|
PersonalAccessTokens::RevokeTokenFamilyService.new(token).execute
|
|
end
|
|
|
|
def access_token_rotation_request?
|
|
current_request.path.match(%r{access_tokens/(\d+|self)/rotate$})
|
|
end
|
|
|
|
# To prevent Rack Attack from incorrectly rate limiting
|
|
# authenticated Git activity, we need to authenticate the user
|
|
# from other means (e.g. HTTP Basic Authentication) only if the
|
|
# request originated from a Git or Git LFS
|
|
# request. Repositories::GitHttpClientController or
|
|
# Repositories::LfsApiController normally does the authentication,
|
|
# but Rack Attack runs before those controllers.
|
|
def find_user_for_git_or_lfs_request
|
|
return unless git_or_lfs_request?
|
|
|
|
find_user_from_lfs_token || find_user_from_basic_auth_password
|
|
end
|
|
|
|
def find_user_from_personal_access_token_for_api_or_git
|
|
return unless api_request? || git_or_lfs_request?
|
|
|
|
find_user_from_personal_access_token
|
|
end
|
|
|
|
def find_user_from_dependency_proxy_token
|
|
return unless dependency_proxy_request?
|
|
|
|
token, _ = ActionController::HttpAuthentication::Token.token_and_options(current_request)
|
|
|
|
return unless token
|
|
|
|
user_or_deploy_token = ::DependencyProxy::AuthTokenService.user_or_deploy_token_from_jwt(token)
|
|
|
|
# Do not return deploy tokens
|
|
# See https://gitlab.com/gitlab-org/gitlab/-/issues/342481
|
|
return unless user_or_deploy_token.is_a?(::User)
|
|
|
|
user_or_deploy_token
|
|
rescue ActiveRecord::RecordNotFound
|
|
nil # invalid id used return no user
|
|
end
|
|
|
|
def dependency_proxy_request?
|
|
Gitlab::PathRegex.dependency_proxy_route_regex.match?(current_request.path)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
Gitlab::Auth::AuthFinders.prepend_mod_with('Gitlab::Auth::AuthFinders')
|