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
This commit is contained in:
commit
59e62fc486
24 changed files with 677 additions and 10 deletions
|
@ -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)
|
||||
|
|
2
Gemfile
2
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'
|
||||
|
|
|
@ -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)
|
||||
|
|
87
app/controllers/jwt_controller.rb
Normal file
87
app/controllers/jwt_controller.rb
Normal file
|
@ -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
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
# ==========================
|
||||
|
|
|
@ -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']
|
||||
|
@ -242,6 +243,16 @@ Settings.artifacts['enabled'] = true if Settings.artifacts['enabled'].nil?
|
|||
Settings.artifacts['path'] = File.expand_path(Settings.artifacts['path'] || File.join(Settings.shared['path'], "artifacts"), Rails.root)
|
||||
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
|
||||
#
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
class AddImagesEnabledForProject < ActiveRecord::Migration
|
||||
def change
|
||||
add_column :projects, :container_registry_enabled, :boolean
|
||||
end
|
||||
end
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 | | | | ✓ | ✓ |
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
42
lib/json_web_token/rsa_token.rb
Normal file
42
lib/json_web_token/rsa_token.rb
Normal file
|
@ -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
|
46
lib/json_web_token/token.rb
Normal file
46
lib/json_web_token/token.rb
Normal file
|
@ -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
|
43
spec/lib/json_web_token/rsa_token_spec.rb
Normal file
43
spec/lib/json_web_token/rsa_token_spec.rb
Normal file
|
@ -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
|
18
spec/lib/json_web_token/token_spec.rb
Normal file
18
spec/lib/json_web_token/token_spec.rb
Normal file
|
@ -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
|
72
spec/requests/jwt_controller_spec.rb
Normal file
72
spec/requests/jwt_controller_spec.rb
Normal file
|
@ -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
|
|
@ -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
|
Loading…
Reference in a new issue