summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDouwe Maan <douwe@gitlab.com>2016-05-16 17:20:31 +0000
committerDouwe Maan <douwe@gitlab.com>2016-05-16 17:20:31 +0000
commit59e62fc4866215eda1d291a240b6c4faaee2e961 (patch)
tree9db48ca4c378bb900780c8dbba6f9a6333468799
parent5dd013f1452e1a521dfa0db99ae9910b1dc27b0e (diff)
parent72577033888a77eeb2f4d362c2ae2331c1cbf6d7 (diff)
downloadgitlab-ce-59e62fc4866215eda1d291a240b6c4faaee2e961.tar.gz
Merge branch 'docker-registry' into 'master'
Added authentication service for docker registry This adds a simple authentication service for docker which uses current user credentials to authenticate pulls and pushes. I have only one concern. Since the `.docker/config` is unencrypted, thus the password for user stored there is unencrypted, maybe we should from the start implement function to generate/provide a separate password just for the purposes of accessing docker registry? What do you think @jacobvosmaer @sytses @marin? cc @marin See merge request !3787
-rw-r--r--CHANGELOG1
-rw-r--r--Gemfile2
-rw-r--r--Gemfile.lock3
-rw-r--r--app/controllers/jwt_controller.rb87
-rw-r--r--app/controllers/projects_controller.rb3
-rw-r--r--app/models/ability.rb13
-rw-r--r--app/models/project.rb7
-rw-r--r--app/services/auth/container_registry_authentication_service.rb70
-rw-r--r--app/views/projects/edit.html.haml10
-rw-r--r--config/gitlab.yml.example9
-rw-r--r--config/initializers/1_settings.rb23
-rw-r--r--config/routes.rb3
-rw-r--r--db/migrate/20160407120251_add_images_enabled_for_project.rb5
-rw-r--r--db/schema.rb1
-rw-r--r--doc/api/projects.md3
-rw-r--r--doc/permissions/permissions.md2
-rw-r--r--lib/api/entities.rb3
-rw-r--r--lib/api/projects.rb5
-rw-r--r--lib/json_web_token/rsa_token.rb42
-rw-r--r--lib/json_web_token/token.rb46
-rw-r--r--spec/lib/json_web_token/rsa_token_spec.rb43
-rw-r--r--spec/lib/json_web_token/token_spec.rb18
-rw-r--r--spec/requests/jwt_controller_spec.rb72
-rw-r--r--spec/services/auth/container_registry_authentication_service_spec.rb216
24 files changed, 677 insertions, 10 deletions
diff --git a/CHANGELOG b/CHANGELOG
index d359d6d4aad..2837b321d54 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -25,6 +25,7 @@ v 8.8.0 (unreleased)
- Update SVG sanitizer to conform to SVG 1.1
- Speed up push emails with multiple recipients by only generating the email once
- Updated search UI
+ - Added authentication service for Container Registry
- Display informative message when new milestone is created
- Sanitize milestones and labels titles
- Support multi-line tag messages. !3833 (Calin Seciu)
diff --git a/Gemfile b/Gemfile
index 6949c16e966..91ad1706a07 100644
--- a/Gemfile
+++ b/Gemfile
@@ -36,6 +36,7 @@ gem 'omniauth-shibboleth', '~> 1.2.0'
gem 'omniauth-twitter', '~> 1.2.0'
gem 'omniauth_crowd', '~> 2.2.0'
gem 'rack-oauth2', '~> 1.2.1'
+gem 'jwt'
# Spam and anti-bot protection
gem 'recaptcha', require: 'recaptcha/rails'
@@ -224,6 +225,7 @@ gem 'request_store', '~> 1.3.0'
gem 'select2-rails', '~> 3.5.9'
gem 'virtus', '~> 1.0.1'
gem 'net-ssh', '~> 3.0.1'
+gem 'base32', '~> 0.3.0'
# Sentry integration
gem 'sentry-raven', '~> 0.15'
diff --git a/Gemfile.lock b/Gemfile.lock
index 4b51bf58bba..b55764504c6 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -70,6 +70,7 @@ GEM
ice_nine (~> 0.11.0)
thread_safe (~> 0.3, >= 0.3.1)
babosa (1.0.2)
+ base32 (0.3.2)
bcrypt (3.1.10)
benchmark-ips (2.3.0)
better_errors (1.0.1)
@@ -893,6 +894,7 @@ DEPENDENCIES
attr_encrypted (~> 1.3.4)
awesome_print (~> 1.2.0)
babosa (~> 1.0.2)
+ base32 (~> 0.3.0)
benchmark-ips
better_errors (~> 1.0.1)
binding_of_caller (~> 0.7.2)
@@ -954,6 +956,7 @@ DEPENDENCIES
jquery-rails (~> 4.1.0)
jquery-turbolinks (~> 2.1.0)
jquery-ui-rails (~> 5.0.0)
+ jwt
kaminari (~> 0.16.3)
letter_opener_web (~> 1.3.0)
licensee (~> 8.0.0)
diff --git a/app/controllers/jwt_controller.rb b/app/controllers/jwt_controller.rb
new file mode 100644
index 00000000000..f5aa5397ff1
--- /dev/null
+++ b/app/controllers/jwt_controller.rb
@@ -0,0 +1,87 @@
+class JwtController < ApplicationController
+ skip_before_action :authenticate_user!
+ skip_before_action :verify_authenticity_token
+ before_action :authenticate_project_or_user
+
+ SERVICES = {
+ Auth::ContainerRegistryAuthenticationService::AUDIENCE => Auth::ContainerRegistryAuthenticationService,
+ }
+
+ def auth
+ service = SERVICES[params[:service]]
+ return head :not_found unless service
+
+ result = service.new(@project, @user, auth_params).execute
+
+ render json: result, status: result[:http_status]
+ end
+
+ private
+
+ def authenticate_project_or_user
+ authenticate_with_http_basic do |login, password|
+ # if it's possible we first try to authenticate project with login and password
+ @project = authenticate_project(login, password)
+ return if @project
+
+ @user = authenticate_user(login, password)
+ return if @user
+
+ render_403
+ end
+ end
+
+ def auth_params
+ params.permit(:service, :scope, :offline_token, :account, :client_id)
+ end
+
+ def authenticate_project(login, password)
+ if login == 'gitlab_ci_token'
+ Project.find_by(builds_enabled: true, runners_token: password)
+ end
+ end
+
+ def authenticate_user(login, password)
+ # TODO: this is a copy and paste from grack_auth,
+ # it should be refactored in the future
+
+ user = Gitlab::Auth.new.find(login, password)
+
+ # If the user authenticated successfully, we reset the auth failure count
+ # from Rack::Attack for that IP. A client may attempt to authenticate
+ # with a username and blank password first, and only after it receives
+ # a 401 error does it present a password. Resetting the count prevents
+ # false positives from occurring.
+ #
+ # Otherwise, we let Rack::Attack know there was a failed authentication
+ # attempt from this IP. This information is stored in the Rails cache
+ # (Redis) and will be used by the Rack::Attack middleware to decide
+ # whether to block requests from this IP.
+ config = Gitlab.config.rack_attack.git_basic_auth
+
+ if config.enabled
+ if user
+ # A successful login will reset the auth failure count from this IP
+ Rack::Attack::Allow2Ban.reset(request.ip, config)
+ else
+ banned = Rack::Attack::Allow2Ban.filter(request.ip, config) do
+ # Unless the IP is whitelisted, return true so that Allow2Ban
+ # increments the counter (stored in Rails.cache) for the IP
+ if config.ip_whitelist.include?(request.ip)
+ false
+ else
+ true
+ end
+ end
+
+ if banned
+ Rails.logger.info "IP #{request.ip} failed to login " \
+ "as #{login} but has been temporarily banned from Git auth"
+ return
+ end
+ end
+ end
+
+ user
+ end
+end
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index 3768efe142a..f4ec60ad2c7 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -235,7 +235,8 @@ class ProjectsController < Projects::ApplicationController
def project_params
params.require(:project).permit(
:name, :path, :description, :issues_tracker, :tag_list, :runners_token,
- :issues_enabled, :merge_requests_enabled, :snippets_enabled, :issues_tracker_id, :default_branch,
+ :issues_enabled, :merge_requests_enabled, :snippets_enabled, :container_registry_enabled,
+ :issues_tracker_id, :default_branch,
:wiki_enabled, :visibility_level, :import_url, :last_activity_at, :namespace_id, :avatar,
:builds_enabled, :build_allow_git_fetch, :build_timeout_in_minutes, :build_coverage_regex,
:public_builds,
diff --git a/app/models/ability.rb b/app/models/ability.rb
index 6103a2947e2..f70268d3138 100644
--- a/app/models/ability.rb
+++ b/app/models/ability.rb
@@ -61,6 +61,7 @@ class Ability
:read_merge_request,
:read_note,
:read_commit_status,
+ :read_container_image,
:download_code
]
@@ -203,6 +204,7 @@ class Ability
:admin_label,
:read_commit_status,
:read_build,
+ :read_container_image,
]
end
@@ -216,7 +218,9 @@ class Ability
:update_build,
:create_merge_request,
:create_wiki,
- :push_code
+ :push_code,
+ :create_container_image,
+ :update_container_image,
]
end
@@ -242,7 +246,8 @@ class Ability
:admin_wiki,
:admin_project,
:admin_commit_status,
- :admin_build
+ :admin_build,
+ :admin_container_image,
]
end
@@ -287,6 +292,10 @@ class Ability
rules += named_abilities('build')
end
+ unless project.container_registry_enabled
+ rules += named_abilities('container_image')
+ end
+
rules
end
diff --git a/app/models/project.rb b/app/models/project.rb
index 418b85e028a..6e85841db44 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -22,6 +22,7 @@ class Project < ActiveRecord::Base
default_value_for :builds_enabled, gitlab_config_features.builds
default_value_for :wiki_enabled, gitlab_config_features.wiki
default_value_for :snippets_enabled, gitlab_config_features.snippets
+ default_value_for :container_registry_enabled, gitlab_config_features.container_registry
default_value_for(:shared_runners_enabled) { current_application_settings.shared_runners_enabled }
# set last_activity_at to the same as created_at
@@ -327,6 +328,12 @@ class Project < ActiveRecord::Base
@repository ||= Repository.new(path_with_namespace, self)
end
+ def container_registry_url
+ if container_registry_enabled? && Gitlab.config.registry.enabled
+ "#{Gitlab.config.registry.host_with_port}/#{path_with_namespace}"
+ end
+ end
+
def commit(id = 'HEAD')
repository.commit(id)
end
diff --git a/app/services/auth/container_registry_authentication_service.rb b/app/services/auth/container_registry_authentication_service.rb
new file mode 100644
index 00000000000..b636f55d031
--- /dev/null
+++ b/app/services/auth/container_registry_authentication_service.rb
@@ -0,0 +1,70 @@
+module Auth
+ class ContainerRegistryAuthenticationService < BaseService
+ AUDIENCE = 'container_registry'
+
+ def execute
+ return error('not found', 404) unless registry.enabled
+
+ if params[:offline_token]
+ return error('forbidden', 403) unless current_user
+ else
+ return error('forbidden', 403) unless scope
+ end
+
+ { token: authorized_token(scope).encoded }
+ end
+
+ private
+
+ def authorized_token(*accesses)
+ token = JSONWebToken::RSAToken.new(registry.key)
+ token.issuer = registry.issuer
+ token.audience = params[:service]
+ token.subject = current_user.try(:username)
+ token[:access] = accesses.compact
+ token
+ end
+
+ def scope
+ return unless params[:scope]
+
+ @scope ||= process_scope(params[:scope])
+ end
+
+ def process_scope(scope)
+ type, name, actions = scope.split(':', 3)
+ actions = actions.split(',')
+ return unless type == 'repository'
+
+ process_repository_access(type, name, actions)
+ end
+
+ def process_repository_access(type, name, actions)
+ requested_project = Project.find_with_namespace(name)
+ return unless requested_project
+
+ actions = actions.select do |action|
+ can_access?(requested_project, action)
+ end
+
+ { type: type, name: name, actions: actions } if actions.present?
+ end
+
+ def can_access?(requested_project, requested_action)
+ return false unless requested_project.container_registry_enabled?
+
+ case requested_action
+ when 'pull'
+ requested_project == project || can?(current_user, :read_container_image, requested_project)
+ when 'push'
+ requested_project == project || can?(current_user, :create_container_image, requested_project)
+ else
+ false
+ end
+ end
+
+ def registry
+ Gitlab.config.registry
+ end
+ end
+end
diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml
index 76a4f41193c..f6a53fddf17 100644
--- a/app/views/projects/edit.html.haml
+++ b/app/views/projects/edit.html.haml
@@ -84,6 +84,16 @@
%br
%span.descr Share code pastes with others out of git repository
+ - if Gitlab.config.registry.enabled
+ .form-group
+ .col-sm-offset-2.col-sm-10
+ .checkbox
+ = f.label :container_registry_enabled do
+ = f.check_box :container_registry_enabled
+ %strong Container Registry
+ %br
+ %span.descr Enable Container Registry for this repository
+
= render 'builds_settings', f: f
%fieldset.features
diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example
index e682bcb976d..d935121d88b 100644
--- a/config/gitlab.yml.example
+++ b/config/gitlab.yml.example
@@ -98,6 +98,7 @@ production: &base
wiki: true
snippets: false
builds: true
+ container_registry: true
## Webhook settings
# Number of seconds to wait for HTTP response after sending webhook HTTP POST request (default: 10)
@@ -175,6 +176,14 @@ production: &base
repository_archive_cache_worker:
cron: "0 * * * *"
+ registry:
+ # enabled: true
+ # host: registry.example.com
+ # port: 5000
+ # api_url: http://localhost:5000/
+ # key: config/registry.key
+ # issuer: omnibus-certificate
+
#
# 2. GitLab CI settings
# ==========================
diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb
index a69b933d811..d1fcb053bee 100644
--- a/config/initializers/1_settings.rb
+++ b/config/initializers/1_settings.rb
@@ -206,12 +206,13 @@ Settings.gitlab['default_projects_features'] ||= {}
Settings.gitlab['webhook_timeout'] ||= 10
Settings.gitlab['max_attachment_size'] ||= 10
Settings.gitlab['session_expire_delay'] ||= 10080
-Settings.gitlab.default_projects_features['issues'] = true if Settings.gitlab.default_projects_features['issues'].nil?
-Settings.gitlab.default_projects_features['merge_requests'] = true if Settings.gitlab.default_projects_features['merge_requests'].nil?
-Settings.gitlab.default_projects_features['wiki'] = true if Settings.gitlab.default_projects_features['wiki'].nil?
-Settings.gitlab.default_projects_features['snippets'] = false if Settings.gitlab.default_projects_features['snippets'].nil?
-Settings.gitlab.default_projects_features['builds'] = true if Settings.gitlab.default_projects_features['builds'].nil?
-Settings.gitlab.default_projects_features['visibility_level'] = Settings.send(:verify_constant, Gitlab::VisibilityLevel, Settings.gitlab.default_projects_features['visibility_level'], Gitlab::VisibilityLevel::PRIVATE)
+Settings.gitlab.default_projects_features['issues'] = true if Settings.gitlab.default_projects_features['issues'].nil?
+Settings.gitlab.default_projects_features['merge_requests'] = true if Settings.gitlab.default_projects_features['merge_requests'].nil?
+Settings.gitlab.default_projects_features['wiki'] = true if Settings.gitlab.default_projects_features['wiki'].nil?
+Settings.gitlab.default_projects_features['snippets'] = false if Settings.gitlab.default_projects_features['snippets'].nil?
+Settings.gitlab.default_projects_features['builds'] = true if Settings.gitlab.default_projects_features['builds'].nil?
+Settings.gitlab.default_projects_features['container_registry'] = true if Settings.gitlab.default_projects_features['container_registry'].nil?
+Settings.gitlab.default_projects_features['visibility_level'] = Settings.send(:verify_constant, Gitlab::VisibilityLevel, Settings.gitlab.default_projects_features['visibility_level'], Gitlab::VisibilityLevel::PRIVATE)
Settings.gitlab['repository_downloads_path'] = File.join(Settings.shared['path'], 'cache/archive') if Settings.gitlab['repository_downloads_path'].nil?
Settings.gitlab['restricted_signup_domains'] ||= []
Settings.gitlab['import_sources'] ||= ['github','bitbucket','gitlab','gitorious','google_code','fogbugz','git']
@@ -243,6 +244,16 @@ Settings.artifacts['path'] = File.expand_path(Settings.artifacts['path']
Settings.artifacts['max_size'] ||= 100 # in megabytes
#
+# Registry
+#
+Settings['registry'] ||= Settingslogic.new({})
+Settings.registry['enabled'] ||= false
+Settings.registry['host'] ||= "example.com"
+Settings.registry['api_url'] ||= "http://localhost:5000/"
+Settings.registry['key'] ||= nil
+Settings.registry['issuer'] ||= nil
+
+#
# Git LFS
#
Settings['lfs'] ||= Settingslogic.new({})
diff --git a/config/routes.rb b/config/routes.rb
index 9e776a1f541..e1b72556098 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -64,6 +64,9 @@ Rails.application.routes.draw do
get 'search' => 'search#show'
get 'search/autocomplete' => 'search#autocomplete', as: :search_autocomplete
+ # JSON Web Token
+ get 'jwt/auth' => 'jwt#auth'
+
# API
API::API.logger Rails.logger
mount API::API => '/api'
diff --git a/db/migrate/20160407120251_add_images_enabled_for_project.rb b/db/migrate/20160407120251_add_images_enabled_for_project.rb
new file mode 100644
index 00000000000..47f0ca8e8de
--- /dev/null
+++ b/db/migrate/20160407120251_add_images_enabled_for_project.rb
@@ -0,0 +1,5 @@
+class AddImagesEnabledForProject < ActiveRecord::Migration
+ def change
+ add_column :projects, :container_registry_enabled, :boolean
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index e1117a0d858..af4f4c609e7 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -762,6 +762,7 @@ ActiveRecord::Schema.define(version: 20160509201028) do
t.integer "pushes_since_gc", default: 0
t.boolean "last_repository_check_failed"
t.datetime "last_repository_check_at"
+ t.boolean "container_registry_enabled"
end
add_index "projects", ["builds_enabled", "shared_runners_enabled"], name: "index_projects_on_builds_enabled_and_shared_runners_enabled", using: :btree
diff --git a/doc/api/projects.md b/doc/api/projects.md
index de1faadebf5..f5f195b97df 100644
--- a/doc/api/projects.md
+++ b/doc/api/projects.md
@@ -424,6 +424,7 @@ Parameters:
- `builds_enabled` (optional)
- `wiki_enabled` (optional)
- `snippets_enabled` (optional)
+- `container_registry_enabled` (optional)
- `public` (optional) - if `true` same as setting visibility_level = 20
- `visibility_level` (optional)
- `import_url` (optional)
@@ -447,6 +448,7 @@ Parameters:
- `builds_enabled` (optional)
- `wiki_enabled` (optional)
- `snippets_enabled` (optional)
+- `container_registry_enabled` (optional)
- `public` (optional) - if `true` same as setting visibility_level = 20
- `visibility_level` (optional)
- `import_url` (optional)
@@ -472,6 +474,7 @@ Parameters:
- `builds_enabled` (optional)
- `wiki_enabled` (optional)
- `snippets_enabled` (optional)
+- `container_registry_enabled` (optional)
- `public` (optional) - if `true` same as setting visibility_level = 20
- `visibility_level` (optional)
- `public_builds` (optional)
diff --git a/doc/permissions/permissions.md b/doc/permissions/permissions.md
index 6219693b8a8..6be5ea0b486 100644
--- a/doc/permissions/permissions.md
+++ b/doc/permissions/permissions.md
@@ -27,6 +27,7 @@ documentation](../workflow/add-user/add-user.md).
| Manage issue tracker | | ✓ | ✓ | ✓ | ✓ |
| Manage labels | | ✓ | ✓ | ✓ | ✓ |
| See a commit status | | ✓ | ✓ | ✓ | ✓ |
+| See a container registry | | ✓ | ✓ | ✓ | ✓ |
| Manage merge requests | | | ✓ | ✓ | ✓ |
| Create new merge request | | | ✓ | ✓ | ✓ |
| Create new branches | | | ✓ | ✓ | ✓ |
@@ -37,6 +38,7 @@ documentation](../workflow/add-user/add-user.md).
| Write a wiki | | | ✓ | ✓ | ✓ |
| Cancel and retry builds | | | ✓ | ✓ | ✓ |
| Create or update commit status | | | ✓ | ✓ | ✓ |
+| Update a container registry | | | ✓ | ✓ | ✓ |
| Create new milestones | | | | ✓ | ✓ |
| Add new team members | | | | ✓ | ✓ |
| Push to protected branches | | | | ✓ | ✓ |
diff --git a/lib/api/entities.rb b/lib/api/entities.rb
index 93a5798e21e..dbd03ea74fa 100644
--- a/lib/api/entities.rb
+++ b/lib/api/entities.rb
@@ -66,7 +66,8 @@ module API
expose :owner, using: Entities::UserBasic, unless: ->(project, options) { project.group }
expose :name, :name_with_namespace
expose :path, :path_with_namespace
- expose :issues_enabled, :merge_requests_enabled, :wiki_enabled, :builds_enabled, :snippets_enabled, :created_at, :last_activity_at
+ expose :issues_enabled, :merge_requests_enabled, :wiki_enabled, :builds_enabled, :snippets_enabled, :container_registry_enabled
+ expose :created_at, :last_activity_at
expose :shared_runners_enabled
expose :creator_id
expose :namespace
diff --git a/lib/api/projects.rb b/lib/api/projects.rb
index 9b595772675..5a22d14988f 100644
--- a/lib/api/projects.rb
+++ b/lib/api/projects.rb
@@ -94,6 +94,7 @@ module API
# builds_enabled (optional)
# wiki_enabled (optional)
# snippets_enabled (optional)
+ # container_registry_enabled (optional)
# shared_runners_enabled (optional)
# namespace_id (optional) - defaults to user namespace
# public (optional) - if true same as setting visibility_level = 20
@@ -112,6 +113,7 @@ module API
:builds_enabled,
:wiki_enabled,
:snippets_enabled,
+ :container_registry_enabled,
:shared_runners_enabled,
:namespace_id,
:public,
@@ -143,6 +145,7 @@ module API
# builds_enabled (optional)
# wiki_enabled (optional)
# snippets_enabled (optional)
+ # container_registry_enabled (optional)
# shared_runners_enabled (optional)
# public (optional) - if true same as setting visibility_level = 20
# visibility_level (optional)
@@ -206,6 +209,7 @@ module API
# builds_enabled (optional)
# wiki_enabled (optional)
# snippets_enabled (optional)
+ # container_registry_enabled (optional)
# shared_runners_enabled (optional)
# public (optional) - if true same as setting visibility_level = 20
# visibility_level (optional) - visibility level of a project
@@ -222,6 +226,7 @@ module API
:builds_enabled,
:wiki_enabled,
:snippets_enabled,
+ :container_registry_enabled,
:shared_runners_enabled,
:public,
:visibility_level,
diff --git a/lib/json_web_token/rsa_token.rb b/lib/json_web_token/rsa_token.rb
new file mode 100644
index 00000000000..d6d6af7089c
--- /dev/null
+++ b/lib/json_web_token/rsa_token.rb
@@ -0,0 +1,42 @@
+module JSONWebToken
+ class RSAToken < Token
+ attr_reader :key_file
+
+ def initialize(key_file)
+ super()
+ @key_file = key_file
+ end
+
+ def encoded
+ headers = {
+ kid: kid
+ }
+ JWT.encode(payload, key, 'RS256', headers)
+ end
+
+ private
+
+ def key_data
+ @key_data ||= File.read(key_file)
+ end
+
+ def key
+ @key ||= OpenSSL::PKey::RSA.new(key_data)
+ end
+
+ def public_key
+ key.public_key
+ end
+
+ def kid
+ # calculate sha256 from DER encoded ASN1
+ kid = Digest::SHA256.digest(public_key.to_der)
+
+ # we encode only 30 bytes with base32
+ kid = Base32.encode(kid[0..29])
+
+ # insert colon every 4 characters
+ kid.scan(/.{4}/).join(':')
+ end
+ end
+end
diff --git a/lib/json_web_token/token.rb b/lib/json_web_token/token.rb
new file mode 100644
index 00000000000..5b67715b0b2
--- /dev/null
+++ b/lib/json_web_token/token.rb
@@ -0,0 +1,46 @@
+module JSONWebToken
+ class Token
+ attr_accessor :issuer, :subject, :audience, :id
+ attr_accessor :issued_at, :not_before, :expire_time
+
+ def initialize
+ @id = SecureRandom.uuid
+ @issued_at = Time.now
+ # we give a few seconds for time shift
+ @not_before = issued_at - 5.seconds
+ # default 60 seconds should be more than enough for this authentication token
+ @expire_time = issued_at + 1.minute
+ @custom_payload = {}
+ end
+
+ def [](key)
+ @custom_payload[key]
+ end
+
+ def []=(key, value)
+ @custom_payload[key] = value
+ end
+
+ def encoded
+ raise NotImplementedError
+ end
+
+ def payload
+ @custom_payload.merge(default_payload)
+ end
+
+ private
+
+ def default_payload
+ {
+ jti: id,
+ aud: audience,
+ sub: subject,
+ iss: issuer,
+ iat: issued_at.to_i,
+ nbf: not_before.to_i,
+ exp: expire_time.to_i
+ }.compact
+ end
+ end
+end
diff --git a/spec/lib/json_web_token/rsa_token_spec.rb b/spec/lib/json_web_token/rsa_token_spec.rb
new file mode 100644
index 00000000000..0c3d3ea7019
--- /dev/null
+++ b/spec/lib/json_web_token/rsa_token_spec.rb
@@ -0,0 +1,43 @@
+describe JSONWebToken::RSAToken do
+ let(:rsa_key) do
+ OpenSSL::PKey::RSA.new <<-eos.strip_heredoc
+ -----BEGIN RSA PRIVATE KEY-----
+ MIIBOgIBAAJBAMA5sXIBE0HwgIB40iNidN4PGWzOyLQK0bsdOBNgpEXkDlZBvnak
+ OUgAPF+rME4PB0Yl415DabUI40T5UNmlwxcCAwEAAQJAZtY2pSwIFm3JAXIh0cZZ
+ iXcAfiJ+YzuqinUOS+eW2sBCAEzjcARlU/o6sFQgtsOi4FOMczAd1Yx8UDMXMmrw
+ 2QIhAPBgVhJiTF09pdmeFWutCvTJDlFFAQNbrbo2X2x/9WF9AiEAzLgqMKeStSRu
+ H9N16TuDrUoO8R+DPqriCwkKrSHaWyMCIFzMhE4inuKcSywBaLmiG4m3GQzs++Al
+ A6PRG/PSTpQtAiBxtBg6zdf+JC3GH3zt/dA0/10tL4OF2wORfYQghRzyYQIhAL2l
+ 0ZQW+yLIZAGrdBFWYEAa52GZosncmzBNlsoTgwE4
+ -----END RSA PRIVATE KEY-----
+ eos
+ end
+ let(:rsa_token) { described_class.new(nil) }
+ let(:rsa_encoded) { rsa_token.encoded }
+
+ before { allow_any_instance_of(described_class).to receive(:key).and_return(rsa_key) }
+
+ context 'token' do
+ context 'for valid key to be validated' do
+ before { rsa_token['key'] = 'value' }
+
+ subject { JWT.decode(rsa_encoded, rsa_key) }
+
+ it { expect{subject}.to_not raise_error }
+ it { expect(subject.first).to include('key' => 'value') }
+ it do
+ expect(subject.second).to eq(
+ "typ" => "JWT",
+ "alg" => "RS256",
+ "kid" => "OGXY:4TR7:FAVO:WEM2:XXEW:E4FP:TKL7:7ACK:TZAF:D54P:SUIA:P3B2")
+ end
+ end
+
+ context 'for invalid key to raise an exception' do
+ let(:new_key) { OpenSSL::PKey::RSA.generate(512) }
+ subject { JWT.decode(rsa_encoded, new_key) }
+
+ it { expect{subject}.to raise_error(JWT::DecodeError) }
+ end
+ end
+end
diff --git a/spec/lib/json_web_token/token_spec.rb b/spec/lib/json_web_token/token_spec.rb
new file mode 100644
index 00000000000..3d955e4d774
--- /dev/null
+++ b/spec/lib/json_web_token/token_spec.rb
@@ -0,0 +1,18 @@
+describe JSONWebToken::Token do
+ let(:token) { described_class.new }
+
+ context 'custom parameters' do
+ let(:value) { 'value' }
+ before { token[:key] = value }
+
+ it { expect(token[:key]).to eq(value) }
+ it { expect(token.payload).to include(key: value) }
+ end
+
+ context 'embeds default payload' do
+ subject { token.payload }
+ let(:default) { token.send(:default_payload) }
+
+ it { is_expected.to include(default) }
+ end
+end
diff --git a/spec/requests/jwt_controller_spec.rb b/spec/requests/jwt_controller_spec.rb
new file mode 100644
index 00000000000..7bb71365a48
--- /dev/null
+++ b/spec/requests/jwt_controller_spec.rb
@@ -0,0 +1,72 @@
+require 'spec_helper'
+
+describe JwtController do
+ let(:service) { double(execute: {}) }
+ let(:service_class) { double(new: service) }
+ let(:service_name) { 'test' }
+ let(:parameters) { { service: service_name } }
+
+ before { stub_const('JwtController::SERVICES', service_name => service_class) }
+
+ context 'existing service' do
+ subject! { get '/jwt/auth', parameters }
+
+ it { expect(response.status).to eq(200) }
+
+ context 'returning custom http code' do
+ let(:service) { double(execute: { http_status: 505 }) }
+
+ it { expect(response.status).to eq(505) }
+ end
+ end
+
+ context 'when using authorized request' do
+ context 'using CI token' do
+ let(:project) { create(:empty_project, runners_token: 'token', builds_enabled: builds_enabled) }
+ let(:headers) { { authorization: credentials('gitlab_ci_token', project.runners_token) } }
+
+ subject! { get '/jwt/auth', parameters, headers }
+
+ context 'project with enabled CI' do
+ let(:builds_enabled) { true }
+
+ it { expect(service_class).to have_received(:new).with(project, nil, parameters) }
+ end
+
+ context 'project with disabled CI' do
+ let(:builds_enabled) { false }
+
+ it { expect(response.status).to eq(403) }
+ end
+ end
+
+ context 'using User login' do
+ let(:user) { create(:user) }
+ let(:headers) { { authorization: credentials('user', 'password') } }
+
+ before { expect_any_instance_of(Gitlab::Auth).to receive(:find).with('user', 'password').and_return(user) }
+
+ subject! { get '/jwt/auth', parameters, headers }
+
+ it { expect(service_class).to have_received(:new).with(nil, user, parameters) }
+ end
+
+ context 'using invalid login' do
+ let(:headers) { { authorization: credentials('invalid', 'password') } }
+
+ subject! { get '/jwt/auth', parameters, headers }
+
+ it { expect(response.status).to eq(403) }
+ end
+ end
+
+ context 'unknown service' do
+ subject! { get '/jwt/auth', service: 'unknown' }
+
+ it { expect(response.status).to eq(404) }
+ end
+
+ def credentials(login, password)
+ ActionController::HttpAuthentication::Basic.encode_credentials(login, password)
+ end
+end
diff --git a/spec/services/auth/container_registry_authentication_service_spec.rb b/spec/services/auth/container_registry_authentication_service_spec.rb
new file mode 100644
index 00000000000..3ea252ed44f
--- /dev/null
+++ b/spec/services/auth/container_registry_authentication_service_spec.rb
@@ -0,0 +1,216 @@
+require 'spec_helper'
+
+describe Auth::ContainerRegistryAuthenticationService, services: true do
+ let(:current_project) { nil }
+ let(:current_user) { nil }
+ let(:current_params) { {} }
+ let(:rsa_key) { OpenSSL::PKey::RSA.generate(512) }
+ let(:registry_settings) do
+ {
+ enabled: true,
+ issuer: 'rspec',
+ key: nil
+ }
+ end
+ let(:payload) { JWT.decode(subject[:token], rsa_key).first }
+
+ subject { described_class.new(current_project, current_user, current_params).execute }
+
+ before do
+ allow(Gitlab.config.registry).to receive_messages(registry_settings)
+ allow_any_instance_of(JSONWebToken::RSAToken).to receive(:key).and_return(rsa_key)
+ end
+
+ shared_examples 'an authenticated' do
+ it { is_expected.to include(:token) }
+ it { expect(payload).to include('access') }
+ end
+
+ shared_examples 'a accessible' do
+ let(:access) do
+ [{
+ 'type' => 'repository',
+ 'name' => project.path_with_namespace,
+ 'actions' => actions,
+ }]
+ end
+
+ it_behaves_like 'an authenticated'
+ it { expect(payload).to include('access' => access) }
+ end
+
+ shared_examples 'a pullable' do
+ it_behaves_like 'a accessible' do
+ let(:actions) { ['pull'] }
+ end
+ end
+
+ shared_examples 'a pushable' do
+ it_behaves_like 'a accessible' do
+ let(:actions) { ['push'] }
+ end
+ end
+
+ shared_examples 'a pullable and pushable' do
+ it_behaves_like 'a accessible' do
+ let(:actions) { ['pull', 'push'] }
+ end
+ end
+
+ shared_examples 'a forbidden' do
+ it { is_expected.to include(http_status: 403) }
+ it { is_expected.to_not include(:token) }
+ end
+
+ context 'user authorization' do
+ let(:project) { create(:project) }
+ let(:current_user) { create(:user) }
+
+ context 'allow to use offline_token' do
+ let(:current_params) do
+ { offline_token: true }
+ end
+
+ it_behaves_like 'an authenticated'
+ end
+
+ context 'allow developer to push images' do
+ before { project.team << [current_user, :developer] }
+
+ let(:current_params) do
+ { scope: "repository:#{project.path_with_namespace}:push" }
+ end
+
+ it_behaves_like 'a pushable'
+ end
+
+ context 'allow reporter to pull images' do
+ before { project.team << [current_user, :reporter] }
+
+ let(:current_params) do
+ { scope: "repository:#{project.path_with_namespace}:pull" }
+ end
+
+ it_behaves_like 'a pullable'
+ end
+
+ context 'return a least of privileges' do
+ before { project.team << [current_user, :reporter] }
+
+ let(:current_params) do
+ { scope: "repository:#{project.path_with_namespace}:push,pull" }
+ end
+
+ it_behaves_like 'a pullable'
+ end
+
+ context 'disallow guest to pull or push images' do
+ before { project.team << [current_user, :guest] }
+
+ let(:current_params) do
+ { scope: "repository:#{project.path_with_namespace}:pull,push" }
+ end
+
+ it_behaves_like 'a forbidden'
+ end
+ end
+
+ context 'project authorization' do
+ let(:current_project) { create(:empty_project) }
+
+ context 'disallow to use offline_token' do
+ let(:current_params) do
+ { offline_token: true }
+ end
+
+ it_behaves_like 'a forbidden'
+ end
+
+ context 'allow to pull and push images' do
+ let(:current_params) do
+ { scope: "repository:#{current_project.path_with_namespace}:pull,push" }
+ end
+
+ it_behaves_like 'a pullable and pushable' do
+ let(:project) { current_project }
+ end
+ end
+
+ context 'for other projects' do
+ context 'when pulling' do
+ let(:current_params) do
+ { scope: "repository:#{project.path_with_namespace}:pull" }
+ end
+
+ context 'allow for public' do
+ let(:project) { create(:empty_project, :public) }
+ it_behaves_like 'a pullable'
+ end
+
+ context 'disallow for private' do
+ let(:project) { create(:empty_project, :private) }
+ it_behaves_like 'a forbidden'
+ end
+ end
+
+ context 'when pushing' do
+ let(:current_params) do
+ { scope: "repository:#{project.path_with_namespace}:push" }
+ end
+
+ context 'disallow for all' do
+ let(:project) { create(:empty_project, :public) }
+ it_behaves_like 'a forbidden'
+ end
+ end
+ end
+ end
+
+ context 'unauthorized' do
+ context 'disallow to use offline_token' do
+ let(:current_params) do
+ { offline_token: true }
+ end
+
+ it_behaves_like 'a forbidden'
+ end
+
+ context 'for invalid scope' do
+ let(:current_params) do
+ { scope: 'invalid:aa:bb' }
+ end
+
+ it_behaves_like 'a forbidden'
+ end
+
+ context 'for private project' do
+ let(:project) { create(:empty_project, :private) }
+
+ let(:current_params) do
+ { scope: "repository:#{project.path_with_namespace}:pull" }
+ end
+
+ it_behaves_like 'a forbidden'
+ end
+
+ context 'for public project' do
+ let(:project) { create(:empty_project, :public) }
+
+ context 'when pulling and pushing' do
+ let(:current_params) do
+ { scope: "repository:#{project.path_with_namespace}:pull,push" }
+ end
+
+ it_behaves_like 'a pullable'
+ end
+
+ context 'when pushing' do
+ let(:current_params) do
+ { scope: "repository:#{project.path_with_namespace}:push" }
+ end
+
+ it_behaves_like 'a forbidden'
+ end
+ end
+ end
+end