Commit 8bd15968 authored by Douwe Maan's avatar Douwe Maan

Merge branch '1552-kerberos-ldap-auto-link' into 'master'

Automatically link Kerberos users and LDAP people

Closes #1552

See merge request !2405
parents b04e2489 d062a5cc
---
title: Automatically link kerberos users to LDAP people
merge_request: 2405
author:
...@@ -105,6 +105,24 @@ user associated with the email, which is built from the Kerberos username and ...@@ -105,6 +105,24 @@ user associated with the email, which is built from the Kerberos username and
realm. User accounts will be created automatically when authentication was realm. User accounts will be created automatically when authentication was
successful. successful.
## Linking Kerberos and LDAP accounts together
If your users log in with Kerberos, but you also have [LDAP integration](../administration/auth/ldap.md)
enabled, then your users will be automatically linked to their LDAP accounts on
first login. For this to work, some prerequisites must be met:
The Kerberos username must match the LDAP user's UID. You can choose which LDAP
attribute is used as the UID in GitLab's [LDAP configuration](../administration/auth/ldap.md#configuration)
but for Active Directory, this should be `sAMAccountName`.
The Kerberos realm must match the domain part of the LDAP user's Distinguished
Name. For instance, if the Kerberos realm is `AD.EXAMPLE.COM`, then the LDAP
user's Distinguished Name should end in `dc=ad,dc=example,dc=com`.
Taken together, these rules mean that linking will only work if your users'
Kerberos usernames are of the form `foo@AD.EXAMPLE.COM` and their
LDAP Distinguished Names look like `sAMAccountName=foo,dc=ad,dc=example,dc=com`.
## HTTP Git access ## HTTP Git access
A linked Kerberos account enables you to `git pull` and `git push` using your A linked Kerberos account enables you to `git pull` and `git push` using your
......
require 'net/ldap/dn'
module EE module EE
module Gitlab module Gitlab
module LDAP module LDAP
...@@ -15,6 +17,31 @@ module EE ...@@ -15,6 +17,31 @@ module EE
nil nil
end end
def find_by_kerberos_principal(principal, adapter)
uid, domain = principal.split('@', 2)
return nil unless uid && domain
# In multi-forest setups, there may be several users with matching
# uids but differing DNs, so skip adapters configured to connect to
# non-matching domains
return unless domain.casecmp(domain_from_dn(adapter.config.base)) == 0
find_by_uid(uid, adapter)
end
# Extracts the rightmost unbroken set of domain components from an
# LDAP DN and constructs a domain name from them
def domain_from_dn(dn)
dn_components = []
Net::LDAP::DN.new(dn).each_pair { |name, value| dn_components << { name: name, value: value } }
dn_components
.reverse
.take_while { |rdn| rdn[:name].casecmp('DC').zero? } # Domain Component
.map { |rdn| rdn[:value] }
.reverse
.join('.')
end
end end
def ssh_keys def ssh_keys
...@@ -27,23 +54,12 @@ module EE ...@@ -27,23 +54,12 @@ module EE
end end
end end
# We assume that the Kerberos username matches the configured uid
# attribute in LDAP. For Active Directory, this is `sAMAccountName`
def kerberos_principal def kerberos_principal
# The following is only meaningful for Active Directory return nil unless uid
return unless entry.respond_to?(:sAMAccountName)
entry[:sAMAccountName].first + '@' + windows_domain_name.upcase
end
def windows_domain_name uid + '@' + self.class.domain_from_dn(dn).upcase
# The following is only meaningful for Active Directory
require 'net/ldap/dn'
dn_components = []
Net::LDAP::DN.new(dn).each_pair { |name, value| dn_components << { name: name, value: value } }
dn_components
.reverse
.take_while { |rdn| rdn[:name].casecmp('DC').zero? } # Domain Component
.map { |rdn| rdn[:value] }
.reverse
.join('.')
end end
def memberof def memberof
......
module EE
module Gitlab
module OAuth
module AuthHash
def kerberos_default_realm
::Gitlab::Kerberos::Authentication.kerberos_default_realm
end
def uid
return @ee_uid if defined?(@ee_uid)
ee_uid = super
# For Kerberos, usernames `principal` and `principal@DEFAULT.REALM`
# are equivalent and may be used indifferently, but omniauth_kerberos
# does not normalize them as of version 0.3.0, so add the default
# realm ourselves if appropriate
if provider == 'kerberos' && ee_uid.present?
ee_uid += "@#{kerberos_default_realm}" unless ee_uid.include?('@')
end
@ee_uid = ee_uid
end
end
end
end
end
module EE
module Gitlab
module OAuth
module User
protected
def find_ldap_person(auth_hash, adapter)
if auth_hash.provider == 'kerberos'
::Gitlab::LDAP::Person.find_by_kerberos_principal(auth_hash.uid, adapter)
else
super
end
end
end
end
end
end
...@@ -3,26 +3,15 @@ ...@@ -3,26 +3,15 @@
module Gitlab module Gitlab
module OAuth module OAuth
class AuthHash class AuthHash
prepend ::EE::Gitlab::OAuth::AuthHash
attr_reader :auth_hash attr_reader :auth_hash
def initialize(auth_hash) def initialize(auth_hash)
@auth_hash = auth_hash @auth_hash = auth_hash
end end
def kerberos_default_realm
Gitlab::Kerberos::Authentication.kerberos_default_realm
end
def normalized_uid
return auth_hash.uid.to_s unless provider == 'kerberos'
# For Kerberos, usernames `principal` and `principal@DEFAULT.REALM` are equivalent and
# may be used indifferently, but omniauth_kerberos does not normalize them as of version 0.3.0.
# Normalize here the uid to always have the canonical Kerberos principal name with realm.
return auth_hash.uid if auth_hash.uid.include?("@")
auth_hash.uid + "@" + kerberos_default_realm
end
def uid def uid
@uid ||= Gitlab::Utils.force_utf8(normalized_uid) @uid ||= Gitlab::Utils.force_utf8(auth_hash.uid.to_s)
end end
def provider def provider
......
...@@ -8,6 +8,8 @@ module Gitlab ...@@ -8,6 +8,8 @@ module Gitlab
SignupDisabledError = Class.new(StandardError) SignupDisabledError = Class.new(StandardError)
class User class User
prepend ::EE::Gitlab::OAuth::User
attr_accessor :auth_hash, :gl_user attr_accessor :auth_hash, :gl_user
def initialize(auth_hash) def initialize(auth_hash)
...@@ -101,14 +103,18 @@ module Gitlab ...@@ -101,14 +103,18 @@ module Gitlab
# Look for a corresponding person with same uid in any of the configured LDAP providers # Look for a corresponding person with same uid in any of the configured LDAP providers
Gitlab::LDAP::Config.providers.each do |provider| Gitlab::LDAP::Config.providers.each do |provider|
adapter = Gitlab::LDAP::Adapter.new(provider) adapter = Gitlab::LDAP::Adapter.new(provider)
@ldap_person = Gitlab::LDAP::Person.find_by_uid(auth_hash.uid, adapter) @ldap_person = find_ldap_person(auth_hash, adapter)
# The `uid` might actually be a DN. Try it next.
@ldap_person ||= Gitlab::LDAP::Person.find_by_dn(auth_hash.uid, adapter)
break if @ldap_person break if @ldap_person
end end
@ldap_person @ldap_person
end end
def find_ldap_person(auth_hash, adapter)
by_uid = Gitlab::LDAP::Person.find_by_uid(auth_hash.uid, adapter)
# The `uid` might actually be a DN. Try it next.
by_uid || Gitlab::LDAP::Person.find_by_dn(auth_hash.uid, adapter)
end
def ldap_config def ldap_config
Gitlab::LDAP::Config.new(ldap_person.provider) if ldap_person Gitlab::LDAP::Config.new(ldap_person.provider) if ldap_person
end end
......
...@@ -29,32 +29,56 @@ describe Gitlab::LDAP::Person do ...@@ -29,32 +29,56 @@ describe Gitlab::LDAP::Person do
end end
end end
describe '#kerberos_principal' do describe '.find_by_kerberos_principal' do
let(:entry) do let(:adapter) { ldap_adapter }
ldif = "dn: cn=foo, dc=bar, dc=com\n" let(:username) { 'foo' }
ldif += "sAMAccountName: #{sam_account_name}\n" if sam_account_name let(:principal) { username + '@' + kerberos_realm }
Net::LDAP::Entry.from_single_ldif_string(ldif) let(:ldap_server) { 'ad.example.com' }
subject { described_class.find_by_kerberos_principal(principal, adapter) }
before do
stub_ldap_config(uid: 'sAMAccountName', base: 'ou=foo,dc=' + ldap_server.gsub('.', ',dc='))
end end
subject { described_class.new(entry, 'ldapmain') } context 'LDAP server is not for kerberos realm' do
let(:kerberos_realm) { 'kerberos.example.com' }
context 'when sAMAccountName is not defined (non-AD LDAP server)' do it 'returns nil without searching' do
let(:sam_account_name) { nil } expect(adapter).not_to receive(:user)
it 'returns nil' do is_expected.to be_nil
expect(subject.kerberos_principal).to be_nil
end end
end end
context 'when sAMAccountName is defined (AD server)' do context 'LDAP server is for kerberos realm' do
let(:sam_account_name) { 'mylogin' } let(:kerberos_realm) { ldap_server }
it 'searches by configured uid attribute' do
expect(adapter).to receive(:user).with('sAMAccountName', username).and_return(:fake_user)
it 'returns the principal combining sAMAccountName and DC components of the distinguishedName' do is_expected.to eq(:fake_user)
expect(subject.kerberos_principal).to eq('mylogin@BAR.COM')
end end
end end
end end
describe '#kerberos_principal' do
let(:entry) do
ldif = "dn: cn=foo, dc=bar, dc=com\nsAMAccountName: myName\n"
Net::LDAP::Entry.from_single_ldif_string(ldif)
end
subject { described_class.new(entry, 'ldapmain') }
before do
stub_ldap_config(uid: 'sAMAccountName')
end
it 'returns the principal combining the configured UID and DC components of the distinguishedName' do
expect(subject.kerberos_principal).to eq('myName@BAR.COM')
end
end
describe '#ssh_keys' do describe '#ssh_keys' do
let(:ssh_key) { 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCrSQHff6a1rMqBdHFt+FwIbytMZ+hJKN3KLkTtOWtSvNIriGhnTdn4rs+tjD/w+z+revytyWnMDM9dS7J8vQi006B16+hc9Xf82crqRoPRDnBytgAFFQY1G/55ql2zdfsC5yvpDOFzuwIJq5dNGsojS82t6HNmmKPq130fzsenFnj5v1pl3OJvk513oduUyKiZBGTroWTn7H/eOPtu7s9MD7pAdEjqYKFLeaKmyidiLmLqQlCRj3Tl2U9oyFg4PYNc0bL5FZJ/Z6t0Ds3i/a2RanQiKxrvgu3GSnUKMx7WIX373baL4jeM7cprRGiOY/1NcS+1cAjfJ8oaxQF/1dYj' } let(:ssh_key) { 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCrSQHff6a1rMqBdHFt+FwIbytMZ+hJKN3KLkTtOWtSvNIriGhnTdn4rs+tjD/w+z+revytyWnMDM9dS7J8vQi006B16+hc9Xf82crqRoPRDnBytgAFFQY1G/55ql2zdfsC5yvpDOFzuwIJq5dNGsojS82t6HNmmKPq130fzsenFnj5v1pl3OJvk513oduUyKiZBGTroWTn7H/eOPtu7s9MD7pAdEjqYKFLeaKmyidiLmLqQlCRj3Tl2U9oyFg4PYNc0bL5FZJ/Z6t0Ds3i/a2RanQiKxrvgu3GSnUKMx7WIX373baL4jeM7cprRGiOY/1NcS+1cAjfJ8oaxQF/1dYj' }
let(:ssh_key_attribute_name) { 'altSecurityIdentities' } let(:ssh_key_attribute_name) { 'altSecurityIdentities' }
......
require 'spec_helper'
describe Gitlab::OAuth::AuthHash, lib: true do
let(:auth_hash) do
Gitlab::OAuth::AuthHash.new(
OmniAuth::AuthHash.new(
provider: ascii('kerberos'),
uid: ascii(uid),
info: { uid: ascii(uid) }
)
)
end
describe '#uid' do
subject { auth_hash.uid }
context 'contains a kerberos realm' do
let(:uid) { 'mylogin@BAR.COM' }
it 'preserves the canonical uid' do
is_expected.to eq('mylogin@BAR.COM')
end
end
context 'does not contain a kerberos realm' do
let(:uid) { 'mylogin' }
before do
allow(Gitlab::Kerberos::Authentication).to receive(:kerberos_default_realm).and_return('FOO.COM')
end
it 'canonicalizes uid with kerberos realm' do
is_expected.to eq('mylogin@FOO.COM')
end
end
end
def ascii(text)
text.force_encoding(Encoding::ASCII_8BIT)
end
end
require 'spec_helper'
describe Gitlab::OAuth::User, lib: true do
include LdapHelpers
describe 'login through kerberos with linkable LDAP user' do
let(:uid) { 'foo' }
let(:provider) { 'kerberos' }
let(:realm) { 'ad.example.com' }
let(:base_dn) { 'ou=users,dc=ad,dc=example,dc=com' }
let(:info_hash) { { email: uid + '@' + realm, username: uid } }
let(:auth_hash) { OmniAuth::AuthHash.new(uid: uid, provider: provider, info: info_hash) }
let(:oauth_user) { Gitlab::OAuth::User.new(auth_hash) }
let(:real_email) { 'myname@example.com' }
before do
allow(::Gitlab::Kerberos::Authentication).to receive(:kerberos_default_realm).and_return(realm)
allow(Gitlab.config.omniauth).to receive_messages(auto_link_ldap_user: true, allow_single_sign_on: ['kerberos'])
stub_ldap_config(base: base_dn)
ldap_entry = Net::LDAP::Entry.new("uid=#{uid}," + base_dn).tap do |entry|
entry['uid'] = uid
entry['email'] = real_email
end
stub_ldap_person_find_by_uid(uid, ldap_entry)
oauth_user.save
end
it 'links the LDAP person to the GitLab user' do
gl_user = oauth_user.gl_user
identities = gl_user.identities.map do |identity|
{ provider: identity.provider, extern_uid: identity.extern_uid }
end
expect(identities).to contain_exactly(
{ provider: 'ldapmain', extern_uid: "uid=#{uid},#{base_dn}" },
{ provider: 'kerberos', extern_uid: uid + '@' + realm }
)
expect(gl_user.email).to eq(real_email)
end
end
end
...@@ -54,29 +54,6 @@ describe Gitlab::OAuth::AuthHash, lib: true do ...@@ -54,29 +54,6 @@ describe Gitlab::OAuth::AuthHash, lib: true do
it { expect(auth_hash.password).not_to be_empty } it { expect(auth_hash.password).not_to be_empty }
end end
context 'with kerberos provider' do
let(:provider_ascii) { 'kerberos'.force_encoding(Encoding::ASCII_8BIT) }
context "and uid contains a kerberos realm" do
let(:uid_ascii) { 'mylogin@BAR.COM'.force_encoding(Encoding::ASCII_8BIT) }
it "preserves the canonical uid" do
expect(auth_hash.uid).to eq('mylogin@BAR.COM')
end
end
context "and uid does not contain a kerberos realm" do
let(:uid_ascii) { 'mylogin'.force_encoding(Encoding::ASCII_8BIT) }
before do
allow(Gitlab::Kerberos::Authentication).to receive(:kerberos_default_realm).and_return("FOO.COM")
end
it "canonicalizes uid with kerberos realm" do
expect(auth_hash.uid).to eq('mylogin@FOO.COM')
end
end
end
context 'email not provided' do context 'email not provided' do
before do before do
info_hash.delete(:email) info_hash.delete(:email)
......
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