From 105017c3084c60e45f4bac85a76da78f39e6433f Mon Sep 17 00:00:00 2001
From: Kamil Trzcinski <ayufan@ayufan.eu>
Date: Mon, 2 May 2016 13:29:17 +0200
Subject: [PATCH] Added JWT controller

---
 Gemfile                           |   1 +
 Gemfile.lock                      |   2 +
 app/controllers/jwt_controller.rb | 173 ++++++++++++++++++++++++++++++
 config/routes.rb                  |   3 +
 4 files changed, 179 insertions(+)
 create mode 100644 app/controllers/jwt_controller.rb

diff --git a/Gemfile b/Gemfile
index 512c6babd7e6c..0301f6fe06222 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 2b578429b3c37..2b1cfdc9bb2a4 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 0000000000000..7e70c70c89c0d
--- /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 adf4bb18b3cc5..5b48819dd9d18 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -63,6 +63,9 @@
   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'
-- 
GitLab