diff options
author | Douwe Maan <douwe@gitlab.com> | 2016-05-16 20:20:31 +0300 |
---|---|---|
committer | Douwe Maan <douwe@gitlab.com> | 2016-05-16 20:20:31 +0300 |
commit | 59e62fc4866215eda1d291a240b6c4faaee2e961 (patch) | |
tree | 9db48ca4c378bb900780c8dbba6f9a6333468799 | |
parent | 5dd013f1452e1a521dfa0db99ae9910b1dc27b0e (diff) | |
parent | 72577033888a77eeb2f4d362c2ae2331c1cbf6d7 (diff) |
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
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) @@ -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 |