Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement SSPI authentication #1128

Merged
merged 2 commits into from Mar 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
11 changes: 9 additions & 2 deletions README.rst
Expand Up @@ -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
<https://magicstack.github.io/asyncpg/current/installation.html>`_.


Basic Usage
-----------
Expand Down
23 changes: 19 additions & 4 deletions asyncpg/connect_utils.py
Expand Up @@ -57,6 +57,7 @@ def parse(cls, sslmode):
'server_settings',
'target_session_attrs',
'krbsrvname',
'gsslib',
])


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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',
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 8 additions & 2 deletions asyncpg/connection.py
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
)


Expand Down
6 changes: 4 additions & 2 deletions asyncpg/protocol/coreproto.pxd
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
67 changes: 45 additions & 22 deletions asyncpg/protocol/coreproto.pyx
Expand Up @@ -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()
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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:
Expand Down
29 changes: 22 additions & 7 deletions docs/installation.rst
Expand Up @@ -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 <https://github.com/pythongssapi/python-gssapi/issues/200#issuecomment-1032934269>`_.)

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 <https://web.mit.edu/kerberos/dist/>`_.
* Set the ``gsslib`` parameter or the ``PGGSSLIB`` environment variable to
`gssapi` when connecting.


Building from source
Expand Down
6 changes: 4 additions & 2 deletions pyproject.toml
Expand Up @@ -35,15 +35,17 @@ 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',
'flake8-pyi~=24.1.0',
'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 = [
Expand Down