From 1aab2094d82104d5eee2cffcfd0c7e7347d4c5b8 Mon Sep 17 00:00:00 2001 From: Eugene Toder Date: Mon, 11 Mar 2024 13:34:24 -0400 Subject: [PATCH] Implement SSPI authentication (#1128) SSPI is a Windows technology for secure authentication. SSPI and GSSAPI interoperate as clients and servers. Postgres documentation recommends using SSPI on Windows clients and servers and GSSAPI on non-Windows platforms[1]. Changes in this PR: * Support AUTH_REQUIRED_SSPI server request. This is the same as AUTH_REQUIRED_GSS, except it allows negotiation with SSPI clients. * Allow using SSPI on the client. Which library to use can be specified using the `gsslib` connection parameter. * Use SSPI instead of GSSAPI on Windows by default. The latter requires installing Kerberos for Windows and is unlikely to work out of the box. Closes #142 [1] https://www.postgresql.org/docs/current/sspi-auth.html --- README.rst | 11 +++- asyncpg/connect_utils.py | 23 +++++-- asyncpg/connection.py | 10 ++- asyncpg/protocol/coreproto.pxd | 6 +- asyncpg/protocol/coreproto.pyx | 67 ++++++++++++------- docs/installation.rst | 29 +++++++-- pyproject.toml | 6 +- tests/test_connect.py | 113 +++++++++++++++++++++++++++++++-- 8 files changed, 219 insertions(+), 46 deletions(-) diff --git a/README.rst b/README.rst index 438b4c44..0d078d82 100644 --- a/README.rst +++ b/README.rst @@ -58,11 +58,18 @@ This enables asyncpg to have easy-to-use support for: Installation ------------ -asyncpg is available on PyPI and has no dependencies. -Use pip to install:: +asyncpg is available on PyPI. When not using GSSAPI/SSPI authentication it +has no dependencies. Use pip to install:: $ pip install asyncpg +If you need GSSAPI/SSPI authentication, use:: + + $ pip install 'asyncpg[gssauth]' + +For more details, please `see the documentation +`_. + Basic Usage ----------- diff --git a/asyncpg/connect_utils.py b/asyncpg/connect_utils.py index 8039d1b4..0631f976 100644 --- a/asyncpg/connect_utils.py +++ b/asyncpg/connect_utils.py @@ -57,6 +57,7 @@ def parse(cls, sslmode): 'server_settings', 'target_session_attrs', 'krbsrvname', + 'gsslib', ]) @@ -262,7 +263,7 @@ def _dot_postgresql_path(filename) -> typing.Optional[pathlib.Path]: def _parse_connect_dsn_and_args(*, dsn, host, port, user, password, passfile, database, ssl, direct_tls, server_settings, - target_session_attrs, krbsrvname): + target_session_attrs, krbsrvname, gsslib): # `auth_hosts` is the version of host information for the purposes # of reading the pgpass file. auth_hosts = None @@ -389,6 +390,11 @@ def _parse_connect_dsn_and_args(*, dsn, host, port, user, if krbsrvname is None: krbsrvname = val + if 'gsslib' in query: + val = query.pop('gsslib') + if gsslib is None: + gsslib = val + if query: if server_settings is None: server_settings = query @@ -659,12 +665,21 @@ def _parse_connect_dsn_and_args(*, dsn, host, port, user, if krbsrvname is None: krbsrvname = os.getenv('PGKRBSRVNAME') + if gsslib is None: + gsslib = os.getenv('PGGSSLIB') + if gsslib is None: + gsslib = 'sspi' if _system == 'Windows' else 'gssapi' + if gsslib not in {'gssapi', 'sspi'}: + raise exceptions.ClientConfigurationError( + "gsslib parameter must be either 'gssapi' or 'sspi'" + ", got {!r}".format(gsslib)) + params = _ConnectionParameters( user=user, password=password, database=database, ssl=ssl, sslmode=sslmode, direct_tls=direct_tls, server_settings=server_settings, target_session_attrs=target_session_attrs, - krbsrvname=krbsrvname) + krbsrvname=krbsrvname, gsslib=gsslib) return addrs, params @@ -675,7 +690,7 @@ def _parse_connect_arguments(*, dsn, host, port, user, password, passfile, max_cached_statement_lifetime, max_cacheable_statement_size, ssl, direct_tls, server_settings, - target_session_attrs, krbsrvname): + target_session_attrs, krbsrvname, gsslib): local_vars = locals() for var_name in {'max_cacheable_statement_size', 'max_cached_statement_lifetime', @@ -705,7 +720,7 @@ def _parse_connect_arguments(*, dsn, host, port, user, password, passfile, direct_tls=direct_tls, database=database, server_settings=server_settings, target_session_attrs=target_session_attrs, - krbsrvname=krbsrvname) + krbsrvname=krbsrvname, gsslib=gsslib) config = _ClientConfiguration( command_timeout=command_timeout, diff --git a/asyncpg/connection.py b/asyncpg/connection.py index bf5f6db6..e54d6df8 100644 --- a/asyncpg/connection.py +++ b/asyncpg/connection.py @@ -2008,7 +2008,8 @@ async def connect(dsn=None, *, record_class=protocol.Record, server_settings=None, target_session_attrs=None, - krbsrvname=None): + krbsrvname=None, + gsslib=None): r"""A coroutine to establish a connection to a PostgreSQL server. The connection parameters may be specified either as a connection @@ -2240,6 +2241,10 @@ async def connect(dsn=None, *, Kerberos service name to use when authenticating with GSSAPI. This must match the server configuration. Defaults to 'postgres'. + :param str gsslib: + GSS library to use for GSSAPI/SSPI authentication. Can be 'gssapi' + or 'sspi'. Defaults to 'sspi' on Windows and 'gssapi' otherwise. + :return: A :class:`~asyncpg.connection.Connection` instance. Example: @@ -2309,7 +2314,7 @@ async def connect(dsn=None, *, Added the *target_session_attrs* parameter. .. versionchanged:: 0.30.0 - Added the *krbsrvname* parameter. + Added the *krbsrvname* and *gsslib* parameters. .. _SSLContext: https://docs.python.org/3/library/ssl.html#ssl.SSLContext .. _create_default_context: @@ -2354,6 +2359,7 @@ async def connect(dsn=None, *, max_cacheable_statement_size=max_cacheable_statement_size, target_session_attrs=target_session_attrs, krbsrvname=krbsrvname, + gsslib=gsslib, ) diff --git a/asyncpg/protocol/coreproto.pxd b/asyncpg/protocol/coreproto.pxd index 612d8cae..8a398de9 100644 --- a/asyncpg/protocol/coreproto.pxd +++ b/asyncpg/protocol/coreproto.pxd @@ -91,7 +91,7 @@ cdef class CoreProtocol: object con_params # Instance of SCRAMAuthentication SCRAMAuthentication scram - # Instance of gssapi.SecurityContext + # Instance of gssapi.SecurityContext or sspilib.SecurityContext object gss_ctx readonly int32_t backend_pid @@ -138,7 +138,9 @@ cdef class CoreProtocol: cdef _auth_password_message_md5(self, bytes salt) cdef _auth_password_message_sasl_initial(self, list sasl_auth_methods) cdef _auth_password_message_sasl_continue(self, bytes server_response) - cdef _auth_gss_init(self) + cdef _auth_gss_init_gssapi(self) + cdef _auth_gss_init_sspi(self, bint negotiate) + cdef _auth_gss_get_spn(self) cdef _auth_gss_step(self, bytes server_response) cdef _write(self, buf) diff --git a/asyncpg/protocol/coreproto.pyx b/asyncpg/protocol/coreproto.pyx index 7a2b257e..fd65327b 100644 --- a/asyncpg/protocol/coreproto.pyx +++ b/asyncpg/protocol/coreproto.pyx @@ -38,7 +38,8 @@ cdef class CoreProtocol: self.encoding = 'utf-8' # type of `scram` is `SCRAMAuthentcation` self.scram = None - # type of `gss_ctx` is `gssapi.SecurityContext` + # type of `gss_ctx` is `gssapi.SecurityContext` or + # `sspilib.SecurityContext` self.gss_ctx = None self._reset_result() @@ -635,29 +636,33 @@ cdef class CoreProtocol: ) self.scram = None - elif status == AUTH_REQUIRED_GSS: - self._auth_gss_init() - self.auth_msg = self._auth_gss_step(None) + elif status in (AUTH_REQUIRED_GSS, AUTH_REQUIRED_SSPI): + # AUTH_REQUIRED_SSPI is the same as AUTH_REQUIRED_GSS, except that + # it uses protocol negotiation with SSPI clients. Both methods use + # AUTH_REQUIRED_GSS_CONTINUE for subsequent authentication steps. + if self.gss_ctx is not None: + self.result_type = RESULT_FAILED + self.result = apg_exc.InterfaceError( + 'duplicate GSSAPI/SSPI authentication request') + else: + if self.con_params.gsslib == 'gssapi': + self._auth_gss_init_gssapi() + else: + self._auth_gss_init_sspi(status == AUTH_REQUIRED_SSPI) + self.auth_msg = self._auth_gss_step(None) elif status == AUTH_REQUIRED_GSS_CONTINUE: server_response = self.buffer.consume_message() self.auth_msg = self._auth_gss_step(server_response) - elif status in (AUTH_REQUIRED_KERBEROS, AUTH_REQUIRED_SCMCRED, - AUTH_REQUIRED_SSPI): - self.result_type = RESULT_FAILED - self.result = apg_exc.InterfaceError( - 'unsupported authentication method requested by the ' - 'server: {!r}'.format(AUTH_METHOD_NAME[status])) - else: self.result_type = RESULT_FAILED self.result = apg_exc.InterfaceError( 'unsupported authentication method requested by the ' - 'server: {}'.format(status)) + 'server: {!r}'.format(AUTH_METHOD_NAME.get(status, status))) - if status not in [AUTH_SASL_CONTINUE, AUTH_SASL_FINAL, - AUTH_REQUIRED_GSS_CONTINUE]: + if status not in (AUTH_SASL_CONTINUE, AUTH_SASL_FINAL, + AUTH_REQUIRED_GSS_CONTINUE): self.buffer.discard_message() cdef _auth_password_message_cleartext(self): @@ -714,25 +719,43 @@ cdef class CoreProtocol: return msg - cdef _auth_gss_init(self): + cdef _auth_gss_init_gssapi(self): try: import gssapi except ModuleNotFoundError: - raise RuntimeError( - 'gssapi module not found; please install asyncpg[gssapi] to ' - 'use asyncpg with Kerberos or GSSAPI authentication' + raise apg_exc.InterfaceError( + 'gssapi module not found; please install asyncpg[gssauth] to ' + 'use asyncpg with Kerberos/GSSAPI/SSPI authentication' ) from None + self.gss_ctx = gssapi.SecurityContext( + name=gssapi.Name(self._auth_gss_get_spn()), usage='initiate') + + cdef _auth_gss_init_sspi(self, bint negotiate): + try: + import sspilib + except ModuleNotFoundError: + raise apg_exc.InterfaceError( + 'sspilib module not found; please install asyncpg[gssauth] to ' + 'use asyncpg with Kerberos/GSSAPI/SSPI authentication' + ) from None + + self.gss_ctx = sspilib.ClientSecurityContext( + target_name=self._auth_gss_get_spn(), + credential=sspilib.UserCredential( + protocol='Negotiate' if negotiate else 'Kerberos')) + + cdef _auth_gss_get_spn(self): service_name = self.con_params.krbsrvname or 'postgres' # find the canonical name of the server host if isinstance(self.address, str): - raise RuntimeError('GSSAPI authentication is only supported for ' - 'TCP/IP connections') + raise apg_exc.InternalClientError( + 'GSSAPI/SSPI authentication is only supported for TCP/IP ' + 'connections') host = self.address[0] host_cname = socket.gethostbyname_ex(host)[0] - gss_name = gssapi.Name(f'{service_name}/{host_cname}') - self.gss_ctx = gssapi.SecurityContext(name=gss_name, usage='initiate') + return f'{service_name}/{host_cname}' cdef _auth_gss_step(self, bytes server_response): cdef: diff --git a/docs/installation.rst b/docs/installation.rst index 6d9ec2ef..bada7998 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -4,20 +4,35 @@ Installation ============ -**asyncpg** has no external dependencies and the recommended way to -install it is to use **pip**: +**asyncpg** has no external dependencies when not using GSSAPI/SSPI +authentication. The recommended way to install it is to use **pip**: .. code-block:: bash $ pip install asyncpg +If you need GSSAPI/SSPI authentication, the recommended way is to use -.. note:: +.. code-block:: bash + + $ pip install 'asyncpg[gssauth]' + +This installs SSPI support on Windows and GSSAPI support on non-Windows +platforms. SSPI and GSSAPI interoperate as clients and servers: an SSPI +client can authenticate to a GSSAPI server and vice versa. + +On Linux installing GSSAPI requires a working C compiler and Kerberos 5 +development files. The latter can be obtained by installing **libkrb5-dev** +package on Debian/Ubuntu or **krb5-devel** on RHEL/Fedora. (This is needed +because PyPI does not have Linux wheels for **gssapi**. See `here for the +details `_.) + +It is also possible to use GSSAPI on Windows: - It is recommended to use **pip** version **8.1** or later to take - advantage of the precompiled wheel packages. Older versions of pip - will ignore the wheel packages and install asyncpg from the source - package. In that case a working C compiler is required. + * `pip install gssapi` + * Install `Kerberos for Windows `_. + * Set the ``gsslib`` parameter or the ``PGGSSLIB`` environment variable to + `gssapi` when connecting. Building from source diff --git a/pyproject.toml b/pyproject.toml index 0019dadc..12f6ae68 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,8 +35,9 @@ dependencies = [ github = "https://github.com/MagicStack/asyncpg" [project.optional-dependencies] -gssapi = [ - 'gssapi', +gssauth = [ + 'gssapi; platform_system != "Windows"', + 'sspilib; platform_system == "Windows"', ] test = [ 'flake8~=6.1', @@ -44,6 +45,7 @@ test = [ 'uvloop>=0.15.3; platform_system != "Windows" and python_version < "3.12.0"', 'gssapi; platform_system == "Linux"', 'k5test; platform_system == "Linux"', + 'sspilib; platform_system == "Windows"', 'mypy~=1.8.0', ] docs = [ diff --git a/tests/test_connect.py b/tests/test_connect.py index ebf0e462..049aea26 100644 --- a/tests/test_connect.py +++ b/tests/test_connect.py @@ -13,6 +13,7 @@ import pathlib import platform import shutil +import socket import ssl import stat import tempfile @@ -45,6 +46,13 @@ CLIENT_SSL_KEY_FILE = os.path.join(CERTS, 'client.key.pem') CLIENT_SSL_PROTECTED_KEY_FILE = os.path.join(CERTS, 'client.key.protected.pem') +if _system == 'Windows': + DEFAULT_GSSLIB = 'sspi' + OTHER_GSSLIB = 'gssapi' +else: + DEFAULT_GSSLIB = 'gssapi' + OTHER_GSSLIB = 'sspi' + @contextlib.contextmanager def mock_dot_postgresql(*, ca=True, crl=False, client=False, protected=False): @@ -398,7 +406,10 @@ def setUpClass(cls): cls.realm.addprinc('postgres/localhost') cls.realm.extract_keytab('postgres/localhost', cls.realm.keytab) - cls.USERS = [(cls.realm.user_princ, 'gss', None)] + cls.USERS = [ + (cls.realm.user_princ, 'gss', None), + (f'wrong-{cls.realm.user_princ}', 'gss', None), + ] super().setUpClass() cls.cluster.override_connection_spec(host='localhost') @@ -427,13 +438,34 @@ async def test_auth_gssapi(self): await self.connect(user=self.realm.user_princ, krbsrvname='wrong') # Credentials mismatch. - self.realm.addprinc('wrong_user', 'password') - self.realm.kinit('wrong_user', 'password') with self.assertRaisesRegex( exceptions.InvalidAuthorizationSpecificationError, 'GSSAPI authentication failed for user' ): - await self.connect(user=self.realm.user_princ) + await self.connect(user=f'wrong-{self.realm.user_princ}') + + +@unittest.skipIf(_system != 'Windows', 'SSPI is only available on Windows') +class TestSspiAuthentication(BaseTestAuthentication): + @classmethod + def setUpClass(cls): + cls.username = f'{os.getlogin()}@{socket.gethostname()}' + cls.USERS = [ + (cls.username, 'sspi', None), + (f'wrong-{cls.username}', 'sspi', None), + ] + super().setUpClass() + + async def test_auth_sspi(self): + conn = await self.connect(user=self.username) + await conn.close() + + # Credentials mismatch. + with self.assertRaisesRegex( + exceptions.InvalidAuthorizationSpecificationError, + 'SSPI authentication failed for user' + ): + await self.connect(user=f'wrong-{self.username}') class TestConnectParams(tb.TestCase): @@ -666,6 +698,9 @@ class TestConnectParams(tb.TestCase): 'name': 'krbsrvname_2', 'dsn': 'postgresql://user@host/db?krbsrvname=srv_qs', 'krbsrvname': 'srv_kws', + 'env': { + 'PGKRBSRVNAME': 'srv_env', + }, 'result': ([('host', 5432)], { 'database': 'db', 'user': 'user', @@ -688,6 +723,69 @@ class TestConnectParams(tb.TestCase): }) }, + { + 'name': 'gsslib', + 'dsn': f'postgresql://user@host/db?gsslib={OTHER_GSSLIB}', + 'env': { + 'PGGSSLIB': 'ignored', + }, + 'result': ([('host', 5432)], { + 'database': 'db', + 'user': 'user', + 'target_session_attrs': 'any', + 'gsslib': OTHER_GSSLIB, + }) + }, + + { + 'name': 'gsslib_2', + 'dsn': 'postgresql://user@host/db?gsslib=ignored', + 'gsslib': OTHER_GSSLIB, + 'env': { + 'PGGSSLIB': 'ignored', + }, + 'result': ([('host', 5432)], { + 'database': 'db', + 'user': 'user', + 'target_session_attrs': 'any', + 'gsslib': OTHER_GSSLIB, + }) + }, + + { + 'name': 'gsslib_3', + 'dsn': 'postgresql://user@host/db', + 'env': { + 'PGGSSLIB': OTHER_GSSLIB, + }, + 'result': ([('host', 5432)], { + 'database': 'db', + 'user': 'user', + 'target_session_attrs': 'any', + 'gsslib': OTHER_GSSLIB, + }) + }, + + { + 'name': 'gsslib_4', + 'dsn': 'postgresql://user@host/db', + 'result': ([('host', 5432)], { + 'database': 'db', + 'user': 'user', + 'target_session_attrs': 'any', + 'gsslib': DEFAULT_GSSLIB, + }) + }, + + { + 'name': 'gsslib_5', + 'dsn': 'postgresql://user@host/db?gsslib=invalid', + 'error': ( + exceptions.ClientConfigurationError, + "gsslib parameter must be either 'gssapi' or 'sspi'" + ), + }, + { 'name': 'dsn_ipv6_multi_host', 'dsn': 'postgresql://user@[2001:db8::1234%25eth0],[::1]/db', @@ -972,6 +1070,7 @@ def run_testcase(self, testcase): server_settings = testcase.get('server_settings') target_session_attrs = testcase.get('target_session_attrs') krbsrvname = testcase.get('krbsrvname') + gsslib = testcase.get('gsslib') expected = testcase.get('result') expected_error = testcase.get('error') @@ -997,7 +1096,7 @@ def run_testcase(self, testcase): direct_tls=False, server_settings=server_settings, target_session_attrs=target_session_attrs, - krbsrvname=krbsrvname) + krbsrvname=krbsrvname, gsslib=gsslib) params = { k: v for k, v in params._asdict().items() @@ -1019,6 +1118,10 @@ def run_testcase(self, testcase): # Avoid the hassle of specifying direct_tls # unless explicitly tested for params.pop('direct_tls', False) + if 'gsslib' not in expected[1]: + # Avoid the hassle of specifying gsslib + # unless explicitly tested for + params.pop('gsslib', None) self.assertEqual(expected, result, 'Testcase: {}'.format(testcase))