Repository: privacyidea/privacyidea

Issue:
Support TLS v1.3 in LDAP Resolver
When verifying TLS in LDAP resolvers, we can choose to force a TLS protocol version, but not v1.3.

- Support TLS v1.3 enforcing
- We may be also able to force a TLS protocol version when NOT verifying the certificate


Discussion:
The question is, if LDAP3 supports TLS 1.3.
It did obviously not in the past. Check it and if available implement it.

Resolving patch:
diff --git a/migrations/versions/9155f0d3d028_.py b/migrations/versions/9155f0d3d028_.py
new file mode 100644
--- /dev/null
+++ b/migrations/versions/9155f0d3d028_.py
@@ -0,0 +1,98 @@
+"""v3.6: set TLS v1.0 for existing ldap resolvers to keep legacy behavior
+
+Revision ID: 9155f0d3d028
+Revises: d5870fd2f2a4
+Create Date: 2021-03-23 14:25:48.425762
+privacyIDEA Version: 3.6
+
+"""
+
+# revision identifiers, used by Alembic.
+revision = '9155f0d3d028'
+down_revision = 'd5870fd2f2a4'
+
+from alembic import op
+from sqlalchemy import orm
+import sqlalchemy as sa
+from sqlalchemy.ext.declarative import declarative_base
+from sqlalchemy.schema import Sequence
+
+Base = declarative_base()
+
+
+class Resolver(Base):
+    __tablename__ = 'resolver'
+    __table_args__ = {'mysql_row_format': 'DYNAMIC'}
+    id = sa.Column(sa.Integer, Sequence("resolver_seq"), primary_key=True,
+                   nullable=False)
+    name = sa.Column(sa.Unicode(255), default=u"",
+                     unique=True, nullable=False)
+    rtype = sa.Column(sa.Unicode(255), default=u"",
+                      nullable=False)
+    config_list = orm.relationship('ResolverConfig',
+                                  lazy='select',
+                                  backref='resolver')
+
+
+class ResolverConfig(Base):
+    __tablename__ = 'resolverconfig'
+    id = sa.Column(sa.Integer, Sequence("resolverconf_seq"), primary_key=True)
+    resolver_id = sa.Column(sa.Integer,
+                            sa.ForeignKey('resolver.id'))
+    Key = sa.Column(sa.Unicode(255), nullable=False)
+    Value = sa.Column(sa.Unicode(2000), default=u'')
+    Type = sa.Column(sa.Unicode(2000), default=u'')
+    Description = sa.Column(sa.Unicode(2000), default=u'')
+    __table_args__ = (sa.UniqueConstraint('resolver_id',
+                                          'Key',
+                                          name='rcix_2'),
+                      {'mysql_row_format': 'DYNAMIC'})
+
+
+
+def upgrade():
+    try:
+        bind = op.get_bind()
+        session = orm.Session(bind=bind)
+        ldapresolvers_list = []
+        # get all ldap resolvers
+        for row in session.query(Resolver).filter(Resolver.rtype == 'ldapresolver'):
+            ldapresolvers_list.append(row.id)
+        # set the legacy TLS Version to v1.0 (=3) for all existing ldap resolvers
+        for resolver_id in ldapresolvers_list:
+            base_query = session.query(ResolverConfig).filter(ResolverConfig.resolver_id == resolver_id)
+            # check for LDAPS
+            LDAPURI_qry = base_query.filter(ResolverConfig.Key == "LDAPURI").first()
+            if LDAPURI_qry:
+                LDAPS = True if LDAPURI_qry.Value.startswith('ldaps') else False
+            else:
+                LDAPS = None
+            # check for STARTTLS
+            START_TLS_qry = base_query.filter(ResolverConfig.Key == "START_TLS").first()
+            if START_TLS_qry:
+                START_TLS = True if START_TLS_qry.Value.lower() == 'true' else False
+            else:
+                START_TLS = False
+            # check if TLS_VERSION is already set. If not, we will use TLS v1.0 as a robust default
+            TLS_VERSION_qry = base_query.filter(ResolverConfig.Key == "TLS_VERSION").first()
+            if TLS_VERSION_qry:
+                TLS_VERSION = TLS_VERSION_qry.Value
+            else:
+                TLS_VERSION = None
+            # For resolvers that had TLS_VERSION set but empty or not set at all use TLS v1.0
+            if (LDAPS or START_TLS):
+                if TLS_VERSION_qry is None:
+                    session.add(ResolverConfig(resolver_id=resolver_id, Key="TLS_VERSION", Value="3",
+                                               Type=u'int'))
+                elif TLS_VERSION == "":
+                    base_query.filter(ResolverConfig.Key == "TLS_VERSION").update({"Value": "3"})
+        session.commit()
+
+    except Exception as exx:
+        session.rollback()
+        print("Failed to set TLS v1.0 for all existing ldap resolvers to keep the legacy behavior.")
+        print(exx)
+
+
+def downgrade():
+    pass
diff --git a/privacyidea/api/resolver.py b/privacyidea/api/resolver.py
--- a/privacyidea/api/resolver.py
+++ b/privacyidea/api/resolver.py
@@ -117,16 +117,25 @@ def set_resolver(resolver=None):
         LDAP:
             * LDAPURI
             * LDAPBASE
