From cad3e2cc1bddad4a8ff742f541c0136efdadf8d7 Mon Sep 17 00:00:00 2001
From: Imre Farkas <ifarkas@gitlab.com>
Date: Mon, 21 Jan 2019 10:29:00 +0100
Subject: [PATCH] Add LDAP integration to smartcard authentication

It implements certificate matching using certificateExactMatch matching
rule defined in RFC4523.
---
 app/views/devise/sessions/_new_ldap.html.haml |   2 +
 config/gitlab.yml.example                     |   4 +
 config/initializers/1_settings.rb             |   2 +
 doc/administration/auth/smartcard.md          |  74 ++++++-
 ee/app/controllers/smartcard_controller.rb    |  14 +-
 ee/app/helpers/ee/application_helper.rb       |   4 +
 ee/app/helpers/ee/auth_helper.rb              |  21 ++
 ee/app/models/ee/identity.rb                  |   4 +
 .../views/devise/sessions/_new_ldap.html.haml |   5 +
 .../devise/sessions/_new_smartcard.html.haml  |   2 +-
 .../sessions/_new_smartcard_ldap.html.haml    |  17 ++
 .../ee-7693-smartcard_ldap_integration.yml    |   5 +
 ee/config/routes/smartcard.rb                 |   1 +
 ee/lib/ee/gitlab/auth/ldap/adapter.rb         |  19 ++
 ee/lib/ee/gitlab/auth/ldap/person.rb          |   5 +
 ee/lib/gitlab/auth/smartcard/base.rb          |  57 ++++++
 ee/lib/gitlab/auth/smartcard/certificate.rb   |  64 ++----
 .../gitlab/auth/smartcard/ldap_certificate.rb |  61 ++++++
 ee/lib/gitlab/auth_logger.rb                  |   9 +
 ee/spec/features/users/login_spec.rb          | 107 +++++++++-
 ee/spec/helpers/ee/auth_helper_spec.rb        | 100 ++++++++++
 ee/spec/lib/gitlab/auth/ldap/adapter_spec.rb  |  36 ++++
 ee/spec/lib/gitlab/auth/ldap/person_spec.rb   |  12 ++
 .../gitlab/auth/smartcard/certificate_spec.rb |  53 +----
 .../auth/smartcard/ldap_certificate_spec.rb   | 134 +++++++++++++
 ee/spec/requests/smartcard_controller_spec.rb | 187 +++++++++++++-----
 .../smartcard_certificate_store.rb            |  59 ++++++
 lib/gitlab/auth/ldap/adapter.rb               |  19 +-
 locale/gitlab.pot                             |   9 +
 29 files changed, 910 insertions(+), 176 deletions(-)
 create mode 100644 ee/app/views/devise/sessions/_new_ldap.html.haml
 create mode 100644 ee/app/views/devise/sessions/_new_smartcard_ldap.html.haml
 create mode 100644 ee/changelogs/unreleased/ee-7693-smartcard_ldap_integration.yml
 create mode 100644 ee/lib/gitlab/auth/smartcard/base.rb
 create mode 100644 ee/lib/gitlab/auth/smartcard/ldap_certificate.rb
 create mode 100644 ee/lib/gitlab/auth_logger.rb
 create mode 100644 ee/spec/lib/gitlab/auth/smartcard/ldap_certificate_spec.rb
 create mode 100644 ee/spec/support/shared_examples/smartcard_certificate_store.rb

diff --git a/app/views/devise/sessions/_new_ldap.html.haml b/app/views/devise/sessions/_new_ldap.html.haml
index 796c0cadda8..f856773526d 100644
--- a/app/views/devise/sessions/_new_ldap.html.haml
+++ b/app/views/devise/sessions/_new_ldap.html.haml
@@ -1,3 +1,5 @@
+- server = local_assigns.fetch(:server)
+
 = form_tag(omniauth_callback_path(:user, server['provider_name']), id: 'new_ldap_user', class: "gl-show-field-errors") do
   .form-group
     = label_tag :username, "#{server['label']} Username"
diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example
index 5807ae6cee6..07604c19d19 100644
--- a/config/gitlab.yml.example
+++ b/config/gitlab.yml.example
@@ -458,6 +458,10 @@ production: &base
         # A value of 0 means there is no timeout.
         timeout: 10
 
+        # Enable smartcard authentication against the LDAP server. Valid values
+        # are "false", "optional", and "required".
+        smartcard_auth: false
+
         # This setting specifies if LDAP server is Active Directory LDAP server.
         # For non AD servers it skips the AD specific queries.
         # If your LDAP server is not AD, set this to false.
diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb
index 595cfa1adce..660237765d6 100644
--- a/config/initializers/1_settings.rb
+++ b/config/initializers/1_settings.rb
@@ -27,6 +27,7 @@ if Settings.ldap['enabled'] || Rails.env.test?
     server['timeout'] ||= 10.seconds
     server['block_auto_created_users'] = false if server['block_auto_created_users'].nil?
     server['allow_username_or_email_login'] = false if server['allow_username_or_email_login'].nil?
+    server['smartcard_auth'] = false unless %w[optional required].include?(server['smartcard_auth'])
     server['active_directory'] = true if server['active_directory'].nil?
     server['attributes'] = {} if server['attributes'].nil?
     server['lowercase_usernames'] = false if server['lowercase_usernames'].nil?
@@ -52,6 +53,7 @@ end
 
 Settings['smartcard'] ||= Settingslogic.new({})
 Settings.smartcard['enabled'] = false if Settings.smartcard['enabled'].nil?
+Settings.smartcard['client_certificate_required_port'] = 3444 if Settings.smartcard['client_certificate_required_port'].nil?
 
 Settings['omniauth'] ||= Settingslogic.new({})
 Settings.omniauth['enabled'] = true if Settings.omniauth['enabled'].nil?
diff --git a/doc/administration/auth/smartcard.md b/doc/administration/auth/smartcard.md
index ad613274210..1107b955c4a 100644
--- a/doc/administration/auth/smartcard.md
+++ b/doc/administration/auth/smartcard.md
@@ -1,16 +1,26 @@
 # Smartcard authentication
 
