Commit 4698ae8b authored by Douwe Maan's avatar Douwe Maan

Merge branch 'kerberos-spnego-git-http-controller' into 'master'

Support Kerberos SPNEGO in GitHttpController

Fixes https://gitlab.com/gitlab-org/gitlab-ee/issues/734

Should merge cleanly with CE because of https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5037

See merge request !509
parents e644b75e 8d988061
# This file should be identical in GitLab Community Edition and Enterprise Edition
class Projects::GitHttpController < Projects::ApplicationController
include ActionController::HttpAuthentication::Basic
include KerberosSpnegoHelper
attr_reader :user
# Git clients will not know what authenticity token to send along
......@@ -40,9 +45,12 @@ class Projects::GitHttpController < Projects::ApplicationController
private
def authenticate_user
return if project && project.public? && upload_pack?
if project && project.public? && upload_pack?
return # Allow access
end
authenticate_or_request_with_http_basic do |login, password|
if allow_basic_auth? && basic_auth_provided?
login, password = user_name_and_password(request)
auth_result = Gitlab::Auth.find_for_git_client(login, password, project: project, ip: request.ip)
if auth_result.type == :ci && upload_pack?
......@@ -53,8 +61,31 @@ class Projects::GitHttpController < Projects::ApplicationController
@user = auth_result.user
end
ci? || user
if ci? || user
return # Allow access
end
elsif allow_kerberos_spnego_auth? && spnego_provided?
@user = find_kerberos_user
if user
send_final_spnego_response
return # Allow access
end
end
send_challenges
render plain: "HTTP Basic: Access denied\n", status: 401
end
def basic_auth_provided?
has_basic_credentials?(request)
end
def send_challenges
challenges = []
challenges << 'Basic realm="GitLab"' if allow_basic_auth?
challenges << spnego_challenge if allow_kerberos_spnego_auth?
headers['Www-Authenticate'] = challenges.join("\n") if challenges.any?
end
def ensure_project_found!
......@@ -120,7 +151,7 @@ class Projects::GitHttpController < Projects::ApplicationController
end
def render_not_found
render text: 'Not Found', status: :not_found
render plain: 'Not Found', status: :not_found
end
def ci?
......
module KerberosSpnegoHelper
include ActionController::HttpAuthentication::Basic
attr_reader :spnego_response_token
def allow_basic_auth?
if Gitlab.config.kerberos.enabled && Gitlab.config.kerberos.use_dedicated_port
!request_uses_kerberos_dedicated_port?
else
true
end
end
def allow_kerberos_spnego_auth?
return false unless Gitlab.config.kerberos.enabled
if Gitlab.config.kerberos.use_dedicated_port
request_uses_kerberos_dedicated_port?
else
true
end
end
def request_uses_kerberos_dedicated_port?
request.env['SERVER_PORT'] == Gitlab.config.kerberos.port.to_s
end
def spnego_challenge
if spnego_response_token
"Negotiate #{::Base64.strict_encode64(spnego_response_token)}"
else
'Negotiate'
end
end
def spnego_provided?
request.authorization.present? && (auth_scheme(request) == 'Negotiate')
end
def send_final_spnego_response
headers['Www-Authenticate'] = spnego_challenge if spnego_response_token
end
def find_kerberos_user
spnego_token = Base64.strict_decode64(auth_param(request))
krb_principal = spnego_credentials!(spnego_token)
return unless krb_principal
identity = ::Identity.find_by(provider: :kerberos, extern_uid: krb_principal)
identity.user if identity
end
# The Kerberos backend will translate spnego_token into a Kerberos
# principal and/or provide a value for @spnego_response_token.
def spnego_credentials!(spnego_token)
require 'gssapi'
gss = GSSAPI::Simple.new(nil, nil, Gitlab.config.kerberos.keytab)
# the GSSAPI::Simple constructor transforms a nil service name into a default value, so
# pass service name to acquire_credentials explicitly to support the special meaning of nil
gss_service_name =
if Gitlab.config.kerberos.service_principal_name.present?
gss.import_name(Gitlab.config.kerberos.service_principal_name)
else
nil # accept any valid service principal name from keytab
end
gss.acquire_credentials(gss_service_name) # grab credentials from keytab
# Decode token
gss_result = gss.accept_context(spnego_token)
# gss_result will be 'true' if nothing has to be returned to the client
@spnego_response_token = gss_result if gss_result && gss_result != true
# Return user principal name if authentication succeeded
gss.display_name
rescue GSSAPI::GssApiError => ex
Rails.logger.error "#{self.class.name}: failed to process Negotiate/Kerberos authentication: #{ex.message}"
false
end
end
......@@ -93,8 +93,8 @@ Rails.application.routes.draw do
# Health check
get 'health_check(/:checks)' => 'health_check#index', as: :health_check
# Enable Grack support
mount Grack::AuthSpawner, at: '/', constraints: lambda { |request| /[-\/\w\.]+\.git\//.match(request.path_info) }, via: [:get, :post, :put]
# Enable Grack support (for LFS only)
mount Grack::AuthSpawner, at: '/', constraints: lambda { |request| /[-\/\w\.]+\.git\/(info\/lfs|gitlab-lfs)/.match(request.path_info) }, via: [:get, :post, :put]
# Help
get 'help' => 'help#index'
......@@ -527,6 +527,13 @@ Rails.application.routes.draw do
end
scope module: :projects do
# Git HTTP clients ('git clone' etc.)
scope constraints: { id: /.+\.git/, format: nil } do
get '/info/refs', to: 'git_http#info_refs'
post '/git-upload-pack', to: 'git_http#git_upload_pack'
post '/git-receive-pack', to: 'git_http#git_receive_pack'
end
# Allow /info/refs, /info/refs?service=git-upload-pack, and
# /info/refs?service=git-receive-pack, but nothing else.
#
......
require_relative 'shell_env'
module Grack
class AuthSpawner
def self.call(env)
......@@ -41,10 +39,7 @@ module Grack
lfs_response = Gitlab::Lfs::Router.new(project, @user, @ci, @request).try_call
return lfs_response unless lfs_response.nil?
if project && authorized_request?
# Tell gitlab-workhorse the request is OK, and what the GL_ID is
render_grack_auth_ok
elsif @user.nil? && !@ci
if @user.nil? && !@ci
unauthorized
else
render_not_found
......@@ -119,11 +114,6 @@ module Grack
@user = authenticate_user(login, password)
end
if @user
Gitlab::ShellEnv.set_env(@user)
@env['REMOTE_USER'] = @auth.username
end
end
def ci_request?(login, password)
......@@ -252,24 +242,6 @@ module Grack
end
end
def render_grack_auth_ok
repo_path =
if @request.path_info =~ /^([\w\.\/-]+)\.wiki\.git/
ProjectWiki.new(project).repository.path_to_repo
else
project.repository.path_to_repo
end
[
200,
{ "Content-Type" => "application/json" },
[JSON.dump({
'GL_ID' => Gitlab::ShellEnv.gl_id(@user),
'RepoPath' => repo_path,
})]
]
end
def render_not_found
[404, { "Content-Type" => "text/plain" }, ["Not Found"]]
end
......
module Gitlab
# This module provide 2 methods
# to set specific ENV variables for GitLab Shell
module ShellEnv
extend self
def set_env(user)
# Set GL_ID env variable
if user
ENV['GL_ID'] = gl_id(user)
end
end
def reset_env
# Reset GL_ID env variable
ENV['GL_ID'] = nil
end
def gl_id(user)
if user.present?
"user-#{user.id}"
else
# This empty string is used in the render_grack_auth_ok method
""
end
end
end
end
require "spec_helper"
describe Grack::Auth, lib: true do
let(:user) { create(:user) }
let(:project) { create(:project) }
let(:app) { lambda { |env| [200, {}, "Success!"] } }
let(:env) do
{
'rack.input' => '',
'REQUEST_METHOD' => 'GET',
'QUERY_STRING' => 'service=git-upload-pack'
}
end
let(:status) { Grack::AuthSpawner::call(env).first }
describe "#call" do
context "when the project doesn't exist" do
before do
env["PATH_INFO"] = "doesnt/exist.git"
end
context "when no authentication is provided" do
it "responds with status 401" do
expect(status).to eq(401)
end
end
context "when username and password are provided" do
context "when authentication fails" do
before do
env["HTTP_AUTHORIZATION"] = ActionController::HttpAuthentication::Basic.encode_credentials(user.username, "nope")
end
it "responds with status 401" do
expect(status).to eq(401)
end
end
context "when authentication succeeds" do
before do
env["HTTP_AUTHORIZATION"] = ActionController::HttpAuthentication::Basic.encode_credentials(user.username, user.password)
end
it "responds with status 404" do
expect(status).to eq(404)
end
end
end
end
context "when the Wiki for a project exists" do
before do
@wiki = ProjectWiki.new(project)
env["PATH_INFO"] = "#{@wiki.repository.path_with_namespace}.git/info/refs"
project.update_attribute(:visibility_level, Project::PUBLIC)
end
it "responds with the right project" do
response = Grack::AuthSpawner::call(env)
json_body = ActiveSupport::JSON.decode(response[2][0])
expect(response.first).to eq(200)
expect(json_body['RepoPath']).to include(@wiki.repository.path_with_namespace)
end
end
context "when the project exists" do
before do
env["PATH_INFO"] = project.path_with_namespace + ".git"
end
context "when the project is public" do
before do
project.update_attribute(:visibility_level, Project::PUBLIC)
end
it "responds with status 200" do
expect(status).to eq(200)
end
end
context "when the project is private" do
before do
project.update_attribute(:visibility_level, Project::PRIVATE)
end
context "when no authentication is provided" do
it "responds with status 401" do
expect(status).to eq(401)
end
end
context "when Kerberos token is provided" do
before do
allow_any_instance_of(Grack::Auth).to receive(:allow_kerberos_auth?).and_return(true)
env["HTTP_AUTHORIZATION"] = "Negotiate #{::Base64.strict_encode64('opaque_request_token')}"
end
shared_examples "RFC4559 compliance" do
it "complies with RFC4559" do
allow_any_instance_of(Grack::Auth::Request).to receive(:spnego_response_token).and_return("opaque_response_token")
headers = Grack::AuthSpawner::call(env)[1]
expect(headers['WWW-Authenticate'].split("\n")).to include("Negotiate #{::Base64.strict_encode64('opaque_response_token')}")
end
end
context "when authentication fails because of invalid Kerberos token" do
before do
allow_any_instance_of(Grack::Auth::Request).to receive(:spnego_credentials!).and_return(nil)
end
it "responds with status 401" do
expect(status).to eq(401)
end
end
context "when authentication fails because of unknown Kerberos identity" do
before do
allow_any_instance_of(Grack::Auth::Request).to receive(:spnego_credentials!).and_return("mylogin@FOO.COM")
end
it "responds with status 401" do
expect(status).to eq(401)
end
end
context "when authentication succeeds" do
before do
allow_any_instance_of(Grack::Auth::Request).to receive(:spnego_credentials!).and_return("mylogin@FOO.COM")
user.identities.build(provider: "kerberos", extern_uid: "mylogin@FOO.COM").save
end
context "when the user has access to the project" do
before do
project.team << [user, :master]
end
context "when the user is blocked" do
before do
user.block
project.team << [user, :master]
end
it "responds with status 404" do
expect(status).to eq(404)
end
end
context "when the user isn't blocked" do
it "responds with status 200" do
expect(status).to eq(200)
end
end
include_examples "RFC4559 compliance"
end
context "when the user doesn't have access to the project" do
it "responds with status 404" do
expect(status).to eq(404)
end
include_examples "RFC4559 compliance"
end
end
end
context "when username and password are provided" do
context "when authentication fails" do
before do
env["HTTP_AUTHORIZATION"] = ActionController::HttpAuthentication::Basic.encode_credentials(user.username, "nope")
end
it "responds with status 401" do
expect(status).to eq(401)
end
context "when the user is IP banned" do
before do
expect(Rack::Attack::Allow2Ban).to receive(:filter).and_return(true)
allow_any_instance_of(Rack::Request).to receive(:ip).and_return('1.2.3.4')
end
it "responds with status 401" do
expect(status).to eq(401)
end
end
end
context "when authentication succeeds" do
before do
env["HTTP_AUTHORIZATION"] = ActionController::HttpAuthentication::Basic.encode_credentials(user.username, user.password)
end
context "when the user has access to the project" do
before do
project.team << [user, :master]
end
context "when the user is blocked" do
before do
user.block
project.team << [user, :master]
end
it "responds with status 404" do
expect(status).to eq(404)
end
end
context "when the user isn't blocked" do
before do
expect(Rack::Attack::Allow2Ban).to receive(:reset)
end
it "responds with status 200" do
expect(status).to eq(200)
end
end
context "when blank password attempts follow a valid login" do
let(:options) { Gitlab.config.rack_attack.git_basic_auth }
let(:maxretry) { options[:maxretry] - 1 }
let(:ip) { '1.2.3.4' }
before do
allow_any_instance_of(Rack::Request).to receive(:ip).and_return(ip)
Rack::Attack::Allow2Ban.reset(ip, options)
end
after do
Rack::Attack::Allow2Ban.reset(ip, options)
end
def attempt_login(include_password)
password = include_password ? user.password : ""
env["HTTP_AUTHORIZATION"] = ActionController::HttpAuthentication::Basic.encode_credentials(user.username, password)
Grack::AuthSpawner::call(env).first
end
it "repeated attempts followed by successful attempt" do
maxretry.times.each do
expect(attempt_login(false)).to eq(401)
end
expect(attempt_login(true)).to eq(200)
expect(Rack::Attack::Allow2Ban.banned?(ip)).to be_falsey
maxretry.times.each do
expect(attempt_login(false)).to eq(401)
end
end
end
end
context "when the user doesn't have access to the project" do
it "responds with status 404" do
expect(status).to eq(404)
end
end
end
end
context "when a gitlab ci token is provided" do
let(:token) { "123" }
let(:project) { FactoryGirl.create :empty_project }
before do
project.update_attributes(runners_token: token, builds_enabled: true)
env["HTTP_AUTHORIZATION"] = ActionController::HttpAuthentication::Basic.encode_credentials("gitlab-ci-token", token)
end
it "responds with status 200" do
expect(status).to eq(200)
end
end
end
end
end
end
......@@ -97,7 +97,7 @@ describe 'Git HTTP requests', lib: true do
allow(Gitlab.config.gitlab_shell).to receive(:upload_pack).and_return(false)
download(path, {}) do |response|
expect(response).to have_http_status(401)
expect(response).to have_http_status(404)
end
end
end
......@@ -122,6 +122,94 @@ describe 'Git HTTP requests', lib: true do
end
end
context "when Kerberos token is provided" do
let(:env) { { spnego_request_token: 'opaque_request_token' } }
before do
allow_any_instance_of(Projects::GitHttpController).to receive(:allow_kerberos_spnego_auth?).and_return(true)
end
context "when authentication fails because of invalid Kerberos token" do
before do
allow_any_instance_of(Projects::GitHttpController).to receive(:spnego_credentials!).and_return(nil)
end
it "responds with status 401" do
download(path, env) do |response|
expect(response.status).to eq(401)
end
end
end
context "when authentication fails because of unknown Kerberos identity" do
before do
allow_any_instance_of(Projects::GitHttpController).to receive(:spnego_credentials!).and_return("mylogin@FOO.COM")
end
it "responds with status 401" do
download(path, env) do |response|
expect(response.status).to eq(401)
end
end
end
context "when authentication succeeds" do
before do
allow_any_instance_of(Projects::GitHttpController).to receive(:spnego_credentials!).and_return("mylogin@FOO.COM")
user.identities.create!(provider: "kerberos", extern_uid: "mylogin@FOO.COM")
end
context "when the user has access to the project" do
before do
project.team << [user, :master]
end
context "when the user is blocked" do
before do
user.block
project.team << [user, :master]
end
it "responds with status 404" do
download(path, env) do |response|
expect(response.status).to eq(404)
end
end
end
context "when the user isn't blocked" do
it "responds with status 200" do
download(path, env) do |response|
expect(response.status).to eq(200)
end
end
end
it "complies with RFC4559" do
allow_any_instance_of(Projects::GitHttpController).to receive(:spnego_response_token).and_return("opaque_response_token")
download(path, env) do |response|
expect(response.headers['WWW-Authenticate'].split("\n")).to include("Negotiate #{::Base64.strict_encode64('opaque_response_token')}")
end
end
end
context "when the user doesn't have access to the project" do
it "responds with status 404" do
download(path, env) do |response|
expect(response.status).to eq(404)
end
end
it "complies with RFC4559" do
allow_any_instance_of(Projects::GitHttpController).to receive(:spnego_response_token).and_return("opaque_response_token")
download(path, env) do |response|
expect(response.headers['WWW-Authenticate'].split("\n")).to include("Negotiate #{::Base64.strict_encode64('opaque_response_token')}")
end
end
end
end
end
context "when username and password are provided" do
let(:env) { { user: user.username, password: 'nope' } }
......@@ -350,23 +438,23 @@ describe 'Git HTTP requests', lib: true do
end
def clone_get(project, options={})
get "/#{project}/info/refs", { service: 'git-upload-pack' }, auth_env(*options.values_at(:user, :password))
get "/#{project}/info/refs", { service: 'git-upload-pack' }, auth_env(*options.values_at(:user, :password, :spnego_request_token))
end
def clone_post(project, options={})
post "/#{project}/git-upload-pack", {}, auth_env(*options.values_at(:user, :password))
post "/#{project}/git-upload-pack", {}, auth_env(*options.values_at(:user, :password, :spnego_request_token))
end
def push_get(project, options={})
get "/#{project}/info/refs", { service: 'git-receive-pack' }, auth_env(*options.values_at(:user, :password))
get "/#{project}/info/refs", { service: 'git-receive-pack' }, auth_env(*options.values_at(:user, :password, :spnego_request_token))
end
def push_post(project, options={})
post "/#{project}/git-receive-pack", {}, auth_env(*options.values_at(:user, :password))
post "/#{project}/git-receive-pack", {}, auth_env(*options.values_at(:user, :password, :spnego_request_token))
end
def download(project, user: nil, password: nil)
args = [project, { user: user, password: password }]
def download(project, user: nil, password: nil, spnego_request_token: nil)
args = [project, { user: user, password: password, spnego_request_token: spnego_request_token }]
clone_get(*args)
yield response
......@@ -375,8 +463,8 @@ describe 'Git HTTP requests', lib: true do
yield response
end
def upload(project, user: nil, password: nil)
args = [project, { user: user, password: password }]
def upload(project, user: nil, password: nil, spnego_request_token: nil)
args = [project, { user: user, password: password, spnego_request_token: spnego_request_token }]
push_get(*args)
yield response
......@@ -385,11 +473,14 @@ describe 'Git HTTP requests', lib: true do
yield response
end
def auth_env(user, password)
def auth_env(user, password, spnego_request_token)
env = {}
if user && password
{ 'HTTP_AUTHORIZATION' => ActionController::HttpAuthentication::Basic.encode_credentials(user, password) }
else
{}
env['HTTP_AUTHORIZATION'] = ActionController::HttpAuthentication::Basic.encode_credentials(user, password)
elsif spnego_request_token
env['HTTP_AUTHORIZATION'] = "Negotiate #{::Base64.strict_encode64('opaque_request_token')}"
end
env
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment