diff --git a/app/controllers/concerns/dependency_proxy/auth.rb b/app/controllers/concerns/dependency_proxy/auth.rb new file mode 100644 index 0000000000000000000000000000000000000000..22618ca636616bbb029f7066e5508b8b79cb54a1 --- /dev/null +++ b/app/controllers/concerns/dependency_proxy/auth.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module DependencyProxy + module Auth + extend ActiveSupport::Concern + + included do + # We disable `authenticate_user!` since the `DependencyProxy::Auth` performs auth using JWT token + skip_before_action :authenticate_user!, raise: false + prepend_before_action :authenticate_user_from_jwt_token! + end + + def authenticate_user_from_jwt_token! + return unless dependency_proxy_for_private_groups? + + authenticate_with_http_token do |token, _| + user = user_from_token(token) + sign_in(user) if user + end + + request_bearer_token! unless current_user + end + + private + + def dependency_proxy_for_private_groups? + Feature.enabled?(:dependency_proxy_for_private_groups, default_enabled: false) + end + + def request_bearer_token! + # unfortunately, we cannot use https://api.rubyonrails.org/classes/ActionController/HttpAuthentication/Token.html#method-i-authentication_request + response.headers['WWW-Authenticate'] = ::DependencyProxy::Registry.authenticate_header + render plain: '', status: :unauthorized + end + + def user_from_token(token) + token_payload = DependencyProxy::AuthTokenService.decoded_token_payload(token) + User.find(token_payload['user_id']) + rescue JWT::DecodeError, JWT::ExpiredSignature, JWT::ImmatureSignature + nil + end + end +end diff --git a/app/controllers/concerns/dependency_proxy/group_access.rb b/app/controllers/concerns/dependency_proxy/group_access.rb new file mode 100644 index 0000000000000000000000000000000000000000..2a923d02752331cccc3e6a93621f6c7e898d3a06 --- /dev/null +++ b/app/controllers/concerns/dependency_proxy/group_access.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module DependencyProxy + module GroupAccess + extend ActiveSupport::Concern + + included do + before_action :verify_dependency_proxy_enabled! + before_action :authorize_read_dependency_proxy! + end + + private + + def verify_dependency_proxy_enabled! + render_404 unless group.dependency_proxy_feature_available? + end + + def authorize_read_dependency_proxy! + access_denied! unless can?(current_user, :read_dependency_proxy, group) + end + + def authorize_admin_dependency_proxy! + access_denied! unless can?(current_user, :admin_dependency_proxy, group) + end + end +end diff --git a/app/controllers/concerns/dependency_proxy_access.rb b/app/controllers/concerns/dependency_proxy_access.rb deleted file mode 100644 index 5036d0cfce47d95522fe1c0ec96b8bd57394769f..0000000000000000000000000000000000000000 --- a/app/controllers/concerns/dependency_proxy_access.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -module DependencyProxyAccess - extend ActiveSupport::Concern - - included do - before_action :verify_dependency_proxy_enabled! - before_action :authorize_read_dependency_proxy! - end - - private - - def verify_dependency_proxy_enabled! - render_404 unless group.dependency_proxy_feature_available? - end - - def authorize_read_dependency_proxy! - access_denied! unless can?(current_user, :read_dependency_proxy, group) - end - - def authorize_admin_dependency_proxy! - access_denied! unless can?(current_user, :admin_dependency_proxy, group) - end -end diff --git a/app/controllers/groups/dependency_proxies_controller.rb b/app/controllers/groups/dependency_proxies_controller.rb index 367dbafdd5992ea3e1a35ad3643da9a6f08a68b6..b896b240daf1372b3f45cb7e2ab3b6861f66dff2 100644 --- a/app/controllers/groups/dependency_proxies_controller.rb +++ b/app/controllers/groups/dependency_proxies_controller.rb @@ -2,7 +2,7 @@ module Groups class DependencyProxiesController < Groups::ApplicationController - include DependencyProxyAccess + include DependencyProxy::GroupAccess before_action :authorize_admin_dependency_proxy!, only: :update before_action :dependency_proxy diff --git a/app/controllers/groups/dependency_proxy_auth_controller.rb b/app/controllers/groups/dependency_proxy_auth_controller.rb new file mode 100644 index 0000000000000000000000000000000000000000..e3e9bd88e24fd7d943665146f818048c0c33b148 --- /dev/null +++ b/app/controllers/groups/dependency_proxy_auth_controller.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class Groups::DependencyProxyAuthController < ApplicationController + include DependencyProxy::Auth + + feature_category :dependency_proxy + + def authenticate + render plain: '', status: :ok + end +end diff --git a/app/controllers/groups/dependency_proxy_for_containers_controller.rb b/app/controllers/groups/dependency_proxy_for_containers_controller.rb index f46902ef90f97b49de2a06bd5c0713e49806bc5e..22aea424998d08f1fc263f3b03ff802bd0de4fa9 100644 --- a/app/controllers/groups/dependency_proxy_for_containers_controller.rb +++ b/app/controllers/groups/dependency_proxy_for_containers_controller.rb @@ -1,7 +1,8 @@ # frozen_string_literal: true class Groups::DependencyProxyForContainersController < Groups::ApplicationController - include DependencyProxyAccess + include DependencyProxy::Auth + include DependencyProxy::GroupAccess include SendFileUpload before_action :ensure_token_granted! @@ -9,7 +10,7 @@ class Groups::DependencyProxyForContainersController < Groups::ApplicationContro attr_reader :token - feature_category :package_registry + feature_category :dependency_proxy def manifest result = DependencyProxy::PullManifestService.new(image, tag, token).execute diff --git a/app/controllers/jwt_controller.rb b/app/controllers/jwt_controller.rb index 5199bb25c8cc252f3e4796606be9295af231c1ad..85ee220432461e42c55bb46f0fd64ef4d0c14a29 100644 --- a/app/controllers/jwt_controller.rb +++ b/app/controllers/jwt_controller.rb @@ -11,7 +11,8 @@ class JwtController < ApplicationController feature_category :authentication_and_authorization SERVICES = { - Auth::ContainerRegistryAuthenticationService::AUDIENCE => Auth::ContainerRegistryAuthenticationService + ::Auth::ContainerRegistryAuthenticationService::AUDIENCE => ::Auth::ContainerRegistryAuthenticationService, + ::Auth::DependencyProxyAuthenticationService::AUDIENCE => ::Auth::DependencyProxyAuthenticationService }.freeze def auth diff --git a/app/models/dependency_proxy/registry.rb b/app/models/dependency_proxy/registry.rb index 471d5be260000e43782f47d2faec6f2a6aef7694..6492acf325a6475a50e17a9760d0d5e5d761e594 100644 --- a/app/models/dependency_proxy/registry.rb +++ b/app/models/dependency_proxy/registry.rb @@ -1,8 +1,9 @@ # frozen_string_literal: true class DependencyProxy::Registry - AUTH_URL = 'https://auth.docker.io'.freeze - LIBRARY_URL = 'https://registry-1.docker.io/v2'.freeze + AUTH_URL = 'https://auth.docker.io' + LIBRARY_URL = 'https://registry-1.docker.io/v2' + PROXY_AUTH_URL = Gitlab::Utils.append_path(Gitlab.config.gitlab.url, "jwt/auth") class << self def auth_url(image) @@ -17,6 +18,10 @@ def blob_url(image, blob_sha) "#{LIBRARY_URL}/#{image_path(image)}/blobs/#{blob_sha}" end + def authenticate_header + "Bearer realm=\"#{PROXY_AUTH_URL}\",service=\"#{::Auth::DependencyProxyAuthenticationService::AUDIENCE}\"" + end + private def image_path(image) diff --git a/app/services/auth/dependency_proxy_authentication_service.rb b/app/services/auth/dependency_proxy_authentication_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..1b8c16b7c792bb5e2d9318ba779d3b3de47f78ae --- /dev/null +++ b/app/services/auth/dependency_proxy_authentication_service.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Auth + class DependencyProxyAuthenticationService < BaseService + AUDIENCE = 'dependency_proxy' + HMAC_KEY = 'gitlab-dependency-proxy' + DEFAULT_EXPIRE_TIME = 1.minute + + def execute(authentication_abilities:) + return error('dependency proxy not enabled', 404) unless ::Gitlab.config.dependency_proxy.enabled + return error('access forbidden', 403) unless current_user + + { token: authorized_token.encoded } + end + + class << self + include ::Gitlab::Utils::StrongMemoize + + def secret + strong_memoize(:secret) do + OpenSSL::HMAC.hexdigest( + 'sha256', + ::Settings.attr_encrypted_db_key_base, + HMAC_KEY + ) + end + end + + def token_expire_at + Time.current + Gitlab::CurrentSettings.container_registry_token_expire_delay.minutes + end + end + + private + + def authorized_token + JSONWebToken::HMACToken.new(self.class.secret).tap do |token| + token['user_id'] = current_user.id + token.expire_time = self.class.token_expire_at + end + end + end +end diff --git a/app/services/dependency_proxy/auth_token_service.rb b/app/services/dependency_proxy/auth_token_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..16279ed12b018abf07a6dd631759601b7539241e --- /dev/null +++ b/app/services/dependency_proxy/auth_token_service.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module DependencyProxy + class AuthTokenService < DependencyProxy::BaseService + attr_reader :token + + def initialize(token) + @token = token + end + + def execute + JSONWebToken::HMACToken.decode(token, ::Auth::DependencyProxyAuthenticationService.secret).first + end + + class << self + def decoded_token_payload(token) + self.new(token).execute + end + end + end +end diff --git a/app/views/groups/dependency_proxies/show.html.haml b/app/views/groups/dependency_proxies/show.html.haml index ff1312eb7631ab19570788186d72a63ec1d53715..abbfb2d3b914d23e18b92d8e4920b9cfb1ff9ecd 100644 --- a/app/views/groups/dependency_proxies/show.html.haml +++ b/app/views/groups/dependency_proxies/show.html.haml @@ -7,7 +7,7 @@ - link_start = ''.html_safe % { url: help_page_path('user/packages/dependency_proxy/index') } = _('Create a local proxy for storing frequently used upstream images. %{link_start}Learn more%{link_end} about dependency proxies.').html_safe % { link_start: link_start, link_end: ''.html_safe } -- if @group.public? +- if Feature.enabled?(:dependency_proxy_for_private_groups, default_enabled: false) || @group.public? - if can?(current_user, :admin_dependency_proxy, @group) = form_for(@dependency_proxy, method: :put, url: group_dependency_proxy_path(@group)) do |f| .form-group diff --git a/config/feature_flags/development/dependency_proxy_for_private_groups.yml b/config/feature_flags/development/dependency_proxy_for_private_groups.yml new file mode 100644 index 0000000000000000000000000000000000000000..60dc1b6f928b574151b0259767b00fb626acf274 --- /dev/null +++ b/config/feature_flags/development/dependency_proxy_for_private_groups.yml @@ -0,0 +1,8 @@ +--- +name: dependency_proxy_for_private_groups +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/46042 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/276777 +milestone: '13.7' +type: development +group: group::package +default_enabled: false diff --git a/config/routes/group.rb b/config/routes/group.rb index 3b52aae52e277eea02330ddd76fab1c0db24ebe3..38c04369d2f3fa6013a9e8b94104304b63a97746 100644 --- a/config/routes/group.rb +++ b/config/routes/group.rb @@ -125,7 +125,7 @@ # Dependency proxy for containers # Because docker adds v2 prefix to URI this need to be outside of usual group routes scope format: false do - get 'v2', to: proc { [200, {}, ['']] } # rubocop:disable Cop/PutGroupRoutesUnderScope + get 'v2' => 'groups/dependency_proxy_auth#authenticate' # rubocop:disable Cop/PutGroupRoutesUnderScope constraints image: Gitlab::PathRegex.container_image_regex, sha: Gitlab::PathRegex.container_image_blob_sha_regex do get 'v2/*group_id/dependency_proxy/containers/*image/manifests/*tag' => 'groups/dependency_proxy_for_containers#manifest' # rubocop:todo Cop/PutGroupRoutesUnderScope diff --git a/doc/user/packages/dependency_proxy/index.md b/doc/user/packages/dependency_proxy/index.md index 8205820b0e34f8ac2bf4861a6ef8a370a0ab9e98..46da5f646580254242a2169c17402ee3a88b8a19 100644 --- a/doc/user/packages/dependency_proxy/index.md +++ b/doc/user/packages/dependency_proxy/index.md @@ -8,6 +8,8 @@ info: To determine the technical writer assigned to the Stage/Group associated w > - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/7934) in [GitLab Premium](https://about.gitlab.com/pricing/) 11.11. > - [Moved](https://gitlab.com/gitlab-org/gitlab/-/issues/273655) to [GitLab Core](https://about.gitlab.com/pricing/) in GitLab 13.6. +> - [Support for private groups](https://gitlab.com/gitlab-org/gitlab/-/issues/11582) in [GitLab Premium](https://about.gitlab.com/pricing/) 13.7. +> - Anonymous access to images in public groups is no longer available starting in [GitLab Premium](https://about.gitlab.com/pricing/) 13.7. The GitLab Dependency Proxy is a local proxy you can use for your frequently-accessed upstream images. @@ -17,9 +19,7 @@ upstream image from a registry, acting as a pull-through cache. ## Prerequisites -To use the Dependency Proxy: - -- Your group must be public. Authentication for private groups is [not supported yet](https://gitlab.com/gitlab-org/gitlab/-/issues/11582). +The Dependency Proxy must be [enabled by an administrator](../../../administration/packages/dependency_proxy.md). ### Supported images and packages @@ -58,6 +58,56 @@ Prerequisites: - Docker Hub must be available. Follow [this issue](https://gitlab.com/gitlab-org/gitlab/-/issues/241639) for progress on accessing images when Docker Hub is down. +### Authenticate with the Dependency Proxy + +Because the Dependency Proxy is storing Docker images in a space associated with your group, +you must authenticate against the Dependency Proxy. + +Follow the [instructions for using images from a private registry](../../../ci/docker/using_docker_images.md#define-an-image-from-a-private-container-registry), +but instead of using `registry.example.com:5000`, use your GitLab domain with no port `gitlab.example.com`. + +For example, to manually log in: + +```shell +docker login gitlab.example.com --username my_username --password my_password +``` + +You can authenticate using: + +- Your GitLab username and password. +- A [personal access token](../../../user/profile/personal_access_tokens.md) with the scope set to `read_registry` and `write_registry`. + +#### Authenticate within CI/CD + +To work with the Dependency Proxy in [GitLab CI/CD](../../../ci/README.md), you can use +`CI_REGISTRY_USER` and `CI_REGISTRY_PASSWORD`. + +```shell +docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" gitlab.example.com +``` + +You can use other [predefined variables](../../../ci/variables/predefined_variables.md) +to further generalize your CI script. For example: + +```yaml +# .gitlab-ci.yml + +dependency-proxy-pull-master: + # Official docker image. + image: docker:latest + stage: build + services: + - docker:dind + before_script: + - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_SERVER_HOST":"$CI_SERVER_PORT" + script: + - docker pull "$CI_SERVER_HOST":"$CI_SERVER_PORT"/groupname/dependency_proxy/containers/alpine:latest +``` + +You can also use [custom environment variables](../../../ci/variables/README.md#custom-environment-variables) to store and access your personal access token or other valid credentials. + +### Store a Docker image in Dependency Proxy cache + To store a Docker image in Dependency Proxy storage: 1. Go to your group's **Packages & Registries > Dependency Proxy**. diff --git a/spec/controllers/groups/dependency_proxy_auth_controller_spec.rb b/spec/controllers/groups/dependency_proxy_auth_controller_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..857e0570621bca965e537e059b734c9c45d86ff9 --- /dev/null +++ b/spec/controllers/groups/dependency_proxy_auth_controller_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Groups::DependencyProxyAuthController do + include DependencyProxyHelpers + + describe 'GET #authenticate' do + subject { get :authenticate } + + context 'feature flag disabled' do + before do + stub_feature_flags(dependency_proxy_for_private_groups: false) + end + + it 'returns successfully', :aggregate_failures do + subject + + expect(response).to have_gitlab_http_status(:success) + end + end + + context 'without JWT' do + it 'returns unauthorized with oauth realm', :aggregate_failures do + subject + + expect(response).to have_gitlab_http_status(:unauthorized) + expect(response.headers['WWW-Authenticate']).to eq DependencyProxy::Registry.authenticate_header + end + end + + context 'with valid JWT' do + let_it_be(:user) { create(:user) } + let(:jwt) { build_jwt(user) } + let(:token_header) { "Bearer #{jwt.encoded}" } + + before do + request.headers['HTTP_AUTHORIZATION'] = token_header + end + + it { is_expected.to have_gitlab_http_status(:success) } + end + + context 'with invalid JWT' do + context 'bad user' do + let(:jwt) { build_jwt(double('bad_user', id: 999)) } + let(:token_header) { "Bearer #{jwt.encoded}" } + + before do + request.headers['HTTP_AUTHORIZATION'] = token_header + end + + it { is_expected.to have_gitlab_http_status(:not_found) } + end + + context 'token with no user id' do + let(:token_header) { "Bearer #{build_jwt.encoded}" } + + before do + request.headers['HTTP_AUTHORIZATION'] = token_header + end + + it { is_expected.to have_gitlab_http_status(:not_found) } + end + + context 'expired token' do + let_it_be(:user) { create(:user) } + let(:jwt) { build_jwt(user, expire_time: Time.zone.now - 1.hour) } + let(:token_header) { "Bearer #{jwt.encoded}" } + + before do + request.headers['HTTP_AUTHORIZATION'] = token_header + end + + it { is_expected.to have_gitlab_http_status(:unauthorized) } + end + end + end +end diff --git a/spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb b/spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb index 615b56ff22f71cd78f3cf71e8939146558792266..87956cc7287c70846465235c3bb64abcb37db77e 100644 --- a/spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb +++ b/spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb @@ -3,8 +3,77 @@ require 'spec_helper' RSpec.describe Groups::DependencyProxyForContainersController do + include HttpBasicAuthHelpers + include DependencyProxyHelpers + + let_it_be(:user) { create(:user) } let(:group) { create(:group) } let(:token_response) { { status: :success, token: 'abcd1234' } } + let(:jwt) { build_jwt(user) } + let(:token_header) { "Bearer #{jwt.encoded}" } + + shared_examples 'without a token' do + before do + request.headers['HTTP_AUTHORIZATION'] = nil + end + + context 'feature flag disabled' do + before do + stub_feature_flags(dependency_proxy_for_private_groups: false) + end + + it { is_expected.to have_gitlab_http_status(:ok) } + end + + it { is_expected.to have_gitlab_http_status(:unauthorized) } + end + + shared_examples 'feature flag disabled with private group' do + before do + stub_feature_flags(dependency_proxy_for_private_groups: false) + end + + it 'redirects', :aggregate_failures do + group.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE) + + subject + + expect(response).to have_gitlab_http_status(:redirect) + expect(response.location).to end_with(new_user_session_path) + end + end + + shared_examples 'without permission' do + context 'with invalid user' do + before do + user = double('bad_user', id: 999) + token_header = "Bearer #{build_jwt(user).encoded}" + request.headers['HTTP_AUTHORIZATION'] = token_header + end + + it { is_expected.to have_gitlab_http_status(:not_found) } + end + + context 'with valid user that does not have access' do + let(:group) { create(:group, :private) } + + before do + user = double('bad_user', id: 999) + token_header = "Bearer #{build_jwt(user).encoded}" + request.headers['HTTP_AUTHORIZATION'] = token_header + end + + it { is_expected.to have_gitlab_http_status(:not_found) } + end + + context 'when user is not found' do + before do + allow(User).to receive(:find).and_return(nil) + end + + it { is_expected.to have_gitlab_http_status(:unauthorized) } + end + end shared_examples 'not found when disabled' do context 'feature disabled' do @@ -27,6 +96,8 @@ allow_next_instance_of(DependencyProxy::RequestTokenService) do |instance| allow(instance).to receive(:execute).and_return(token_response) end + + request.headers['HTTP_AUTHORIZATION'] = token_header end describe 'GET #manifest' do @@ -46,6 +117,10 @@ enable_dependency_proxy end + it_behaves_like 'without a token' + it_behaves_like 'without permission' + it_behaves_like 'feature flag disabled with private group' + context 'remote token request fails' do let(:token_response) do { @@ -113,6 +188,10 @@ def get_manifest enable_dependency_proxy end + it_behaves_like 'without a token' + it_behaves_like 'without permission' + it_behaves_like 'feature flag disabled with private group' + context 'remote blob request fails' do let(:blob_response) do { diff --git a/spec/features/groups/dependency_proxy_spec.rb b/spec/features/groups/dependency_proxy_spec.rb index 9bbfdc488fb7ec0dde99e3830fc5bb5cb96cd210..51371ddc532678129015dab20029f38509f26610 100644 --- a/spec/features/groups/dependency_proxy_spec.rb +++ b/spec/features/groups/dependency_proxy_spec.rb @@ -79,13 +79,19 @@ sign_in(developer) end - context 'group is private' do - let(:group) { create(:group, :private) } + context 'feature flag is disabled' do + before do + stub_feature_flags(dependency_proxy_for_private_groups: false) + end - it 'informs user that feature is only available for public groups' do - visit path + context 'group is private' do + let(:group) { create(:group, :private) } - expect(page).to have_content('Dependency proxy feature is limited to public groups for now.') + it 'informs user that feature is only available for public groups' do + visit path + + expect(page).to have_content('Dependency proxy feature is limited to public groups for now.') + end end end diff --git a/spec/models/dependency_proxy/registry_spec.rb b/spec/models/dependency_proxy/registry_spec.rb index 5bfa75a2eed73a16746cf05d35fc06f6d2d15d77..a888ee2b7f7b26798212b44f68a4851443c415ae 100644 --- a/spec/models/dependency_proxy/registry_spec.rb +++ b/spec/models/dependency_proxy/registry_spec.rb @@ -54,4 +54,11 @@ end end end + + describe '#authenticate_header' do + it 'returns the OAuth realm and service header' do + expect(described_class.authenticate_header) + .to eq("Bearer realm=\"#{Gitlab.config.gitlab.url}/jwt/auth\",service=\"dependency_proxy\"") + end + end end diff --git a/spec/requests/jwt_controller_spec.rb b/spec/requests/jwt_controller_spec.rb index fe6c0f0a55669b677378f8f7210256dc201e620f..e154e691d5fd7a2ffca9aef49bd36709b0d01d27 100644 --- a/spec/requests/jwt_controller_spec.rb +++ b/spec/requests/jwt_controller_spec.rb @@ -5,13 +5,13 @@ RSpec.describe JwtController do include_context 'parsed logs' - let(:service) { double(execute: {}) } - let(:service_class) { double(new: service) } - let(:service_name) { 'test' } + let(:service) { double(execute: {} ) } + let(:service_class) { Auth::ContainerRegistryAuthenticationService } + let(:service_name) { 'container_registry' } let(:parameters) { { service: service_name } } before do - stub_const('JwtController::SERVICES', service_name => service_class) + allow(service_class).to receive(:new).and_return(service) end shared_examples 'user logging' do @@ -22,194 +22,266 @@ end end - context 'existing service' do - subject! { get '/jwt/auth', params: parameters } + context 'authenticating against container registry' do + context 'existing service' do + subject! { get '/jwt/auth', params: parameters } - it { expect(response).to have_gitlab_http_status(:ok) } + it { expect(response).to have_gitlab_http_status(:ok) } - context 'returning custom http code' do - let(:service) { double(execute: { http_status: 505 }) } + context 'returning custom http code' do + let(:service) { double(execute: { http_status: 505 }) } - it { expect(response).to have_gitlab_http_status(:http_version_not_supported) } + it { expect(response).to have_gitlab_http_status(:http_version_not_supported) } + end end - end - context 'when using authenticated request' do - shared_examples 'rejecting a blocked user' do - context 'with blocked user' do - let(:user) { create(:user, :blocked) } + context 'when using authenticated request' do + shared_examples 'rejecting a blocked user' do + context 'with blocked user' do + let(:user) { create(:user, :blocked) } - it 'rejects the request as unauthorized' do - expect(response).to have_gitlab_http_status(:unauthorized) - expect(response.body).to include('HTTP Basic: Access denied') + it 'rejects the request as unauthorized' do + expect(response).to have_gitlab_http_status(:unauthorized) + expect(response.body).to include('HTTP Basic: Access denied') + end end end - end - context 'using CI token' do - let(:user) { create(:user) } - let(:build) { create(:ci_build, :running, user: user) } - let(:project) { build.project } - let(:headers) { { authorization: credentials('gitlab-ci-token', build.token) } } + context 'using CI token' do + let(:user) { create(:user) } + let(:build) { create(:ci_build, :running, user: user) } + let(:project) { build.project } + let(:headers) { { authorization: credentials('gitlab-ci-token', build.token) } } - context 'project with enabled CI' do - subject! { get '/jwt/auth', params: parameters, headers: headers } - - it { expect(service_class).to have_received(:new).with(project, user, ActionController::Parameters.new(parameters).permit!) } + context 'project with enabled CI' do + subject! { get '/jwt/auth', params: parameters, headers: headers } - it_behaves_like 'user logging' - end + it { expect(service_class).to have_received(:new).with(project, user, ActionController::Parameters.new(parameters).permit!) } - context 'project with disabled CI' do - before do - project.project_feature.update_attribute(:builds_access_level, ProjectFeature::DISABLED) + it_behaves_like 'user logging' end - subject! { get '/jwt/auth', params: parameters, headers: headers } + context 'project with disabled CI' do + before do + project.project_feature.update_attribute(:builds_access_level, ProjectFeature::DISABLED) + end - it { expect(response).to have_gitlab_http_status(:unauthorized) } - end + subject! { get '/jwt/auth', params: parameters, headers: headers } - context 'using deploy tokens' do - let(:deploy_token) { create(:deploy_token, read_registry: true, projects: [project]) } - let(:headers) { { authorization: credentials(deploy_token.username, deploy_token.token) } } + it { expect(response).to have_gitlab_http_status(:unauthorized) } + end - subject! { get '/jwt/auth', params: parameters, headers: headers } + context 'using deploy tokens' do + let(:deploy_token) { create(:deploy_token, read_registry: true, projects: [project]) } + let(:headers) { { authorization: credentials(deploy_token.username, deploy_token.token) } } - it 'authenticates correctly' do - expect(response).to have_gitlab_http_status(:ok) - expect(service_class).to have_received(:new).with(nil, deploy_token, ActionController::Parameters.new(parameters).permit!) - end + subject! { get '/jwt/auth', params: parameters, headers: headers } - it 'does not log a user' do - expect(log_data.keys).not_to include(%w(username user_id)) + it 'authenticates correctly' do + expect(response).to have_gitlab_http_status(:ok) + expect(service_class).to have_received(:new).with(nil, deploy_token, ActionController::Parameters.new(parameters).permit!) + end + + it 'does not log a user' do + expect(log_data.keys).not_to include(%w(username user_id)) + end end - end - context 'using personal access tokens' do - let(:pat) { create(:personal_access_token, user: user, scopes: ['read_registry']) } - let(:headers) { { authorization: credentials('personal_access_token', pat.token) } } + context 'using personal access tokens' do + let(:pat) { create(:personal_access_token, user: user, scopes: ['read_registry']) } + let(:headers) { { authorization: credentials('personal_access_token', pat.token) } } - before do - stub_container_registry_config(enabled: true) + before do + stub_container_registry_config(enabled: true) + end + + subject! { get '/jwt/auth', params: parameters, headers: headers } + + it 'authenticates correctly' do + expect(response).to have_gitlab_http_status(:ok) + expect(service_class).to have_received(:new).with(nil, user, ActionController::Parameters.new(parameters).permit!) + end + + it_behaves_like 'rejecting a blocked user' + it_behaves_like 'user logging' end + end + + context 'using User login' do + let(:user) { create(:user) } + let(:headers) { { authorization: credentials(user.username, user.password) } } subject! { get '/jwt/auth', params: parameters, headers: headers } - it 'authenticates correctly' do - expect(response).to have_gitlab_http_status(:ok) - expect(service_class).to have_received(:new).with(nil, user, ActionController::Parameters.new(parameters).permit!) - end + it { expect(service_class).to have_received(:new).with(nil, user, ActionController::Parameters.new(parameters).permit!) } it_behaves_like 'rejecting a blocked user' - it_behaves_like 'user logging' - end - end - - context 'using User login' do - let(:user) { create(:user) } - let(:headers) { { authorization: credentials(user.username, user.password) } } - subject! { get '/jwt/auth', params: parameters, headers: headers } + context 'when passing a flat array of scopes' do + # We use this trick to make rails to generate a query_string: + # scope=scope1&scope=scope2 + # It works because :scope and 'scope' are the same as string, but different objects + let(:parameters) do + { + :service => service_name, + :scope => 'scope1', + 'scope' => 'scope2' + } + end - it { expect(service_class).to have_received(:new).with(nil, user, ActionController::Parameters.new(parameters).permit!) } + let(:service_parameters) do + ActionController::Parameters.new({ service: service_name, scopes: %w(scope1 scope2) }).permit! + end - it_behaves_like 'rejecting a blocked user' + it { expect(service_class).to have_received(:new).with(nil, user, service_parameters) } - context 'when passing a flat array of scopes' do - # We use this trick to make rails to generate a query_string: - # scope=scope1&scope=scope2 - # It works because :scope and 'scope' are the same as string, but different objects - let(:parameters) do - { - :service => service_name, - :scope => 'scope1', - 'scope' => 'scope2' - } + it_behaves_like 'user logging' end - let(:service_parameters) do - ActionController::Parameters.new({ service: service_name, scopes: %w(scope1 scope2) }).permit! + context 'when user has 2FA enabled' do + let(:user) { create(:user, :two_factor) } + + context 'without personal token' do + it 'rejects the authorization attempt' do + expect(response).to have_gitlab_http_status(:unauthorized) + expect(response.body).to include('You must use a personal access token with \'api\' scope for Git over HTTP') + end + end + + context 'with personal token' do + let(:access_token) { create(:personal_access_token, user: user) } + let(:headers) { { authorization: credentials(user.username, access_token.token) } } + + it 'accepts the authorization attempt' do + expect(response).to have_gitlab_http_status(:ok) + end + end end - it { expect(service_class).to have_received(:new).with(nil, user, service_parameters) } + it 'does not cause session based checks to be activated' do + expect(Gitlab::Session).not_to receive(:with_session) + + get '/jwt/auth', params: parameters, headers: headers - it_behaves_like 'user logging' + expect(response).to have_gitlab_http_status(:ok) + end end - context 'when user has 2FA enabled' do - let(:user) { create(:user, :two_factor) } + context 'using invalid login' do + let(:headers) { { authorization: credentials('invalid', 'password') } } - context 'without personal token' do + context 'when internal auth is enabled' do it 'rejects the authorization attempt' do + get '/jwt/auth', params: parameters, headers: headers + expect(response).to have_gitlab_http_status(:unauthorized) - expect(response.body).to include('You must use a personal access token with \'api\' scope for Git over HTTP') + expect(response.body).not_to include('You must use a personal access token with \'api\' scope for Git over HTTP') end end - context 'with personal token' do - let(:access_token) { create(:personal_access_token, user: user) } - let(:headers) { { authorization: credentials(user.username, access_token.token) } } + context 'when internal auth is disabled' do + it 'rejects the authorization attempt with personal access token message' do + allow_next_instance_of(ApplicationSetting) do |instance| + allow(instance).to receive(:password_authentication_enabled_for_git?) { false } + end + get '/jwt/auth', params: parameters, headers: headers - it 'accepts the authorization attempt' do - expect(response).to have_gitlab_http_status(:ok) + expect(response).to have_gitlab_http_status(:unauthorized) + expect(response.body).to include('You must use a personal access token with \'api\' scope for Git over HTTP') end end end + end - it 'does not cause session based checks to be activated' do - expect(Gitlab::Session).not_to receive(:with_session) - - get '/jwt/auth', params: parameters, headers: headers + context 'when using unauthenticated request' do + it 'accepts the authorization attempt' do + get '/jwt/auth', params: parameters expect(response).to have_gitlab_http_status(:ok) end + + it 'allows read access' do + expect(service).to receive(:execute).with(authentication_abilities: Gitlab::Auth.read_only_authentication_abilities) + + get '/jwt/auth', params: parameters + end end - context 'using invalid login' do - let(:headers) { { authorization: credentials('invalid', 'password') } } + context 'unknown service' do + subject! { get '/jwt/auth', params: { service: 'unknown' } } - context 'when internal auth is enabled' do - it 'rejects the authorization attempt' do - get '/jwt/auth', params: parameters, headers: headers + it { expect(response).to have_gitlab_http_status(:not_found) } + end - expect(response).to have_gitlab_http_status(:unauthorized) - expect(response.body).not_to include('You must use a personal access token with \'api\' scope for Git over HTTP') - end - end + def credentials(login, password) + ActionController::HttpAuthentication::Basic.encode_credentials(login, password) + end + end - context 'when internal auth is disabled' do - it 'rejects the authorization attempt with personal access token message' do - allow_next_instance_of(ApplicationSetting) do |instance| - allow(instance).to receive(:password_authentication_enabled_for_git?) { false } - end - get '/jwt/auth', params: parameters, headers: headers + context 'authenticating against dependency proxy' do + let_it_be(:user) { create(:user) } + let_it_be(:personal_access_token) { create(:personal_access_token, user: user) } + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, :private, group: group) } + let_it_be(:group_deploy_token) { create(:deploy_token, :group, groups: [group]) } + let_it_be(:project_deploy_token) { create(:deploy_token, :project, projects: [project]) } + let_it_be(:service_name) { 'dependency_proxy' } + let(:headers) { { authorization: credentials(credential_user, credential_password) } } + let(:params) { { account: credential_user, client_id: 'docker', offline_token: true, service: service_name } } + + before do + stub_config(dependency_proxy: { enabled: true }) + end - expect(response).to have_gitlab_http_status(:unauthorized) - expect(response.body).to include('You must use a personal access token with \'api\' scope for Git over HTTP') - end + subject { get '/jwt/auth', params: params, headers: headers } + + shared_examples 'with valid credentials' do + it 'returns token successfully' do + subject + + expect(response).to have_gitlab_http_status(:success) + expect(json_response['token']).to be_present end end - end - context 'when using unauthenticated request' do - it 'accepts the authorization attempt' do - get '/jwt/auth', params: parameters + context 'with personal access token' do + let(:credential_user) { nil } + let(:credential_password) { personal_access_token.token } - expect(response).to have_gitlab_http_status(:ok) + it_behaves_like 'with valid credentials' end - it 'allows read access' do - expect(service).to receive(:execute).with(authentication_abilities: Gitlab::Auth.read_only_authentication_abilities) + context 'with user credentials token' do + let(:credential_user) { user.username } + let(:credential_password) { user.password } - get '/jwt/auth', params: parameters + it_behaves_like 'with valid credentials' end - end - context 'unknown service' do - subject! { get '/jwt/auth', params: { service: 'unknown' } } + context 'with group deploy token' do + let(:credential_user) { group_deploy_token.username } + let(:credential_password) { group_deploy_token.token } - it { expect(response).to have_gitlab_http_status(:not_found) } + it_behaves_like 'with valid credentials' + end + + context 'with project deploy token' do + let(:credential_user) { project_deploy_token.username } + let(:credential_password) { project_deploy_token.token } + + it_behaves_like 'with valid credentials' + end + + context 'with invalid credentials' do + let(:credential_user) { 'foo' } + let(:credential_password) { 'bar' } + + it 'returns unauthorized' do + subject + + expect(response).to have_gitlab_http_status(:unauthorized) + end + end end def credentials(login, password) diff --git a/spec/routing/group_routing_spec.rb b/spec/routing/group_routing_spec.rb index f4d5ccc81b68fd93cd4380fb8ca74959e9c7c289..f171c2faf5e6820f72ef4a36a1137429f5b86a06 100644 --- a/spec/routing/group_routing_spec.rb +++ b/spec/routing/group_routing_spec.rb @@ -81,6 +81,10 @@ end describe 'dependency proxy for containers' do + it 'routes to #authenticate' do + expect(get('/v2')).to route_to('groups/dependency_proxy_auth#authenticate') + end + context 'image name without namespace' do it 'routes to #manifest' do expect(get('/v2/gitlabhq/dependency_proxy/containers/ruby/manifests/2.3.6')) diff --git a/spec/services/auth/dependency_proxy_authentication_service_spec.rb b/spec/services/auth/dependency_proxy_authentication_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..ba50149f53aa515be464111ca34728dcd1572fbb --- /dev/null +++ b/spec/services/auth/dependency_proxy_authentication_service_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Auth::DependencyProxyAuthenticationService do + let_it_be(:user) { create(:user) } + let(:service) { Auth::DependencyProxyAuthenticationService.new(nil, user) } + + before do + stub_config(dependency_proxy: { enabled: true }) + end + + describe '#execute' do + subject { service.execute(authentication_abilities: nil) } + + context 'dependency proxy is not enabled' do + before do + stub_config(dependency_proxy: { enabled: false }) + end + + it 'returns not found' do + result = subject + + expect(result[:http_status]).to eq(404) + expect(result[:message]).to eq('dependency proxy not enabled') + end + end + + context 'without a user' do + let(:user) { nil } + + it 'returns forbidden' do + result = subject + + expect(result[:http_status]).to eq(403) + expect(result[:message]).to eq('access forbidden') + end + end + + context 'with a user' do + it 'returns a token' do + expect(subject[:token]).not_to be_nil + end + end + end +end diff --git a/spec/services/dependency_proxy/auth_token_service_spec.rb b/spec/services/dependency_proxy/auth_token_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..4b96f9d75a91d98bf5e14c41b289ce11555dc54a --- /dev/null +++ b/spec/services/dependency_proxy/auth_token_service_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true +require 'spec_helper' + +RSpec.describe DependencyProxy::AuthTokenService do + include DependencyProxyHelpers + + describe '.decoded_token_payload' do + let_it_be(:user) { create(:user) } + let_it_be(:token) { build_jwt(user) } + + subject { described_class.decoded_token_payload(token.encoded) } + + it 'returns the user' do + result = subject + + expect(result['user_id']).to eq(user.id) + end + + it 'raises an error if the token is expired' do + travel_to(Time.zone.now + Auth::DependencyProxyAuthenticationService.token_expire_at + 1.minute) do + expect { subject }.to raise_error(JWT::ExpiredSignature) + end + end + + it 'raises an error if decoding fails' do + allow(JWT).to receive(:decode).and_raise(JWT::DecodeError) + + expect { subject }.to raise_error(JWT::DecodeError) + end + + it 'raises an error if signature is immature' do + allow(JWT).to receive(:decode).and_raise(JWT::ImmatureSignature) + + expect { subject }.to raise_error(JWT::ImmatureSignature) + end + end +end diff --git a/spec/support/helpers/dependency_proxy_helpers.rb b/spec/support/helpers/dependency_proxy_helpers.rb index 545b9d1f4d0e9bf2f3bf08b29aa048783ca2ddaa..0074cfb793170356882d3ed6bcb30876738d4d4a 100644 --- a/spec/support/helpers/dependency_proxy_helpers.rb +++ b/spec/support/helpers/dependency_proxy_helpers.rb @@ -25,6 +25,13 @@ def stub_blob_download(image, blob_sha, status = 200, body = '123456') .to_return(status: status, body: body) end + def build_jwt(user = nil, expire_time: nil) + JSONWebToken::HMACToken.new(::Auth::DependencyProxyAuthenticationService.secret).tap do |jwt| + jwt['user_id'] = user.id if user + jwt.expire_time = expire_time || jwt.issued_at + 1.minute + end + end + private def registry