+GitLab supports authentication using smartcards.
+
+## Authentication methods
+
+GitLab supports two authentication methods:
+
+- X.509 certificates with local databases.
+- LDAP servers.
+
+### Authentication against a local database with X.509 certificates
+
 > [Introduced](https://gitlab.com/gitlab-org/gitlab-ee/issues/726) in
 [GitLab Premium](https://about.gitlab.com/pricing/) 11.6 as an experimental
-feature. Smartcard authentication may change or be removed completely in future
-releases.
+feature. Smartcard authentication against local databases may change or be
+removed completely in future releases.
 
 Smartcards with X.509 certificates can be used to authenticate with GitLab.
 
-## X.509 certificates
-
-To use a smartcard with an X.509 certificate to authenticate with GitLab, `CN`
-and `emailAddress` must be defined in the certificate. For example:
+To use a smartcard with an X.509 certificate to authenticate against a local
+database with GitLab, `CN` and `emailAddress` must be defined in the
+certificate. For example:
 
 ```
 Certificate:
@@ -25,6 +35,21 @@ Certificate:
         Subject: CN=Gitlab User, emailAddress=gitlab-user@example.com
 ```
 
+### Authentication against an LDAP server
+
+> [Introduced](https://gitlab.com/gitlab-org/gitlab-ee/issues/7693) in
+[GitLab Premium](https://about.gitlab.com/pricing/) 11.8 as an experimental
+feature. Smartcard authentication against an LDAP server may change or be
+removed completely in future releases.
+
+GitLab implements a standard way of certificate matching following
+[RFC4523](https://tools.ietf.org/html/rfc4523). It uses the
+`certificateExactMatch` certificate matching rule against the `userCertificate`
+attribute. As a prerequisite, you must use an LDAP server that:
+
+- Supports the `certificateExactMatch` matching rule.
+- Has the certificate stored in the `userCertificate` attribute.
+
 ## Configure GitLab for smartcard authentication
 
 **For Omnibus installations**
@@ -122,3 +147,40 @@ Certificate:
 
 1. Save the file and [restart](../restart_gitlab.md#installations-from-source)
    GitLab for the changes to take effect.
+
+### Additional steps when authenticating against an LDAP server
+
+**For Omnibus installations**
+
+1. Edit `/etc/gitlab/gitlab.rb`:
+
+    ```ruby
+    gitlab_rails['ldap_servers'] = YAML.load <<-EOS
+    main:
+      # snip...
+      # Enable smartcard authentication against the LDAP server. Valid values
+      # are "false", "optional", and "required".
+      smartcard_auth: optional
+    EOS
+    ```
+
+1. Save the file and [reconfigure](../restart_gitlab.md#omnibus-gitlab-reconfigure)
+   GitLab for the changes to take effect.
+
+**For installations from source**
+
+1. Edit `config/gitlab.yml`:
+
+    ```yaml
+    production:
+      ldap:
+        servers:
+          main:
+            # snip...
+            # Enable smartcard authentication against the LDAP server. Valid values
+            # are "false", "optional", and "required".
+            smartcard_auth: optional
+    ```
+
+1. Save the file and [restart](../restart_gitlab.md#installations-from-source)
+   GitLab for the changes to take effect.
diff --git a/ee/app/controllers/smartcard_controller.rb b/ee/app/controllers/smartcard_controller.rb
index 8a92681cae7..67ac86e8ac8 100644
--- a/ee/app/controllers/smartcard_controller.rb
+++ b/ee/app/controllers/smartcard_controller.rb
@@ -9,7 +9,17 @@ class SmartcardController < ApplicationController
 
   def auth
     certificate = Gitlab::Auth::Smartcard::Certificate.new(certificate_header)
+    sign_in_with(certificate)
+  end
+
+  def ldap_auth
+    certificate = Gitlab::Auth::Smartcard::LDAPCertificate.new(params[:provider], certificate_header)
+    sign_in_with(certificate)
+  end
 
+  private
+
+  def sign_in_with(certificate)
     user = certificate.find_or_create_user
     unless user
       flash[:alert] = _('Failed to signing using smartcard authentication')
@@ -18,12 +28,10 @@ class SmartcardController < ApplicationController
       return
     end
 
-    log_audit_event(user, with: 'smartcard')
+    log_audit_event(user, with: certificate.auth_method)
     sign_in_and_redirect(user)
   end
 
-  protected
-
   def check_feature_availability
     render_404 unless ::Gitlab::Auth::Smartcard.enabled?
   end
diff --git a/ee/app/helpers/ee/application_helper.rb b/ee/app/helpers/ee/application_helper.rb
index b04cd3838ea..1365dd72381 100644
--- a/ee/app/helpers/ee/application_helper.rb
+++ b/ee/app/helpers/ee/application_helper.rb
@@ -49,6 +49,10 @@ module EE
       end
     end
 
+    def smartcard_config_port
+      ::Gitlab.config.smartcard.client_certificate_required_port
+    end
+
     def page_class
       class_names = super
       class_names += system_message_class
diff --git a/ee/app/helpers/ee/auth_helper.rb b/ee/app/helpers/ee/auth_helper.rb
index 42933dbdd43..2b9ab5713a8 100644
--- a/ee/app/helpers/ee/auth_helper.rb
+++ b/ee/app/helpers/ee/auth_helper.rb
@@ -50,6 +50,27 @@ module EE
       ::Gitlab::Auth::Smartcard.enabled?
     end
 
+    def smartcard_enabled_for_ldap?(provider_name, required: false)
+      return false unless smartcard_enabled?
+
+      server = ::Gitlab::Auth::LDAP::Config.servers.find do |server|
+        server['provider_name'] == provider_name
+      end
+
+      return false unless server
+
+      truthy_values = ['required']
+      truthy_values << 'optional' unless required
+
+      truthy_values.include? server['smartcard_auth']
+    end
+
+    def smartcard_login_button_classes(provider_name)
+      css_classes = %w[btn btn-success]
+      css_classes << 'btn-inverted' unless smartcard_enabled_for_ldap?(provider_name, required: true)
+      css_classes.join(' ')
+    end
+
     def group_saml_enabled?
       auth_providers.include?(:group_saml)
     end
diff --git a/ee/app/models/ee/identity.rb b/ee/app/models/ee/identity.rb
index b145774ca1a..749c161a8e3 100644
--- a/ee/app/models/ee/identity.rb
+++ b/ee/app/models/ee/identity.rb
@@ -20,6 +20,10 @@ module EE
     end
 
     class_methods do
+      def find_by_extern_uid(provider, extern_uid)
+        with_extern_uid(provider, extern_uid).take
+      end
+
       def preload_saml_group
         preload(saml_provider: { group: :route })
       end
diff --git a/ee/app/views/devise/sessions/_new_ldap.html.haml b/ee/app/views/devise/sessions/_new_ldap.html.haml
new file mode 100644
index 00000000000..3c6b049c06d
--- /dev/null
+++ b/ee/app/views/devise/sessions/_new_ldap.html.haml
@@ -0,0 +1,5 @@
+- unless smartcard_enabled_for_ldap?(server['provider_name'], required: true)
+  = render_ce('devise/sessions/new_ldap', server: server)
+  %hr
+
+= render 'devise/sessions/new_smartcard_ldap', server: server
diff --git a/ee/app/views/devise/sessions/_new_smartcard.html.haml b/ee/app/views/devise/sessions/_new_smartcard.html.haml
index 51084830524..ae19cd25794 100644
--- a/ee/app/views/devise/sessions/_new_smartcard.html.haml
+++ b/ee/app/views/devise/sessions/_new_smartcard.html.haml
@@ -1,6 +1,6 @@
 - if smartcard_enabled?
   .login-box.tab-pane{ id: 'smartcard', role: 'tabpanel', class: active_when(form_based_auth_provider_has_active_class?(:smartcard)) }
     .login-body
-      = form_tag(smartcard_auth_url(port: Gitlab.config.smartcard.client_certificate_required_port), html: { 'aria-live' => 'assertive'}) do
+      = form_tag(smartcard_auth_url(port: smartcard_config_port), html: { 'aria-live' => 'assertive'}) do
         .submit-container
           = submit_tag _('Login with smartcard'), class: 'btn btn-success'
diff --git a/ee/app/views/devise/sessions/_new_smartcard_ldap.html.haml b/ee/app/views/devise/sessions/_new_smartcard_ldap.html.haml
new file mode 100644
index 00000000000..46125b1ebe1
--- /dev/null
+++ b/ee/app/views/devise/sessions/_new_smartcard_ldap.html.haml
@@ -0,0 +1,17 @@
+- if smartcard_enabled_for_ldap?(server['provider_name'])
+  %div{ id: "#{server['provider_name']}_smartcard" }
+    %span
+      %strong
+        = sprite_icon('smart-card', size: 16, css_class: 'vertical-align-middle' )
+        %span.vertical-align-middle
+          = _('Sign in using smart card')
+    %p
+      = _('Use your smart card to authenticate with the LDAP server.')
+
+    .login-body
+      = form_tag(smartcard_ldap_auth_url(provider: server['provider_name'],
+                                         port: smartcard_config_port),
+                 html: { 'aria-live' => 'assertive'}) do
+        .submit-container
+          = submit_tag(_('Sign in with smart card'),
+                       class: smartcard_login_button_classes(server['provider_name']))
diff --git a/ee/changelogs/unreleased/ee-7693-smartcard_ldap_integration.yml b/ee/changelogs/unreleased/ee-7693-smartcard_ldap_integration.yml
new file mode 100644
index 00000000000..262c6257a02
--- /dev/null
+++ b/ee/changelogs/unreleased/ee-7693-smartcard_ldap_integration.yml
@@ -0,0 +1,5 @@
+---
+title: Add LDAP integration to smartcard authentication
+merge_request: 9235
+author:
+type: added
diff --git a/ee/config/routes/smartcard.rb b/ee/config/routes/smartcard.rb
index 4141e1ed2d6..4fdaeaabc25 100644
--- a/ee/config/routes/smartcard.rb
+++ b/ee/config/routes/smartcard.rb
@@ -1,3 +1,4 @@
 # frozen_string_literal: true
 
 post 'smartcard/auth' => 'smartcard#auth'
+post 'smartcard/ldap_auth' => 'smartcard#ldap_auth'
diff --git a/ee/lib/ee/gitlab/auth/ldap/adapter.rb b/ee/lib/ee/gitlab/auth/ldap/adapter.rb
index 6a3caac36ac..ee92dfc3fa1 100644
--- a/ee/lib/ee/gitlab/auth/ldap/adapter.rb
+++ b/ee/lib/ee/gitlab/auth/ldap/adapter.rb
@@ -55,6 +55,25 @@ module EE
               LDAP::Group.new(entry, self)
             end
           end
+
+          def user_by_certificate_assertion(certificate_assertion)
+            options = user_options_for_cert(certificate_assertion)
+            users_search(options).first
+          end
+
+          private
+
+          def user_options_for_cert(certificate_assertion)
+            options = {
+              attributes: ::Gitlab::Auth::LDAP::Person.ldap_attributes(config),
+              base: config.base
+            }
+
+            filter = Net::LDAP::Filter.ex(
+              'userCertificate:certificateExactMatch', certificate_assertion)
+
+            options.merge(filter: user_filter(filter))
+          end
         end
       end
     end
diff --git a/ee/lib/ee/gitlab/auth/ldap/person.rb b/ee/lib/ee/gitlab/auth/ldap/person.rb
index b6537724be2..e64d328e7fa 100644
--- a/ee/lib/ee/gitlab/auth/ldap/person.rb
+++ b/ee/lib/ee/gitlab/auth/ldap/person.rb
@@ -21,6 +21,11 @@ module EE
               nil
             end
 
+            def find_by_certificate_issuer_and_serial(issuer_dn, serial, adapter)
+              certificate_assertion = "{ serialNumber #{serial}, issuer \"#{issuer_dn}\" }"
+              adapter.user_by_certificate_assertion(certificate_assertion)
+            end
+
             def find_by_kerberos_principal(principal, adapter)
               uid, domain = principal.split('@', 2)
               return nil unless uid && domain
diff --git a/ee/lib/gitlab/auth/smartcard/base.rb b/ee/lib/gitlab/auth/smartcard/base.rb
new file mode 100644
index 00000000000..c28f20f9f1b
--- /dev/null
+++ b/ee/lib/gitlab/auth/smartcard/base.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+module Gitlab
+  module Auth
+    module Smartcard
+      class Base
+        InvalidCAFilePath = Class.new(StandardError)
+        InvalidCertificate = Class.new(StandardError)
+
+        delegate :allow_signup?,
+                 to: :'Gitlab::CurrentSettings.current_application_settings'
+
+        def self.store
+          @store ||= OpenSSL::X509::Store.new.tap do |store|
+            store.add_cert(
+              OpenSSL::X509::Certificate.new(
+                File.read(Gitlab.config.smartcard.ca_file)))
+          end
+        rescue Errno::ENOENT => ex
+          logger.error(message: 'Failed to open Gitlab.config.smartcard.ca_file',
+                       error: ex)
+
+          raise InvalidCAFilePath
+        rescue OpenSSL::X509::CertificateError => ex
+          logger.error(message: 'Gitlab.config.smartcard.ca_file is not a valid certificate',
+                       error: ex)
+
+          raise InvalidCertificate
+        end
+
+        def self.logger
+          @logger ||= ::Gitlab::AuthLogger.build
+        end
+
+        def initialize(certificate)
+          @certificate = OpenSSL::X509::Certificate.new(certificate)
+        rescue OpenSSL::X509::CertificateError
+          # no-op, certificate verification fails in this case in #valid?
+        end
+
+        def find_or_create_user
+          return unless valid?
+
+          user = find_user
+          user ||= create_user if allow_signup?
+          user
+        end
+
+        private
+
+        def valid?
+          self.class.store.verify(@certificate) if @certificate
+        end
+      end
+    end
+  end
+end
diff --git a/ee/lib/gitlab/auth/smartcard/certificate.rb b/ee/lib/gitlab/auth/smartcard/certificate.rb
index b779941dd34..ac943a712d3 100644
--- a/ee/lib/gitlab/auth/smartcard/certificate.rb
+++ b/ee/lib/gitlab/auth/smartcard/certificate.rb
@@ -3,53 +3,15 @@
 module Gitlab
   module Auth
     module Smartcard
-      class Certificate
-        InvalidCAFilePath = Class.new(StandardError)
-        InvalidCertificate = Class.new(StandardError)
-
-        delegate :allow_signup?,
-                 to: :'Gitlab::CurrentSettings.current_application_settings'
-
-        def self.store
-          @store ||= OpenSSL::X509::Store.new.tap do |store|
-            store.add_cert(
-              OpenSSL::X509::Certificate.new(
-                File.read(Gitlab.config.smartcard.ca_file)))
-          end
-        rescue Errno::ENOENT => ex
-          Gitlab::AppLogger.error('Failed to open Gitlab.config.smartcard.ca_file')
-          Gitlab::AppLogger.error(ex)
-          raise InvalidCAFilePath
-        rescue OpenSSL::X509::CertificateError => ex
-          Gitlab::AppLogger.error('Gitlab.config.smartcard.ca_file is not a valid certificate')
-          Gitlab::AppLogger.error(ex)
-          raise InvalidCertificate
-        end
-
-        def initialize(certificate)
-          @certificate = OpenSSL::X509::Certificate.new(certificate)
-          @subject = @certificate.subject.to_s
-          @issuer = @certificate.issuer.to_s
-        rescue OpenSSL::X509::CertificateError
-          # no-op
-        end
-
-        def find_or_create_user
-          return unless valid?
-
-          user = find_user
-          user ||= create_user if allow_signup?
-          user
+      class Certificate < Gitlab::Auth::Smartcard::Base
+        def auth_method
+          'smartcard'
         end
 
         private
 
-        def valid?
-          self.class.store.verify(@certificate) if @certificate
-        end
-
         def find_user
-          User.find_by_smartcard_identity(@subject, @issuer)
+          User.find_by_smartcard_identity(subject, issuer)
         end
 
         def create_user
@@ -66,8 +28,8 @@ module Gitlab
             password:                   password,
             password_confirmation:      password,
             password_automatically_set: true,
-            certificate_subject:        @subject,
-            certificate_issuer:         @issuer,
+            certificate_subject:        subject,
+            certificate_issuer:         issuer,
             skip_confirmation:          true
           }
 
@@ -75,15 +37,23 @@ module Gitlab
         end
 
         def create_smartcard_identity_for(user)
-          SmartcardIdentity.create(user: user, subject: @subject, issuer: @issuer)
+          SmartcardIdentity.create(user: user, subject: subject, issuer: issuer)
+        end
+
+        def issuer
+          @certificate.issuer.to_s
+        end
+
+        def subject
+          @certificate.subject.to_s
         end
 
         def common_name
-          @subject.split('/').find { |part| part =~ /CN=/ }&.remove('CN=')&.strip
+          subject.split('/').find { |part| part =~ /CN=/ }&.remove('CN=')&.strip
         end
 
         def email
-          @subject.split('/').find { |part| part =~ /emailAddress=/ }&.remove('emailAddress=')&.strip
+          subject.split('/').find { |part| part =~ /emailAddress=/ }&.remove('emailAddress=')&.strip
         end
 
         def username
diff --git a/ee/lib/gitlab/auth/smartcard/ldap_certificate.rb b/ee/lib/gitlab/auth/smartcard/ldap_certificate.rb
new file mode 100644
index 00000000000..3eaad930b02
--- /dev/null
+++ b/ee/lib/gitlab/auth/smartcard/ldap_certificate.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+module Gitlab
+  module Auth
+    module Smartcard
+      class LDAPCertificate < Gitlab::Auth::Smartcard::Base
+        def initialize(provider, certificate)
+          super(certificate)
+
+          @provider = provider
+        end
+
+        def auth_method
+          'smartcard_ldap'
+        end
+
+        private
+
+        def find_user
+          identity = Identity.find_by_extern_uid(@provider, ldap_user.dn)
+          identity&.user
+        end
+
+        def create_user
+          user_params = {
+            name:                       ldap_user.name,
+            username:                   username,
+            email:                      ldap_user.email.first,
+            extern_uid:                 ldap_user.dn,
+            provider:                   @provider,
+            password:                   password,
+            password_confirmation:      password,
+            password_automatically_set: true,
+            skip_confirmation:          true
+          }
+
+          Users::CreateService.new(nil, user_params).execute(skip_authorization: true)
+        end
+
+        def adapter
+          @adapter ||= Gitlab::Auth::LDAP::Adapter.new(@provider)
+        end
+
+        def ldap_user
+          @ldap_user ||= ::Gitlab::Auth::LDAP::Person.find_by_certificate_issuer_and_serial(
+            @certificate.issuer.to_s(OpenSSL::X509::Name::RFC2253),
+            @certificate.serial.to_s,
+            adapter)
+        end
+
+        def username
+          ::Namespace.clean_path(ldap_user.username)
+        end
+
+        def password
+          @password ||= Devise.friendly_token(8)
+        end
+      end
+    end
+  end
+end
diff --git a/ee/lib/gitlab/auth_logger.rb b/ee/lib/gitlab/auth_logger.rb
new file mode 100644
index 00000000000..f6221e23e5a
--- /dev/null
+++ b/ee/lib/gitlab/auth_logger.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module Gitlab
+  class AuthLogger < Gitlab::JsonLogger
+    def self.file_name_noext
+      'auth_json'
+    end
+  end
+end
diff --git a/ee/spec/features/users/login_spec.rb b/ee/spec/features/users/login_spec.rb
index 6a8f842bd81..263c0979071 100644
--- a/ee/spec/features/users/login_spec.rb
+++ b/ee/spec/features/users/login_spec.rb
@@ -1,6 +1,7 @@
 require 'spec_helper'
 
 describe 'Login' do
+  include LdapHelpers
   include UserLoginHelper
 
   before do
@@ -28,22 +29,30 @@ describe 'Login' do
       .to change { SecurityEvent.where(entity_id: -1).count }.from(0).to(1)
   end
 
-  describe 'UI tabs and panes' do
-    context 'when smartcard is enabled' do
-      before do
-        visit new_user_session_path
-        allow(page).to receive(:form_based_providers).and_return([:smartcard])
-        allow(page).to receive(:smartcard_enabled?).and_return(true)
-      end
+  describe 'smartcard authentication' do
+    before do
+      allow(Gitlab.config.smartcard).to receive(:enabled).and_return(true)
+    end
 
+    subject { visit new_user_session_path }
+
+    context 'when smartcard is enabled' do
       context 'with smartcard_auth feature flag off' do
         before do
           stub_licensed_features(smartcard_auth: false)
         end
 
         it 'correctly renders tabs and panes' do
+          subject
+
           ensure_tab_pane_correctness(false)
         end
+
+        it 'does not show smartcard login form' do
+          subject
+
+          expect(page).not_to have_selector('.nav-tabs a[href="#smartcard"]')
+        end
       end
 
       context 'with smartcard_auth feature flag on' do
@@ -52,9 +61,91 @@ describe 'Login' do
         end
 
         it 'correctly renders tabs and panes' do
-          ensure_tab_pane_correctness(false)
+          subject
+
+          expect(page.all('.nav-tabs a[data-toggle="tab"]').length).to be(3)
+
+          ensure_one_active_tab
+          ensure_one_active_pane
+        end
+
+        it 'shows smartcard login form' do
+          subject
+
+          expect(page).to have_selector('.nav-tabs a[href="#smartcard"]')
         end
       end
     end
   end
+
+  describe 'smartcard authentication against LDAP server' do
+    let(:ldap_server_config) do
+      {
+        'provider_name' => 'ldapmain',
+        'attributes' => {},
+        'encryption' => 'plain',
+        'smartcard_auth' => smartcard_auth_status,
+        'uid' => 'uid',
+        'base' => 'dc=example,dc=com'
+      }
+    end
+
+    subject { visit new_user_session_path }
+
+    before do
+      stub_licensed_features(smartcard_auth: true)
+      stub_ldap_setting(enabled: true)
+      allow(Gitlab.config.smartcard).to receive(:enabled).and_return(true)
+      allow(::Gitlab::Auth::LDAP::Config).to receive_messages(enabled: true, servers: [ldap_server_config])
+      allow_any_instance_of(ActionDispatch::Routing::RoutesProxy)
+        .to receive(:user_ldapmain_omniauth_callback_path)
+              .and_return('/users/auth/ldapmain/callback')
+    end
+
+    context 'when smartcard auth is optional' do
+      let(:smartcard_auth_status) { 'optional' }
+
+      it 'correctly renders tabs and panes' do
+        subject
+
+        ensure_one_active_tab
+        ensure_one_active_pane
+      end
+
+      it 'shows LDAP login form' do
+        subject
+
+        expect(page).to have_selector('#ldapmain.tab-pane form#new_ldap_user')
+      end
+
+      it 'shows LDAP smartcard login form' do
+        subject
+
+        expect(page).to have_selector('#ldapmain_smartcard input[value="Sign in with smart card"]')
+      end
+    end
+
+    context 'when smartcard auth is required' do
+      let(:smartcard_auth_status) { 'required' }
+
+      it 'correctly renders tabs and panes' do
+        subject
+
+        ensure_one_active_tab
+        ensure_one_active_pane
+      end
+
+      it 'does not show LDAP login form' do
+        subject
+
+        expect(page).not_to have_selector('#ldapmain.tab-pane form#new_ldap_user')
+      end
+
+      it 'shows LDAP smartcard login form' do
+        subject
+
+        expect(page).to have_selector('#ldapmain_smartcard input[value="Sign in with smart card"]')
+      end
+    end
+  end
 end
diff --git a/ee/spec/helpers/ee/auth_helper_spec.rb b/ee/spec/helpers/ee/auth_helper_spec.rb
index 3826175e6f9..d481a2f14d8 100644
--- a/ee/spec/helpers/ee/auth_helper_spec.rb
+++ b/ee/spec/helpers/ee/auth_helper_spec.rb
@@ -28,4 +28,104 @@ describe EE::AuthHelper do
       end
     end
   end
+
+  describe 'smartcard_enabled_for_ldap?' do
+    let(:provider_name) { 'ldapmain' }
+    let(:ldap_server_config) do
+      {
+        'provider_name' => provider_name,
+        'attributes' => {},
+        'encryption' => 'plain',
+        'smartcard_auth' => smartcard_auth_status,
+        'uid' => 'uid',
+        'base' => 'dc=example,dc=com'
+      }
+    end
+
+    before do
+      allow(::Gitlab::Auth::Smartcard).to receive(:enabled?).and_return(true)
+      allow(::Gitlab::Auth::LDAP::Config).to receive(:servers).and_return([ldap_server_config])
+    end
+
+    context 'LDAP server with optional smartcard auth' do
+      let(:smartcard_auth_status) { 'optional' }
+
+      it 'returns true' do
+        expect(smartcard_enabled_for_ldap?(provider_name, required: false)).to be(true)
+      end
+
+      it 'returns false with required flag' do
+        expect(smartcard_enabled_for_ldap?(provider_name, required: true)).to be(false)
+      end
+    end
+
+    context 'LDAP server with required smartcard auth' do
+      let(:smartcard_auth_status) { 'required' }
+
+      it 'returns true' do
+        expect(smartcard_enabled_for_ldap?(provider_name, required: false)).to be(true)
+      end
+
+      it 'returns true with required flag' do
+        expect(smartcard_enabled_for_ldap?(provider_name, required: true)).to be(true)
+      end
+    end
+
+    context 'LDAP server with disabled smartcard auth' do
+      let(:smartcard_auth_status) { false }
+
+      it 'returns false' do
+        expect(smartcard_enabled_for_ldap?(provider_name, required: false)).to be(false)
+      end
+
+      it 'returns false with required flag' do
+        expect(smartcard_enabled_for_ldap?(provider_name, required: true)).to be(false)
+      end
+    end
+
+    context 'no matching LDAP server' do
+      let(:smartcard_auth_status) { 'optional' }
+
+      it 'returns false' do
+        expect(smartcard_enabled_for_ldap?('nonexistent')).to be(false)
+      end
+    end
+  end
+
+  describe 'smartcard_login_button_classes' do
+    let(:provider_name) { 'ldapmain' }
+    let(:ldap_server_config) do
+      {
+        'provider_name' => provider_name,
+        'attributes' => {},
+        'encryption' => 'plain',
+        'smartcard_auth' => smartcard_auth_status,
+        'uid' => 'uid',
+        'base' => 'dc=example,dc=com'
+      }
+    end
+
+    subject { smartcard_login_button_classes(provider_name) }
+
+    before do
+      allow(::Gitlab::Auth::Smartcard).to receive(:enabled?).and_return(true)
+      allow(::Gitlab::Auth::LDAP::Config).to receive(:servers).and_return([ldap_server_config])
+    end
+
+    context 'when smartcard auth is optional' do
+      let(:smartcard_auth_status) { 'optional' }
+
+      it 'returns the correct CSS classes' do
+        expect(subject).to eql('btn btn-success btn-inverted')
+      end
+    end
+
+    context 'when smartcard auth is required' do
+      let(:smartcard_auth_status) { 'required' }
+
+      it 'returns the correct CSS classes' do
+        expect(subject).to eql('btn btn-success')
+      end
+    end
+  end
 end
diff --git a/ee/spec/lib/gitlab/auth/ldap/adapter_spec.rb b/ee/spec/lib/gitlab/auth/ldap/adapter_spec.rb
index 5cb4d743bad..ee4dba121d3 100644
--- a/ee/spec/lib/gitlab/auth/ldap/adapter_spec.rb
+++ b/ee/spec/lib/gitlab/auth/ldap/adapter_spec.rb
@@ -39,4 +39,40 @@ describe Gitlab::Auth::LDAP::Adapter do
       expect(results.first.member_dns).to match_array(%w(uid=john uid=mary))
     end
   end
+
+  describe '#user_by_certificate_assertion' do
+    let(:certificate_assertion) { 'certificate_assertion' }
+
+    subject { adapter.user_by_certificate_assertion(certificate_assertion) }
+
+    context 'return value' do
+      let(:entry) { ldap_user_entry('john') }
+
+      before do
+        allow(adapter).to receive(:ldap_search).and_return([entry])
+      end
+
+      it 'returns a person object' do
+        expect(subject).to be_a(::EE::Gitlab::Auth::LDAP::Person)
+      end
+
+      it 'returns correct attributes' do
+        result = subject
+
+        expect(result.uid).to eq('john')
+        expect(result.dn).to eq('uid=john,ou=users,dc=example,dc=com')
+      end
+    end
+
+    it 'searches with the proper options' do
+      expect(adapter).to receive(:ldap_search).with(
+        { attributes: array_including('dn', 'cn', 'mail', 'uid', 'userid'),
+          base: 'dc=example,dc=com',
+          filter: Net::LDAP::Filter.ex(
+            'userCertificate:certificateExactMatch', certificate_assertion) }
+      ).and_return({})
+
+      subject
+    end
+  end
 end
diff --git a/ee/spec/lib/gitlab/auth/ldap/person_spec.rb b/ee/spec/lib/gitlab/auth/ldap/person_spec.rb
index b87afaf29e3..7aa67e73216 100644
--- a/ee/spec/lib/gitlab/auth/ldap/person_spec.rb
+++ b/ee/spec/lib/gitlab/auth/ldap/person_spec.rb
@@ -36,6 +36,18 @@ describe Gitlab::Auth::LDAP::Person do
     end
   end
 
+  describe '.find_by_certificate_issuer_and_serial' do
+    it 'searches by certificate assertion' do
+      adapter = ldap_adapter
+      serial = 'serial'
+      issuer_dn = 'issuer_dn'
+
+      expect(adapter).to receive(:user_by_certificate_assertion).with("{ serialNumber #{serial}, issuer \"#{issuer_dn}\" }")
+
+      described_class.find_by_certificate_issuer_and_serial(issuer_dn, serial, adapter)
+    end
+  end
+
   describe '.find_by_kerberos_principal' do
     let(:adapter) { ldap_adapter }
     let(:username) { 'foo' }
diff --git a/ee/spec/lib/gitlab/auth/smartcard/certificate_spec.rb b/ee/spec/lib/gitlab/auth/smartcard/certificate_spec.rb
index a9765419ef7..84709676692 100644
--- a/ee/spec/lib/gitlab/auth/smartcard/certificate_spec.rb
+++ b/ee/spec/lib/gitlab/auth/smartcard/certificate_spec.rb
@@ -119,57 +119,8 @@ describe Gitlab::Auth::Smartcard::Certificate do
       end
     end
 
-    context 'invalid certificate' do
-      before do
-        allow(openssl_certificate_store).to receive(:verify).and_return(false)
-      end
-
-      it 'returns nil' do
-        expect(subject).to be_nil
-      end
-    end
-
-    context 'incorrect certificate' do
-      before do
-        allow(OpenSSL::X509::Certificate).to receive(:new).and_call_original
-      end
-
-      it 'returns nil' do
-        expect(subject).to be_nil
-      end
-    end
+    it_behaves_like 'a valid certificate is required'
   end
 
-  describe '.store' do
-    before do
-      allow(Gitlab.config.smartcard).to receive(:ca_file).and_return('ca_file')
-      allow(described_class).to receive(:store).and_call_original
-      allow(OpenSSL::X509::Certificate).to receive(:new).and_call_original
-      clear_store
-    end
-    after do
-      clear_store
-    end
-
-    subject { described_class.store }
-
-    context 'file does not exist' do
-      it 'raises error' do
-        expect { subject }.to raise_error(Gitlab::Auth::Smartcard::Certificate::InvalidCAFilePath)
-      end
-    end
-
-    context 'smartcard.ca_file is not a valid certificate' do
-      it 'raises error' do
-        expect(File).to receive(:read).with('ca_file').and_return('invalid certificate')
-        expect { subject }.to raise_error(Gitlab::Auth::Smartcard::Certificate::InvalidCertificate)
-      end
-    end
-  end
-
-  def clear_store
-    described_class.remove_instance_variable(:@store)
-  rescue NameError
-    # raised if @store was not set; ignore
-  end
+  it_behaves_like 'a certificate store'
 end
diff --git a/ee/spec/lib/gitlab/auth/smartcard/ldap_certificate_spec.rb b/ee/spec/lib/gitlab/auth/smartcard/ldap_certificate_spec.rb
new file mode 100644
index 00000000000..3599bf90528
--- /dev/null
+++ b/ee/spec/lib/gitlab/auth/smartcard/ldap_certificate_spec.rb
@@ -0,0 +1,134 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Gitlab::Auth::Smartcard::LDAPCertificate do
+  let(:certificate_header) { 'certificate' }
+  let(:openssl_certificate_store) { instance_double(OpenSSL::X509::Store) }
+  let(:user_build_service) { instance_double(Users::BuildService) }
+  let(:subject_ldap_dn) { 'subject_ldap_dn' }
+  let(:issuer) { instance_double(OpenSSL::X509::Name, to_s: 'issuer_dn') }
+  let(:openssl_certificate) do
+    instance_double(OpenSSL::X509::Certificate,
+                    { issuer: issuer,
+                      serial: '42' } )
+  end
+  let(:ldap_provider) { 'ldapmain' }
+  let(:ldap_connection) { instance_double(::Net::LDAP) }
+  let(:ldap_person_name) { 'John Doe' }
+  let(:ldap_person_email) { 'john.doe@example.com' }
+  let(:ldap_entry) do
+    Net::LDAP::Entry.new.tap do |entry|
+      entry['dn'] = subject_ldap_dn
+      entry['uid'] = 'john doe'
+      entry['cn'] = ldap_person_name
+      entry['mail'] = ldap_person_email
+    end
+  end
+  let(:ldap_person) { ::Gitlab::Auth::LDAP::Person.new(ldap_entry, ldap_provider) }
+
+  before do
+    allow(described_class).to(
+      receive(:store).and_return(openssl_certificate_store))
+    allow(OpenSSL::X509::Certificate).to(
+      receive(:new).and_return(openssl_certificate))
+    allow(openssl_certificate_store).to(
+      receive(:verify).and_return(true))
+    allow(Net::LDAP).to receive(:new).and_return(ldap_connection)
+    allow(ldap_connection).to receive(:search).and_return([ldap_entry])
+  end
+
+  describe '#find_or_create_user' do
+    subject { described_class.new(ldap_provider, certificate_header).find_or_create_user }
+
+    context 'user already exists' do
+      let(:user) { create(:user) }
+
+      before do
+        create(:identity, { provider: ldap_provider,
+                            extern_uid: subject_ldap_dn,
+                            user: user })
+      end
+
+      it 'finds existing user' do
+        expect(subject).to eql(user)
+      end
+
+      it 'does not create new user' do
+        expect { subject }.not_to change { User.count }
+      end
+    end
+
+    context 'user does not exist' do
+      let(:user) { create(:user) }
+
+      it 'creates user' do
+        expect { subject }.to change { User.count }.from(0).to(1)
+      end
+
+      it 'creates user with correct attributes' do
+        subject
+
+        user = User.find_by(username: 'johndoe')
+
+        expect(user).not_to be_nil
+        expect(user.email).to eql(ldap_person_email)
+      end
+
+      it 'creates identity' do
+        expect { subject }.to change { Identity.count }.from(0).to(1)
+      end
+
+      it 'creates identity with correct attributes' do
+        subject
+
+        identity = Identity.find_by(provider: ldap_provider, extern_uid: subject_ldap_dn)
+
+        expect(identity).not_to be_nil
+      end
+
+      it 'calls Users::BuildService with correct params' do
+        user_params = { name: ldap_person_name,
+                        username: 'johndoe',
+                        email: ldap_person_email,
+                        extern_uid: 'subject_ldap_dn',
+                        provider: ldap_provider,
+                        password_automatically_set: true,
+                        skip_confirmation: true }
+
+        expect(Users::BuildService).to(
+          receive(:new)
+            .with(nil, hash_including(user_params))
+            .and_return(user_build_service))
+        expect(user_build_service).to(
+          receive(:execute).with(skip_authorization: true).and_return(user))
+
+        subject
+      end
+
+      context 'username generation' do
+        context 'uses LDAP uid' do
+          it 'creates user with correct username' do
+            subject
+
+            user = User.find_by(username: 'johndoe')
+            expect(user).not_to be_nil
+          end
+        end
+
+        context 'avoids conflicting namespaces' do
+          let!(:existing_user) { create(:user, username: 'johndoe') }
+
+          it 'creates user with correct username' do
+            expect { subject }.to change { User.count }.from(1).to(2)
+            expect(User.last.username).to eql('johndoe1')
+          end
+        end
+      end
+    end
+
+    it_behaves_like 'a valid certificate is required'
+  end
+
+  it_behaves_like 'a certificate store'
+end
diff --git a/ee/spec/requests/smartcard_controller_spec.rb b/ee/spec/requests/smartcard_controller_spec.rb
index d8c0b2bb979..a64ce03996d 100644
--- a/ee/spec/requests/smartcard_controller_spec.rb
+++ b/ee/spec/requests/smartcard_controller_spec.rb
@@ -3,24 +3,14 @@
 require 'spec_helper'
 
 describe SmartcardController, type: :request do
-  let(:subject_dn) { '/O=Random Corp Ltd/CN=gitlab-user/emailAddress=gitlab-user@random-corp.org' }
-  let(:issuer_dn) { '/O=Random Corp Ltd/CN=Random Corp' }
+  include LdapHelpers
+
   let(:certificate_headers) { { 'X-SSL-CLIENT-CERTIFICATE': 'certificate' } }
   let(:openssl_certificate_store) { instance_double(OpenSSL::X509::Store) }
-  let(:openssl_certificate) { instance_double(OpenSSL::X509::Certificate, subject: subject_dn, issuer: issuer_dn) }
   let(:audit_event_service) { instance_double(AuditEventService) }
 
-  subject { post '/-/smartcard/auth', params: {}, headers: certificate_headers }
-
-  describe '#auth' do
+  shared_examples 'a client certificate authentication' do |auth_method|
     context 'with smartcard_auth enabled' do
-      before do
-        allow(Gitlab::Auth::Smartcard).to receive(:enabled?).and_return(true)
-        allow(Gitlab::Auth::Smartcard::Certificate).to receive(:store).and_return(openssl_certificate_store)
-        allow(OpenSSL::X509::Certificate).to receive(:new).and_return(openssl_certificate)
-        allow(openssl_certificate_store).to receive(:verify).and_return(true)
-      end
-
       it 'allows sign in' do
         subject
 
@@ -36,7 +26,7 @@ describe SmartcardController, type: :request do
       it 'logs audit event' do
         expect(AuditEventService).to(
           receive(:new)
-            .with(instance_of(User), instance_of(User), with: 'smartcard')
+            .with(instance_of(User), instance_of(User), with: auth_method)
             .and_return(audit_event_service))
         expect(audit_event_service).to receive_message_chain(:for_authentication, :security_event)
 
@@ -63,66 +53,159 @@ describe SmartcardController, type: :request do
           end
         end
       end
+    end
 
-      context 'user already exists' do
-        before do
-          user = create(:user)
-          create(:smartcard_identity, subject: subject_dn, issuer: issuer_dn, user: user)
-        end
+    context 'with smartcard_auth disabled' do
+      before do
+        allow(Gitlab::Auth::Smartcard).to receive(:enabled?).and_return(false)
+      end
 
-        it 'finds existing user' do
-          expect { subject }.not_to change { User.count }
-          expect(request.env['warden']).to be_authenticated
-        end
+      it 'renders 404' do
+        subject
+
+        expect(response).to have_gitlab_http_status(404)
       end
+    end
+  end
 
-      context 'certificate header formats from NGINX' do
-        shared_examples 'valid certificate header' do
-          it 'authenticates user' do
-            expect(Gitlab::Auth::Smartcard::Certificate).to receive(:new).with(expected_certificate).and_call_original
+  describe '#auth' do
+    let(:subject_dn) { '/O=Random Corp Ltd/CN=gitlab-user/emailAddress=gitlab-user@random-corp.org' }
+    let(:issuer_dn) { '/O=Random Corp Ltd/CN=Random Corp' }
+    let(:certificate_headers) { { 'X-SSL-CLIENT-CERTIFICATE': 'certificate' } }
+    let(:openssl_certificate_store) { instance_double(OpenSSL::X509::Store) }
+    let(:openssl_certificate) { instance_double(OpenSSL::X509::Certificate, subject: subject_dn, issuer: issuer_dn) }
+    let(:audit_event_service) { instance_double(AuditEventService) }
+
+    before do
+      allow(Gitlab::Auth::Smartcard).to receive(:enabled?).and_return(true)
+      allow(Gitlab::Auth::Smartcard::Certificate).to receive(:store).and_return(openssl_certificate_store)
+      allow(openssl_certificate_store).to receive(:verify).and_return(true)
+      allow(OpenSSL::X509::Certificate).to receive(:new).and_return(openssl_certificate)
+    end
 
-            subject
+    subject { post '/-/smartcard/auth', params: {}, headers: certificate_headers }
 
-            expect(request.env['warden']).to be_authenticated
-          end
-        end
+    it_behaves_like 'a client certificate authentication', 'smartcard'
 
-        let(:expected_certificate) { "-----BEGIN CERTIFICATE-----\nrow\nrow\n-----END CERTIFICATE-----" }
+    context 'user already exists' do
+      before do
+        user = create(:user)
+        create(:smartcard_identity, subject: subject_dn, issuer: issuer_dn, user: user)
+      end
 
-        context 'escaped format' do
-          let(:certificate_headers) { { 'X-SSL-CLIENT-CERTIFICATE': '-----BEGIN%20CERTIFICATE-----%0Arow%0Arow%0A-----END%20CERTIFICATE-----' } }
+      it 'finds existing user' do
+        expect { subject }.not_to change { User.count }
+        expect(request.env['warden']).to be_authenticated
+      end
+    end
 
-          it_behaves_like 'valid certificate header'
-        end
+    context 'certificate header formats from NGINX' do
+      shared_examples 'valid certificate header' do
+        it 'authenticates user' do
+          expect(Gitlab::Auth::Smartcard::Certificate).to receive(:new).with(expected_certificate).and_call_original
 
-        context 'deprecated format' do
-          let(:certificate_headers) { { 'X-SSL-CLIENT-CERTIFICATE': '-----BEGIN CERTIFICATE----- row row -----END CERTIFICATE-----' } }
+          subject
 
-          it_behaves_like 'valid certificate header'
+          expect(request.env['warden']).to be_authenticated
         end
       end
 
-      context 'missing certificate headers' do
-        let(:certificate_headers) { nil }
+      let(:expected_certificate) { "-----BEGIN CERTIFICATE-----\nrow\nrow\n-----END CERTIFICATE-----" }
 
-        it 'renders 401' do
-          subject
+      context 'escaped format' do
+        let(:certificate_headers) { { 'X-SSL-CLIENT-CERTIFICATE': '-----BEGIN%20CERTIFICATE-----%0Arow%0Arow%0A-----END%20CERTIFICATE-----' } }
 
-          expect(response).to have_gitlab_http_status(401)
-          expect(request.env['warden']).not_to be_authenticated
-        end
+        it_behaves_like 'valid certificate header'
       end
-    end
 
-    context 'with smartcard_auth disabled' do
-      before do
-        allow(Gitlab::Auth::Smartcard).to receive(:enabled?).and_return(false)
+      context 'deprecated format' do
+        let(:certificate_headers) { { 'X-SSL-CLIENT-CERTIFICATE': '-----BEGIN CERTIFICATE----- row row -----END CERTIFICATE-----' } }
+
+        it_behaves_like 'valid certificate header'
       end
+    end
 
-      it 'renders 404' do
+    context 'missing certificate headers' do
+      let(:certificate_headers) { nil }
+
+      it 'renders 401' do
         subject
 
-        expect(response).to have_gitlab_http_status(404)
+        expect(response).to have_gitlab_http_status(401)
+        expect(request.env['warden']).not_to be_authenticated
+      end
+    end
+  end
+
+  describe '#ldap_auth ' do
+    let(:subject_ldap_dn) { 'uid=john doe,ou=people,dc=example,dc=com' }
+    let(:issuer_dn) { 'CN=Random Corp,O=Random Corp Ltd,C=US' }
+    let(:issuer) { instance_double(OpenSSL::X509::Name, to_s: issuer_dn) }
+    let(:serial) { '42' }
+    let(:openssl_certificate) do
+      instance_double(OpenSSL::X509::Certificate,
+                      issuer: issuer, serial: serial)
+    end
+
+    let(:ldap_connection) { instance_double(::Net::LDAP) }
+    let(:ldap_entry) do
+      Net::LDAP::Entry.new.tap do |entry|
+        entry['dn'] = subject_ldap_dn
+        entry['uid'] = 'john doe'
+        entry['cn'] = 'John Doe'
+        entry['mail'] = 'john.doe@example.com'
+      end
+    end
+    let(:ldap_user_search_scope) { 'dc=example,dc=com' }
+    let(:ldap_search_params) do
+      { attributes: array_including('dn', 'cn', 'mail', 'uid', 'userid'),
+        base: ldap_user_search_scope,
+        filter: Net::LDAP::Filter.ex(
+          'userCertificate:certificateExactMatch',
+          "{ serialNumber #{serial}, issuer \"#{issuer_dn}\" }") }
+    end
+
+    subject do
+      post('/-/smartcard/ldap_auth',
+           { params: { provider: 'ldapmain' },
+             headers: certificate_headers } )
+    end
+
+    before do
+      allow(Gitlab::Auth::Smartcard).to receive(:enabled?).and_return(true)
+
+      allow(Gitlab::Auth::Smartcard::LDAPCertificate).to(
+        receive(:store).and_return(openssl_certificate_store))
+      allow(openssl_certificate_store).to receive(:verify).and_return(true)
+
+      allow(OpenSSL::X509::Certificate).to(
+        receive(:new).and_return(openssl_certificate))
+
+      allow(Net::LDAP).to receive(:new).and_return(ldap_connection)
+      allow(ldap_connection).to(
+        receive(:search).with(ldap_search_params).and_return([ldap_entry]))
+    end
+
+    it_behaves_like 'a client certificate authentication', 'smartcard_ldap'
+
+    it 'sets correct parameters for LDAP search' do
+      expect(ldap_connection).to(
+        receive(:search).with(ldap_search_params).and_return([ldap_entry]))
+
+      subject
+    end
+
+    context 'user already exists' do
+      before do
+        user = create(:user)
+        create(:identity, { provider: 'ldapmain',
+                            extern_uid: subject_ldap_dn,
+                            user: user })
+      end
+
+      it 'finds existing user' do
+        expect { subject }.not_to change { User.count }
+        expect(request.env['warden']).to be_authenticated
       end
     end
   end
diff --git a/ee/spec/support/shared_examples/smartcard_certificate_store.rb b/ee/spec/support/shared_examples/smartcard_certificate_store.rb
new file mode 100644
index 00000000000..8a356d5d483
--- /dev/null
+++ b/ee/spec/support/shared_examples/smartcard_certificate_store.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+shared_examples 'a certificate store' do
+  describe '.store' do
+    before do
+      allow(Gitlab.config.smartcard).to receive(:ca_file).and_return('ca_file')
+      allow(described_class).to receive(:store).and_call_original
+      allow(OpenSSL::X509::Certificate).to receive(:new).and_call_original
+
+      clear_store
+    end
+
+    after do
+      clear_store
+    end
+
+    subject { described_class.store }
+
+    context 'file does not exist' do
+      it 'raises error' do
+        expect { subject }.to(
+          raise_error(Gitlab::Auth::Smartcard::Certificate::InvalidCAFilePath))
+      end
+    end
+
+    context 'smartcard ca_file is not a valid certificate' do
+      it 'raises error' do
+        expect(File).to(
+          receive(:read).with('ca_file').and_return('invalid certificate'))
+        expect { subject }.to(
+          raise_error(Gitlab::Auth::Smartcard::Certificate::InvalidCertificate))
+      end
+    end
+  end
+
+  def clear_store
+    described_class.remove_instance_variable(:@store)
+  rescue NameError
+    # raised if @store was not set; ignore
+  end
+end
+
+shared_examples 'a valid certificate is required' do
+  context 'invalid certificate' do
+    it 'returns nil' do
+      allow(openssl_certificate_store).to receive(:verify).and_return(false)
+
+      expect(subject).to be_nil
+    end
+  end
+
+  context 'incorrect certificate' do
+    it 'returns nil' do
+      allow(OpenSSL::X509::Certificate).to receive(:new).and_call_original
+
+      expect(subject).to be_nil
+    end
+  end
+end
diff --git a/lib/gitlab/auth/ldap/adapter.rb b/lib/gitlab/auth/ldap/adapter.rb
index 8c001b18e13..32ab89d8786 100644
--- a/lib/gitlab/auth/ldap/adapter.rb
+++ b/lib/gitlab/auth/ldap/adapter.rb
@@ -32,14 +32,7 @@ module Gitlab
 
         def users(fields, value, limit = nil)
           options = user_options(Array(fields), value, limit)
-
-          entries = ldap_search(options).select do |entry|
-            entry.respond_to? config.uid
-          end
-
-          entries.map do |entry|
-            Gitlab::Auth::LDAP::Person.new(entry, provider)
-          end
+          users_search(options)
         end
 
         def user(*args)
@@ -92,6 +85,16 @@ module Gitlab
           SEARCH_RETRY_FACTOR[retry_number] * config.timeout
         end
 
+        def users_search(options)
+          entries = ldap_search(options).select do |entry|
+            entry.respond_to? config.uid
+          end
+
+          entries.map do |entry|
+            Gitlab::Auth::LDAP::Person.new(entry, provider)
+          end
+        end
+
         def user_options(fields, value, limit)
           options = {
             attributes: Gitlab::Auth::LDAP::Person.ldap_attributes(config),
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 81b1ffad3df..ce9f9880d4e 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -8363,12 +8363,18 @@ msgstr ""
 msgid "Sign in to \"%{group_name}\""
 msgstr ""
 
+msgid "Sign in using smart card"
+msgstr ""
+
 msgid "Sign in via 2FA code"
 msgstr ""
 
 msgid "Sign in with Single Sign-On"
 msgstr ""
 
+msgid "Sign in with smart card"
+msgstr ""
+
 msgid "Sign out"
 msgstr ""
 
@@ -9933,6 +9939,9 @@ msgstr ""
 msgid "Use your global notification setting"
 msgstr ""
 
+msgid "Use your smart card to authenticate with the LDAP server."
+msgstr ""
+
 msgid "Used by members to sign in to your group in GitLab"
 msgstr ""
 
-- 
2.30.9