+            * AUTHTYPE
             * BINDDN
             * BINDPW
             * TIMEOUT
+            * CACHE_TIMEOUT
             * SIZELIMIT
             * LOGINNAMEATTRIBUTE
             * LDAPSEARCHFILTER
             * LDAPFILTER
+            * LOGINNAMEATTRIBUTE
+            * MULTIVALUEATTRIBUTES
             * USERINFO
+            * UIDTYPE
             * NOREFERRALS        - True|False
+            * NOSCHEMAS          - True|False
             * EDITABLE           - True|False
+            * START_TLS          - True|False
+            * TLS_VERIFY         - True|False
+            * TLS_VERSION
         SQL:
             * Database
             * Driver
diff --git a/privacyidea/lib/resolvers/LDAPIdResolver.py b/privacyidea/lib/resolvers/LDAPIdResolver.py
--- a/privacyidea/lib/resolvers/LDAPIdResolver.py
+++ b/privacyidea/lib/resolvers/LDAPIdResolver.py
@@ -110,6 +110,9 @@
 else:
     DEFAULT_CA_FILE = "/etc/privacyidea/ldap-ca.crt"
 
+DEFAULT_TLS_PROTOCOL = ssl.PROTOCOL_TLS
+
+TLS_OPTIONS_1_3 = (ssl.OP_NO_TLSv1_2, ssl.OP_NO_TLSv1_1, ssl.OP_NO_TLSv1, ssl.OP_NO_SSLv3)
 
 class LockingServerPool(ldap3.ServerPool):
     """
@@ -484,6 +487,32 @@ def _bind(self):
                 raise Exception("Wrong credentials")
             self.i_am_bound = True
 
+    @staticmethod
+    def _get_tls_context(ldap_uri=None, start_tls=False, tls_version=None, tls_verify=None,
+                         tls_ca_file=None, tls_options=None):
+        """
+        This method creates the Tls object to be used with ldap3.
+
+        """
+        if ldap_uri.lower().startswith("ldaps") or is_true(start_tls):
+            if not tls_version:
+                tls_version = int(DEFAULT_TLS_PROTOCOL)
+            # If TLS_VERSION is 2, set tls_options to use TLS v1.3
+            if not tls_options:
+                tls_options = TLS_OPTIONS_1_3 if tls_version == int(ssl.PROTOCOL_TLS) else None
+            if tls_verify:
+                tls_ca_file = tls_ca_file or DEFAULT_CA_FILE
+            else:
+                tls_verify = ssl.CERT_NONE
+            tls_context = Tls(validate=tls_verify,
+                              version=int(tls_version),
+                              ssl_options=tls_options,
+                              ca_certs_file=tls_ca_file)
+        else:
+            tls_context = None
+
+        return tls_context
+
     @cache
     def getUserInfo(self, userId):
         """
@@ -764,17 +793,13 @@ def loadConfig(self, config):
         self.resolverId = self.uri
         self.authtype = config.get("AUTHTYPE", AUTHTYPE.SIMPLE)
         self.tls_verify = is_true(config.get("TLS_VERIFY", False))
-        # Fallback to TLSv1. (int: 3, TLSv1.1: 4, v1.2: 5)
-        self.tls_version = int(config.get("TLS_VERSION") or ssl.PROTOCOL_TLSv1)
-
+        # Fallback to DEFAULT_TLS_PROTOCOL (TLSv1: 3, TLSv1.1: 4, v1.2: 5, TLS negotiation: 2)
+        self.tls_version = int(config.get("TLS_VERSION") or DEFAULT_TLS_PROTOCOL)
         self.tls_ca_file = config.get("TLS_CA_FILE") or DEFAULT_CA_FILE
-        if self.tls_verify and (self.uri.lower().startswith("ldaps") or
-                                    self.start_tls):
-            self.tls_context = Tls(validate=ssl.CERT_REQUIRED,
-                                   version=self.tls_version,
-                                   ca_certs_file=self.tls_ca_file)
-        else:
-            self.tls_context = None
+        self.tls_context = self._get_tls_context(ldap_uri=self.uri, start_tls=self.start_tls,
+                                            tls_version=self.tls_version,
+                                            tls_verify=self.tls_verify,
+                                            tls_ca_file=self.tls_ca_file)
         self.serverpool_persistent = is_true(config.get("SERVERPOOL_PERSISTENT", False))
         self.serverpool_rounds = int(config.get("SERVERPOOL_ROUNDS") or SERVERPOOL_ROUNDS)
         self.serverpool_skip = int(config.get("SERVERPOOL_SKIP") or SERVERPOOL_SKIP)
