summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Gemfile1
-rw-r--r--Gemfile.lock2
-rw-r--r--app/controllers/jwt_controller.rb173
-rw-r--r--config/routes.rb3
4 files changed, 179 insertions, 0 deletions
diff --git a/Gemfile b/Gemfile
index 512c6babd7e..0301f6fe062 100644
--- a/Gemfile
+++ b/Gemfile
@@ -225,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 2b578429b3c..2b1cfdc9bb2 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -74,6 +74,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)
@@ -897,6 +898,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)
diff --git a/app/controllers/jwt_controller.rb b/app/controllers/jwt_controller.rb
new file mode 100644
index 00000000000..7e70c70c89c
--- /dev/null
+++ b/app/controllers/jwt_controller.rb
@@ -0,0 +1,173 @@
+class JwtController < ApplicationController
+ skip_before_action :authenticate_user!
+ skip_before_action :verify_authenticity_token
+
+ def auth
+ @authenticated = authenticate_with_http_basic do |login, password|
+ @ci_project = ci_project(login, password)
+ @user = authenticate_user(login, password) unless @ci_project
+ end
+
+ unless @authenticated
+ return render_403 if has_basic_credentials?
+ end
+
+ case params[:service]
+ when 'docker'
+ docker_token_auth(params[:scope], params[:offline_token])
+ else
+ return render_404
+ end
+ end
+
+ private
+
+ def render_400
+ head :invalid_request
+ end
+
+ def render_404
+ head :not_found
+ end
+
+ def render_403
+ head :forbidden
+ end
+
+ def docker_token_auth(scope, offline_token)
+ payload = {
+ aud: params[:service],
+ sub: @user.try(:username)
+ }
+
+ if offline_token
+ return render_403 unless @user
+ elsif scope
+ access = process_access(scope)
+ return render_404 unless access
+ payload[:access] = [access]
+ end
+
+ render json: { token: encode(payload) }
+ end
+
+ def ci_project(login, password)
+ matched_login = /(?<s>^[a-zA-Z]*-ci)-token$/.match(login)
+
+ if matched_login.present?
+ underscored_service = matched_login['s'].underscore
+
+ if underscored_service == 'gitlab_ci'
+ Project.find_by(builds_enabled: true, runners_token: password)
+ end
+ end
+ end
+
+ def authenticate_user(login, password)
+ 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"
+ end
+ end
+ end
+
+ user
+ end
+
+ def process_access(scope)
+ type, name, actions = scope.split(':', 3)
+ actions = actions.split(',')
+
+ case type
+ when 'repository'
+ process_repository_access(type, name, actions)
+ end
+ end
+
+ def process_repository_access(type, name, actions)
+ project = Project.find_with_namespace(name)
+ return unless project
+
+ actions = actions.select do |action|
+ can_access?(project, action)
+ end
+
+ { type: 'repository', name: name, actions: actions } if actions
+ end
+
+ def default_payload
+ {
+ aud: 'docker',
+ sub: @user.try(:username),
+ aud: params[:service],
+ }
+ end
+
+ def private_key
+ @private_key ||= OpenSSL::PKey::RSA.new File.read Gitlab.config.registry.key
+ end
+
+ def encode(payload)
+ issued_at = Time.now
+ payload = payload.merge(
+ iss: Gitlab.config.registry.issuer,
+ iat: issued_at.to_i,
+ nbf: issued_at.to_i - 5.seconds.to_i,
+ exp: issued_at.to_i + 60.minutes.to_i,
+ jti: SecureRandom.uuid,
+ )
+ headers = {
+ kid: kid(private_key)
+ }
+ JWT.encode(payload, private_key, 'RS256', headers)
+ end
+
+ def can_access?(project, action)
+ case action
+ when 'pull'
+ project == @ci_project || can?(@user, :download_code, project)
+ when 'push'
+ project == @ci_project || can?(@user, :push_code, project)
+ else
+ false
+ end
+ end
+
+ def kid(private_key)
+ sha256 = Digest::SHA256.new
+ sha256.update(private_key.public_key.to_der)
+ payload = StringIO.new(sha256.digest).read(30)
+ Base32.encode(payload).split('').each_slice(4).each_with_object([]) do |slice, mem|
+ mem << slice.join
+ end.join(':')
+ end
+end
diff --git a/config/routes.rb b/config/routes.rb
index adf4bb18b3c..5b48819dd9d 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -63,6 +63,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'