Skip to content

Commit

Permalink
Implement SSPI authentication (#1128)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
eltoder authored Mar 11, 2024
1 parent d42432b commit 1aab209
Show file tree
Hide file tree
Showing 8 changed files with 219 additions and 46 deletions.
11 changes: 9 additions & 2 deletions README.rst
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Loading

0 comments on commit 1aab209

Please sign in to comment.