@@ -983,16 +1008,12 @@ def testconnection(cls, param):
         size_limit = int(param.get("SIZELIMIT", 500))
         serverpool_rounds = int(param.get("SERVERPOOL_ROUNDS") or SERVERPOOL_ROUNDS)
         serverpool_skip = int(param.get("SERVERPOOL_SKIP") or SERVERPOOL_SKIP)
-        if is_true(param.get("TLS_VERIFY")) \
-                and (ldap_uri.lower().startswith("ldaps") or
-                                    param.get("START_TLS")):
-            tls_version = int(param.get("TLS_VERSION") or ssl.PROTOCOL_TLSv1)
-            tls_ca_file = param.get("TLS_CA_FILE") or DEFAULT_CA_FILE
-            tls_context = Tls(validate=ssl.CERT_REQUIRED,
-                              version=tls_version,
-                              ca_certs_file=tls_ca_file)
-        else:
-            tls_context = None
+        tls_context = cls._get_tls_context(ldap_uri=ldap_uri,
+                                           start_tls=param.get("START_TLS"),
+                                           tls_version=param.get("TLS_VERSION"),
+                                           tls_verify=param.get("TLS_VERIFY"),
+                                           tls_ca_file=param.get("TLS_CA_FILE"),
+                                           tls_options=None)
         get_info = get_info_configuration(is_true(param.get("NOSCHEMAS")))
         try:
             server_pool = cls.create_serverpool(ldap_uri, timeout,


Test patch:
diff --git a/tests/test_lib_resolver.py b/tests/test_lib_resolver.py
--- a/tests/test_lib_resolver.py
+++ b/tests/test_lib_resolver.py
@@ -1606,6 +1606,52 @@ def test_24_ldaps(self):
         for _, kwargs in ldap3mock.get_server_mock().call_args_list:
             self.assertIsNotNone(kwargs['tls'])
             self.assertTrue(kwargs['use_ssl'])
+            ldap3_tls_config = kwargs['tls'].__str__()
+            self.assertIn("protocol: 2", ldap3_tls_config)
+            self.assertIn("CA certificates file: present", ldap3_tls_config)
+            self.assertTrue("verify mode: VerifyMode.CERT_REQUIRED" in ldap3_tls_config
+                            or "verify mode: 2" in ldap3_tls_config
+                            or "verify mode: True" in ldap3_tls_config)
+
+    def test_24b_tls_options(self):
+        @ldap3mock.activate
+        def check_tls_version_ldap3(tls_version_pi, tls_version_ldap3):
+
+            # Check that START_TLS and TLS_VERIFY are actually passed to the ldap3 Connection
+            ldap3mock.setLDAPDirectory(LDAPDirectory)
+            config = {'LDAPURI': 'ldap://localhost',
+                      'LDAPBASE': 'o=test',
+                      'BINDDN': 'cn=manager,ou=example,o=test',
+                      'BINDPW': 'ldaptest',
+                      'LOGINNAMEATTRIBUTE': 'cn',
+                      'LDAPSEARCHFILTER': '(cn=*)',
+                      'USERINFO': '{ "username": "cn"}',
+                      'UIDTYPE': 'unknownType',
+                      'CACHE_TIMEOUT': 0,
+                      'START_TLS': '1',
+                      'TLS_VERIFY': '1'
+        }
+            config.update({"TLS_VERSION": tls_version_pi})
+            start_tls_resolver = LDAPResolver()
+            start_tls_resolver.loadConfig(config)
+            result = start_tls_resolver.getUserList({'username': '*'})
+            self.assertEqual(len(result), len(LDAPDirectory))
+            # We check two things:
+            # 1) start_tls has actually been called!
+            self.assertTrue(start_tls_resolver.l.start_tls_called)
+            # 2) All Server objects were constructed with a non-None TLS context and use_ssl=False
+            for _, kwargs in ldap3mock.get_server_mock().call_args_list:
+                self.assertIsNotNone(kwargs['tls'])
+                self.assertFalse(kwargs['use_ssl'])
+                ldap3_tls_config = kwargs['tls'].__str__()
+                self.assertIn(tls_version_ldap3, ldap3_tls_config)
+
+        tls_versions = {"": "protocol: 2",
+                        "5": "protocol: 5",
+                        "1234": "protocol: 1234"}
+        for tls_version_pi, tls_version_ldap3 in tls_versions.items():
+            check_tls_version_ldap3(tls_version_pi, tls_version_ldap3)
+        self.assertRaises(ValueError, check_tls_version_ldap3, "version is nonsense", "nothing to check")
 
     @ldap3mock.activate
     def test_25_LDAP_DN_with_utf8(self):