diff --git a/app/controllers/jwt_controller.rb b/app/controllers/jwt_controller.rb index 7e70c70c89c..2a92627cb1b 100644 --- a/app/controllers/jwt_controller.rb +++ b/app/controllers/jwt_controller.rb @@ -2,6 +2,10 @@ class JwtController < ApplicationController skip_before_action :authenticate_user! skip_before_action :verify_authenticity_token + SERVICES = { + 'docker' => Jwt::DockerAuthenticationService, + } + def auth @authenticated = authenticate_with_http_basic do |login, password| @ci_project = ci_project(login, password) @@ -9,46 +13,22 @@ class JwtController < ApplicationController end unless @authenticated - return render_403 if has_basic_credentials? + head :forbidden if ActionController::HttpAuthentication::Basic.has_basic_credentials?(request) end - case params[:service] - when 'docker' - docker_token_auth(params[:scope], params[:offline_token]) - else - return render_404 - end + service = SERVICES[params[:service]] + head :not_found unless service + + result = service.new(@ci_project, @user, auth_params).execute + return head result[:http_status] if result[:http_status] + + render json: result 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) } + def auth_params + params.permit(:service, :scope, :offline_token, :account, :client_id) end def ci_project(login, password) @@ -102,72 +82,4 @@ class JwtController < ApplicationController 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/app/services/jwt/docker_authentication_service.rb b/app/services/jwt/docker_authentication_service.rb new file mode 100644 index 00000000000..ce28085e5d6 --- /dev/null +++ b/app/services/jwt/docker_authentication_service.rb @@ -0,0 +1,65 @@ +module Jwt + class DockerAuthenticationService < BaseService + def execute + if params[:offline_token] + return error('forbidden', 403) unless current_user + end + + { token: token.encoded } + end + + private + + def token + token = ::Jwt::RSAToken.new(registry.key) + token.issuer = registry.issuer + token.audience = params[:service] + token.subject = current_user.try(:username) + token[:access] = access + token + end + + def access + return unless params[:scope] + + scope = process_scope(params[:scope]) + [scope].compact + end + + def process_scope(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) + current_project = Project.find_with_namespace(name) + return unless current_project + + actions = actions.select do |action| + can_access?(current_project, action) + end + + { type: type, name: name, actions: actions } if actions + end + + def can_access?(current_project, action) + case action + when 'pull' + current_project == project || can?(current_user, :download_code, current_project) + when 'push' + current_project == project || can?(current_user, :push_code, current_project) + else + false + end + end + + def registry + Gitlab.config.registry + end + end +end diff --git a/lib/jwt/rsa_token.rb b/lib/jwt/rsa_token.rb new file mode 100644 index 00000000000..cc265e3b31a --- /dev/null +++ b/lib/jwt/rsa_token.rb @@ -0,0 +1,36 @@ +module Jwt + 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 kid + sha256 = Digest::SHA256.new + sha256.update(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 +end diff --git a/lib/jwt/token.rb b/lib/jwt/token.rb new file mode 100644 index 00000000000..38cbc8004e7 --- /dev/null +++ b/lib/jwt/token.rb @@ -0,0 +1,48 @@ +module Jwt + class Token + attr_accessor :issuer, :subject, :audience, :id + attr_accessor :issued_at, :not_before, :expire_time + + def initialize + @payload = {} + @id = SecureRandom.uuid + @issued_at = Time.now + @not_before = issued_at - 5.seconds + @expire_time = issued_at + 1.minute + end + + def [](key) + @payload[key] + end + + def []=(key, value) + @payload[key] = value + end + + def encoded + raise NotImplementedError + end + + def payload + @payload.merge(default_payload) + end + + def to_json + payload.to_json + 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 \ No newline at end of file