Commit 358dff7b authored by Kamil Trzciński's avatar Kamil Trzciński

Merge branch '11582-dependency-proxy-private-projects' into 'master'

Use the Dependency Proxy with private GitLab groups

See merge request gitlab-org/gitlab!46042
parents d798edaf ea4ef2b5
# 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
# 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
# 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
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
module Groups module Groups
class DependencyProxiesController < Groups::ApplicationController class DependencyProxiesController < Groups::ApplicationController
include DependencyProxyAccess include DependencyProxy::GroupAccess
before_action :authorize_admin_dependency_proxy!, only: :update before_action :authorize_admin_dependency_proxy!, only: :update
before_action :dependency_proxy before_action :dependency_proxy
......
# frozen_string_literal: true
class Groups::DependencyProxyAuthController < ApplicationController
include DependencyProxy::Auth
feature_category :dependency_proxy
def authenticate
render plain: '', status: :ok
end
end
# frozen_string_literal: true # frozen_string_literal: true
class Groups::DependencyProxyForContainersController < Groups::ApplicationController class Groups::DependencyProxyForContainersController < Groups::ApplicationController
include DependencyProxyAccess include DependencyProxy::Auth
include DependencyProxy::GroupAccess
include SendFileUpload include SendFileUpload
before_action :ensure_token_granted! before_action :ensure_token_granted!
...@@ -9,7 +10,7 @@ class Groups::DependencyProxyForContainersController < Groups::ApplicationContro ...@@ -9,7 +10,7 @@ class Groups::DependencyProxyForContainersController < Groups::ApplicationContro
attr_reader :token attr_reader :token
feature_category :package_registry feature_category :dependency_proxy
def manifest def manifest
result = DependencyProxy::PullManifestService.new(image, tag, token).execute result = DependencyProxy::PullManifestService.new(image, tag, token).execute
......
...@@ -11,7 +11,8 @@ class JwtController < ApplicationController ...@@ -11,7 +11,8 @@ class JwtController < ApplicationController
feature_category :authentication_and_authorization feature_category :authentication_and_authorization
SERVICES = { SERVICES = {
Auth::ContainerRegistryAuthenticationService::AUDIENCE => Auth::ContainerRegistryAuthenticationService ::Auth::ContainerRegistryAuthenticationService::AUDIENCE => ::Auth::ContainerRegistryAuthenticationService,
::Auth::DependencyProxyAuthenticationService::AUDIENCE => ::Auth::DependencyProxyAuthenticationService
}.freeze }.freeze
def auth def auth
......
# frozen_string_literal: true # frozen_string_literal: true
class DependencyProxy::Registry class DependencyProxy::Registry
AUTH_URL = 'https://auth.docker.io'.freeze AUTH_URL = 'https://auth.docker.io'
LIBRARY_URL = 'https://registry-1.docker.io/v2'.freeze LIBRARY_URL = 'https://registry-1.docker.io/v2'
PROXY_AUTH_URL = Gitlab::Utils.append_path(Gitlab.config.gitlab.url, "jwt/auth")
class << self class << self
def auth_url(image) def auth_url(image)
...@@ -17,6 +18,10 @@ class DependencyProxy::Registry ...@@ -17,6 +18,10 @@ class DependencyProxy::Registry
"#{LIBRARY_URL}/#{image_path(image)}/blobs/#{blob_sha}" "#{LIBRARY_URL}/#{image_path(image)}/blobs/#{blob_sha}"
end end
def authenticate_header
"Bearer realm=\"#{PROXY_AUTH_URL}\",service=\"#{::Auth::DependencyProxyAuthenticationService::AUDIENCE}\""
end
private private
def image_path(image) def image_path(image)
......
# 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
# 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
...@@ -7,7 +7,7 @@ ...@@ -7,7 +7,7 @@
- link_start = '<a href="%{url}">'.html_safe % { url: help_page_path('user/packages/dependency_proxy/index') } - link_start = '<a href="%{url}">'.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: '</a>'.html_safe } = _('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: '</a>'.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) - if can?(current_user, :admin_dependency_proxy, @group)
= form_for(@dependency_proxy, method: :put, url: group_dependency_proxy_path(@group)) do |f| = form_for(@dependency_proxy, method: :put, url: group_dependency_proxy_path(@group)) do |f|
.form-group .form-group
......
---
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
...@@ -125,7 +125,7 @@ end ...@@ -125,7 +125,7 @@ end
# Dependency proxy for containers # Dependency proxy for containers
# Because docker adds v2 prefix to URI this need to be outside of usual group routes # Because docker adds v2 prefix to URI this need to be outside of usual group routes
scope format: false do 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 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 get 'v2/*group_id/dependency_proxy/containers/*image/manifests/*tag' => 'groups/dependency_proxy_for_containers#manifest' # rubocop:todo Cop/PutGroupRoutesUnderScope
......
...@@ -8,6 +8,8 @@ info: To determine the technical writer assigned to the Stage/Group associated w ...@@ -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. > - [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. > - [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 The GitLab Dependency Proxy is a local proxy you can use for your frequently-accessed
upstream images. upstream images.
...@@ -17,9 +19,7 @@ upstream image from a registry, acting as a pull-through cache. ...@@ -17,9 +19,7 @@ upstream image from a registry, acting as a pull-through cache.
## Prerequisites ## Prerequisites
To use the Dependency Proxy: The Dependency Proxy must be [enabled by an administrator](../../../administration/packages/dependency_proxy.md).
- Your group must be public. Authentication for private groups is [not supported yet](https://gitlab.com/gitlab-org/gitlab/-/issues/11582).
### Supported images and packages ### Supported images and packages
...@@ -58,6 +58,56 @@ Prerequisites: ...@@ -58,6 +58,56 @@ Prerequisites:
- Docker Hub must be available. Follow [this issue](https://gitlab.com/gitlab-org/gitlab/-/issues/241639) - 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. 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: To store a Docker image in Dependency Proxy storage:
1. Go to your group's **Packages & Registries > Dependency Proxy**. 1. Go to your group's **Packages & Registries > Dependency Proxy**.
......
# 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
...@@ -3,8 +3,77 @@ ...@@ -3,8 +3,77 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe Groups::DependencyProxyForContainersController do RSpec.describe Groups::DependencyProxyForContainersController do
include HttpBasicAuthHelpers
include DependencyProxyHelpers
let_it_be(:user) { create(:user) }
let(:group) { create(:group) } let(:group) { create(:group) }
let(:token_response) { { status: :success, token: 'abcd1234' } } 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 shared_examples 'not found when disabled' do
context 'feature disabled' do context 'feature disabled' do
...@@ -27,6 +96,8 @@ RSpec.describe Groups::DependencyProxyForContainersController do ...@@ -27,6 +96,8 @@ RSpec.describe Groups::DependencyProxyForContainersController do
allow_next_instance_of(DependencyProxy::RequestTokenService) do |instance| allow_next_instance_of(DependencyProxy::RequestTokenService) do |instance|
allow(instance).to receive(:execute).and_return(token_response) allow(instance).to receive(:execute).and_return(token_response)
end end
request.headers['HTTP_AUTHORIZATION'] = token_header
end end
describe 'GET #manifest' do describe 'GET #manifest' do
...@@ -46,6 +117,10 @@ RSpec.describe Groups::DependencyProxyForContainersController do ...@@ -46,6 +117,10 @@ RSpec.describe Groups::DependencyProxyForContainersController do
enable_dependency_proxy enable_dependency_proxy
end 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 context 'remote token request fails' do
let(:token_response) do let(:token_response) do
{ {
...@@ -113,6 +188,10 @@ RSpec.describe Groups::DependencyProxyForContainersController do ...@@ -113,6 +188,10 @@ RSpec.describe Groups::DependencyProxyForContainersController do
enable_dependency_proxy enable_dependency_proxy
end 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 context 'remote blob request fails' do
let(:blob_response) do let(:blob_response) do
{ {
......
...@@ -79,13 +79,19 @@ RSpec.describe 'Group Dependency Proxy' do ...@@ -79,13 +79,19 @@ RSpec.describe 'Group Dependency Proxy' do
sign_in(developer) sign_in(developer)
end end
context 'group is private' do context 'feature flag is disabled' do
let(:group) { create(:group, :private) } before do
stub_feature_flags(dependency_proxy_for_private_groups: false)
end
it 'informs user that feature is only available for public groups' do context 'group is private' do
visit path 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
end end
......
...@@ -54,4 +54,11 @@ RSpec.describe DependencyProxy::Registry, type: :model do ...@@ -54,4 +54,11 @@ RSpec.describe DependencyProxy::Registry, type: :model do
end end
end 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 end
...@@ -5,13 +5,13 @@ require 'spec_helper' ...@@ -5,13 +5,13 @@ require 'spec_helper'
RSpec.describe JwtController do RSpec.describe JwtController do
include_context 'parsed logs' include_context 'parsed logs'
let(:service) { double(execute: {}) } let(:service) { double(execute: {} ) }
let(:service_class) { double(new: service) } let(:service_class) { Auth::ContainerRegistryAuthenticationService }
let(:service_name) { 'test' } let(:service_name) { 'container_registry' }
let(:parameters) { { service: service_name } } let(:parameters) { { service: service_name } }
before do before do
stub_const('JwtController::SERVICES', service_name => service_class) allow(service_class).to receive(:new).and_return(service)
end end
shared_examples 'user logging' do shared_examples 'user logging' do
...@@ -22,194 +22,266 @@ RSpec.describe JwtController do ...@@ -22,194 +22,266 @@ RSpec.describe JwtController do
end end
end end
context 'existing service' do context 'authenticating against container registry' do
subject! { get '/jwt/auth', params: parameters } 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 context 'returning custom http code' do
let(:service) { double(execute: { http_status: 505 }) } 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
end
context 'when using authenticated request' do context 'when using authenticated request' do
shared_examples 'rejecting a blocked user' do shared_examples 'rejecting a blocked user' do
context 'with blocked user' do context 'with blocked user' do
let(:user) { create(:user, :blocked) } let(:user) { create(:user, :blocked) }
it 'rejects the request as unauthorized' do it 'rejects the request as unauthorized' do
expect(response).to have_gitlab_http_status(:unauthorized) expect(response).to have_gitlab_http_status(:unauthorized)
expect(response.body).to include('HTTP Basic: Access denied') expect(response.body).to include('HTTP Basic: Access denied')
end
end end
end end
end
context 'using CI token' do context 'using CI token' do
let(:user) { create(:user) } let(:user) { create(:user) }
let(:build) { create(:ci_build, :running, user: user) } let(:build) { create(:ci_build, :running, user: user) }
let(:project) { build.project } let(:project) { build.project }
let(:headers) { { authorization: credentials('gitlab-ci-token', build.token) } } let(:headers) { { authorization: credentials('gitlab-ci-token', build.token) } }
context 'project with enabled CI' do context 'project with enabled CI' do
subject! { get '/jwt/auth', params: parameters, headers: headers } subject! { get '/jwt/auth', params: parameters, headers: headers }
it { expect(service_class).to have_received(:new).with(project, user, ActionController::Parameters.new(parameters).permit!) }
it_behaves_like 'user logging' it { expect(service_class).to have_received(:new).with(project, user, ActionController::Parameters.new(parameters).permit!) }
end
context 'project with disabled CI' do it_behaves_like 'user logging'
before do
project.project_feature.update_attribute(:builds_access_level, ProjectFeature::DISABLED)
end 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) } subject! { get '/jwt/auth', params: parameters, headers: headers }
end
context 'using deploy tokens' do it { expect(response).to have_gitlab_http_status(:unauthorized) }
let(:deploy_token) { create(:deploy_token, read_registry: true, projects: [project]) } end
let(:headers) { { authorization: credentials(deploy_token.username, deploy_token.token) } }
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 subject! { get '/jwt/auth', params: parameters, headers: headers }
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 it 'authenticates correctly' do
expect(log_data.keys).not_to include(%w(username user_id)) 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
end
context 'using personal access tokens' do context 'using personal access tokens' do
let(:pat) { create(:personal_access_token, user: user, scopes: ['read_registry']) } let(:pat) { create(:personal_access_token, user: user, scopes: ['read_registry']) }
let(:headers) { { authorization: credentials('personal_access_token', pat.token) } } let(:headers) { { authorization: credentials('personal_access_token', pat.token) } }
before do before do
stub_container_registry_config(enabled: true) 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
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 } subject! { get '/jwt/auth', params: parameters, headers: headers }
it 'authenticates correctly' do it { expect(service_class).to have_received(:new).with(nil, user, ActionController::Parameters.new(parameters).permit!) }
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 '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 it_behaves_like 'user logging'
# 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 end
let(:service_parameters) do context 'when user has 2FA enabled' do
ActionController::Parameters.new({ service: service_name, scopes: %w(scope1 scope2) }).permit! 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 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 end
context 'when user has 2FA enabled' do context 'using invalid login' do
let(:user) { create(:user, :two_factor) } let(:headers) { { authorization: credentials('invalid', 'password') } }
context 'without personal token' do context 'when internal auth is enabled' do
it 'rejects the authorization attempt' do it 'rejects the authorization attempt' do
get '/jwt/auth', params: parameters, headers: headers
expect(response).to have_gitlab_http_status(:unauthorized) 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
end end
context 'with personal token' do context 'when internal auth is disabled' do
let(:access_token) { create(:personal_access_token, user: user) } it 'rejects the authorization attempt with personal access token message' do
let(:headers) { { authorization: credentials(user.username, access_token.token) } } 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(:unauthorized)
expect(response).to have_gitlab_http_status(:ok) expect(response.body).to include('You must use a personal access token with \'api\' scope for Git over HTTP')
end end
end end
end end
end
it 'does not cause session based checks to be activated' do context 'when using unauthenticated request' do
expect(Gitlab::Session).not_to receive(:with_session) it 'accepts the authorization attempt' do
get '/jwt/auth', params: parameters
get '/jwt/auth', params: parameters, headers: headers
expect(response).to have_gitlab_http_status(:ok) expect(response).to have_gitlab_http_status(:ok)
end 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 end
context 'using invalid login' do context 'unknown service' do
let(:headers) { { authorization: credentials('invalid', 'password') } } subject! { get '/jwt/auth', params: { service: 'unknown' } }
context 'when internal auth is enabled' do it { expect(response).to have_gitlab_http_status(:not_found) }
it 'rejects the authorization attempt' do end
get '/jwt/auth', params: parameters, headers: headers
expect(response).to have_gitlab_http_status(:unauthorized) def credentials(login, password)
expect(response.body).not_to include('You must use a personal access token with \'api\' scope for Git over HTTP') ActionController::HttpAuthentication::Basic.encode_credentials(login, password)
end end
end end
context 'when internal auth is disabled' do context 'authenticating against dependency proxy' do
it 'rejects the authorization attempt with personal access token message' do let_it_be(:user) { create(:user) }
allow_next_instance_of(ApplicationSetting) do |instance| let_it_be(:personal_access_token) { create(:personal_access_token, user: user) }
allow(instance).to receive(:password_authentication_enabled_for_git?) { false } let_it_be(:group) { create(:group) }
end let_it_be(:project) { create(:project, :private, group: group) }
get '/jwt/auth', params: parameters, headers: headers 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) subject { get '/jwt/auth', params: params, headers: headers }
expect(response.body).to include('You must use a personal access token with \'api\' scope for Git over HTTP')
end 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 end
end
context 'when using unauthenticated request' do context 'with personal access token' do
it 'accepts the authorization attempt' do let(:credential_user) { nil }
get '/jwt/auth', params: parameters let(:credential_password) { personal_access_token.token }
expect(response).to have_gitlab_http_status(:ok) it_behaves_like 'with valid credentials'
end end
it 'allows read access' do context 'with user credentials token' do
expect(service).to receive(:execute).with(authentication_abilities: Gitlab::Auth.read_only_authentication_abilities) let(:credential_user) { user.username }
let(:credential_password) { user.password }
get '/jwt/auth', params: parameters it_behaves_like 'with valid credentials'
end end
end
context 'unknown service' do context 'with group deploy token' do
subject! { get '/jwt/auth', params: { service: 'unknown' } } 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 end
def credentials(login, password) def credentials(login, password)
......
...@@ -81,6 +81,10 @@ RSpec.describe "Groups", "routing" do ...@@ -81,6 +81,10 @@ RSpec.describe "Groups", "routing" do
end end
describe 'dependency proxy for containers' do 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 context 'image name without namespace' do
it 'routes to #manifest' do it 'routes to #manifest' do
expect(get('/v2/gitlabhq/dependency_proxy/containers/ruby/manifests/2.3.6')) expect(get('/v2/gitlabhq/dependency_proxy/containers/ruby/manifests/2.3.6'))
......
# 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
# 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
...@@ -25,6 +25,13 @@ module DependencyProxyHelpers ...@@ -25,6 +25,13 @@ module DependencyProxyHelpers
.to_return(status: status, body: body) .to_return(status: status, body: body)
end 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 private
def registry def registry
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment