Skip to content

Commit 72a5fda

Browse files
committed
Implement SSPI authentication
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
1 parent d42432b commit 72a5fda

File tree

6 files changed

+183
-35
lines changed

6 files changed

+183
-35
lines changed

asyncpg/connect_utils.py

+19-4
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ def parse(cls, sslmode):
5757
'server_settings',
5858
'target_session_attrs',
5959
'krbsrvname',
60+
'gsslib',
6061
])
6162

6263

@@ -262,7 +263,7 @@ def _dot_postgresql_path(filename) -> typing.Optional[pathlib.Path]:
262263
def _parse_connect_dsn_and_args(*, dsn, host, port, user,
263264
password, passfile, database, ssl,
264265
direct_tls, server_settings,
265-
target_session_attrs, krbsrvname):
266+
target_session_attrs, krbsrvname, gsslib):
266267
# `auth_hosts` is the version of host information for the purposes
267268
# of reading the pgpass file.
268269
auth_hosts = None
@@ -389,6 +390,11 @@ def _parse_connect_dsn_and_args(*, dsn, host, port, user,
389390
if krbsrvname is None:
390391
krbsrvname = val
391392

393+
if 'gsslib' in query:
394+
val = query.pop('gsslib')
395+
if gsslib is None:
396+
gsslib = val
397+
392398
if query:
393399
if server_settings is None:
394400
server_settings = query
@@ -659,12 +665,21 @@ def _parse_connect_dsn_and_args(*, dsn, host, port, user,
659665
if krbsrvname is None:
660666
krbsrvname = os.getenv('PGKRBSRVNAME')
661667

668+
if gsslib is None:
669+
gsslib = os.getenv('PGGSSLIB')
670+
if gsslib is None:
671+
gsslib = 'sspi' if _system == 'Windows' else 'gssapi'
672+
if gsslib not in {'gssapi', 'sspi'}:
673+
raise exceptions.ClientConfigurationError(
674+
"gsslib parameter must be either 'gssapi' or 'sspi'"
675+
", got {!r}".format(gsslib))
676+
662677
params = _ConnectionParameters(
663678
user=user, password=password, database=database, ssl=ssl,
664679
sslmode=sslmode, direct_tls=direct_tls,
665680
server_settings=server_settings,
666681
target_session_attrs=target_session_attrs,
667-
krbsrvname=krbsrvname)
682+
krbsrvname=krbsrvname, gsslib=gsslib)
668683

669684
return addrs, params
670685

@@ -675,7 +690,7 @@ def _parse_connect_arguments(*, dsn, host, port, user, password, passfile,
675690
max_cached_statement_lifetime,
676691
max_cacheable_statement_size,
677692
ssl, direct_tls, server_settings,
678-
target_session_attrs, krbsrvname):
693+
target_session_attrs, krbsrvname, gsslib):
679694
local_vars = locals()
680695
for var_name in {'max_cacheable_statement_size',
681696
'max_cached_statement_lifetime',
@@ -705,7 +720,7 @@ def _parse_connect_arguments(*, dsn, host, port, user, password, passfile,
705720
direct_tls=direct_tls, database=database,
706721
server_settings=server_settings,
707722
target_session_attrs=target_session_attrs,
708-
krbsrvname=krbsrvname)
723+
krbsrvname=krbsrvname, gsslib=gsslib)
709724

710725
config = _ClientConfiguration(
711726
command_timeout=command_timeout,

asyncpg/connection.py

+8-2
Original file line numberDiff line numberDiff line change
@@ -2008,7 +2008,8 @@ async def connect(dsn=None, *,
20082008
record_class=protocol.Record,
20092009
server_settings=None,
20102010
target_session_attrs=None,
2011-
krbsrvname=None):
2011+
krbsrvname=None,
2012+
gsslib=None):
20122013
r"""A coroutine to establish a connection to a PostgreSQL server.
20132014
20142015
The connection parameters may be specified either as a connection
@@ -2240,6 +2241,10 @@ async def connect(dsn=None, *,
22402241
Kerberos service name to use when authenticating with GSSAPI. This
22412242
must match the server configuration. Defaults to 'postgres'.
22422243
2244+
:param str gsslib:
2245+
GSS library to use for GSSAPI/SSPI authentication. Can be 'gssapi'
2246+
or 'sspi'. Defaults to 'sspi' on Windows and 'gssapi' otherwise.
2247+
22432248
:return: A :class:`~asyncpg.connection.Connection` instance.
22442249
22452250
Example:
@@ -2309,7 +2314,7 @@ async def connect(dsn=None, *,
23092314
Added the *target_session_attrs* parameter.
23102315
23112316
.. versionchanged:: 0.30.0
2312-
Added the *krbsrvname* parameter.
2317+
Added the *krbsrvname* and *gsslib* parameters.
23132318
23142319
.. _SSLContext: https://docs.python.org/3/library/ssl.html#ssl.SSLContext
23152320
.. _create_default_context:
@@ -2354,6 +2359,7 @@ async def connect(dsn=None, *,
23542359
max_cacheable_statement_size=max_cacheable_statement_size,
23552360
target_session_attrs=target_session_attrs,
23562361
krbsrvname=krbsrvname,
2362+
gsslib=gsslib,
23572363
)
23582364

23592365

asyncpg/protocol/coreproto.pxd

+4-2
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ cdef class CoreProtocol:
9191
object con_params
9292
# Instance of SCRAMAuthentication
9393
SCRAMAuthentication scram
94-
# Instance of gssapi.SecurityContext
94+
# Instance of gssapi.SecurityContext or sspilib.SecurityContext
9595
object gss_ctx
9696

9797
readonly int32_t backend_pid
@@ -138,7 +138,9 @@ cdef class CoreProtocol:
138138
cdef _auth_password_message_md5(self, bytes salt)
139139
cdef _auth_password_message_sasl_initial(self, list sasl_auth_methods)
140140
cdef _auth_password_message_sasl_continue(self, bytes server_response)
141-
cdef _auth_gss_init(self)
141+
cdef _auth_gss_init_gssapi(self)
142+
cdef _auth_gss_init_sspi(self, bint negotiate)
143+
cdef _auth_gss_get_spn(self)
142144
cdef _auth_gss_step(self, bytes server_response)
143145

144146
cdef _write(self, buf)

asyncpg/protocol/coreproto.pyx

+41-21
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ cdef class CoreProtocol:
3838
self.encoding = 'utf-8'
3939
# type of `scram` is `SCRAMAuthentcation`
4040
self.scram = None
41-
# type of `gss_ctx` is `gssapi.SecurityContext`
41+
# type of `gss_ctx` is `gssapi.SecurityContext` or
42+
# `sspilib.SecurityContext`
4243
self.gss_ctx = None
4344

4445
self._reset_result()
@@ -635,29 +636,30 @@ cdef class CoreProtocol:
635636
)
636637
self.scram = None
637638

638-
elif status == AUTH_REQUIRED_GSS:
639-
self._auth_gss_init()
640-
self.auth_msg = self._auth_gss_step(None)
639+
elif status in (AUTH_REQUIRED_GSS, AUTH_REQUIRED_SSPI):
640+
if self.gss_ctx is not None:
641+
self.result_type = RESULT_FAILED
642+
self.result = apg_exc.InterfaceError(
643+
'duplicate GSSAPI/SSPI authentication request')
644+
else:
645+
if self.con_params.gsslib == 'gssapi':
646+
self._auth_gss_init_gssapi()
647+
else:
648+
self._auth_gss_init_sspi(status == AUTH_REQUIRED_SSPI)
649+
self.auth_msg = self._auth_gss_step(None)
641650

642651
elif status == AUTH_REQUIRED_GSS_CONTINUE:
643652
server_response = self.buffer.consume_message()
644653
self.auth_msg = self._auth_gss_step(server_response)
645654

646-
elif status in (AUTH_REQUIRED_KERBEROS, AUTH_REQUIRED_SCMCRED,
647-
AUTH_REQUIRED_SSPI):
648-
self.result_type = RESULT_FAILED
649-
self.result = apg_exc.InterfaceError(
650-
'unsupported authentication method requested by the '
651-
'server: {!r}'.format(AUTH_METHOD_NAME[status]))
652-
653655
else:
654656
self.result_type = RESULT_FAILED
655657
self.result = apg_exc.InterfaceError(
656658
'unsupported authentication method requested by the '
657-
'server: {}'.format(status))
659+
'server: {!r}'.format(AUTH_METHOD_NAME.get(status, status)))
658660

659-
if status not in [AUTH_SASL_CONTINUE, AUTH_SASL_FINAL,
660-
AUTH_REQUIRED_GSS_CONTINUE]:
661+
if status not in (AUTH_SASL_CONTINUE, AUTH_SASL_FINAL,
662+
AUTH_REQUIRED_GSS_CONTINUE):
661663
self.buffer.discard_message()
662664

663665
cdef _auth_password_message_cleartext(self):
@@ -714,25 +716,43 @@ cdef class CoreProtocol:
714716

715717
return msg
716718

717-
cdef _auth_gss_init(self):
719+
cdef _auth_gss_init_gssapi(self):
718720
try:
719721
import gssapi
720722
except ModuleNotFoundError:
721-
raise RuntimeError(
723+
raise apg_exc.InterfaceError(
722724
'gssapi module not found; please install asyncpg[gssapi] to '
723-
'use asyncpg with Kerberos or GSSAPI authentication'
725+
'use asyncpg with Kerberos/GSSAPI/SSPI authentication'
724726
) from None
725727

728+
self.gss_ctx = gssapi.SecurityContext(
729+
name=gssapi.Name(self._auth_gss_get_spn()), usage='initiate')
730+
731+
cdef _auth_gss_init_sspi(self, bint negotiate):
732+
try:
733+
import sspilib
734+
except ModuleNotFoundError:
735+
raise apg_exc.InterfaceError(
736+
'sspilib module not found; please install asyncpg[gssapi] to '
737+
'use asyncpg with Kerberos/GSSAPI/SSPI authentication'
738+
) from None
739+
740+
self.gss_ctx = sspilib.ClientSecurityContext(
741+
self._auth_gss_get_spn(),
742+
credential=sspilib.UserCredential(
743+
protocol='Negotiate' if negotiate else 'Kerberos'))
744+
745+
cdef _auth_gss_get_spn(self):
726746
service_name = self.con_params.krbsrvname or 'postgres'
727747
# find the canonical name of the server host
728748
if isinstance(self.address, str):
729-
raise RuntimeError('GSSAPI authentication is only supported for '
730-
'TCP/IP connections')
749+
raise apg_exc.InternalClientError(
750+
'GSSAPI/SSPI authentication is only supported for TCP/IP '
751+
'connections')
731752

732753
host = self.address[0]
733754
host_cname = socket.gethostbyname_ex(host)[0]
734-
gss_name = gssapi.Name(f'{service_name}/{host_cname}')
735-
self.gss_ctx = gssapi.SecurityContext(name=gss_name, usage='initiate')
755+
return f'{service_name}/{host_cname}'
736756

737757
cdef _auth_gss_step(self, bytes server_response):
738758
cdef:

pyproject.toml

+3-1
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,16 @@ github = "https://github.com/MagicStack/asyncpg"
3636

3737
[project.optional-dependencies]
3838
gssapi = [
39-
'gssapi',
39+
'gssapi; platform_system != "Windows"',
40+
'sspilib; platform_system == "Windows"',
4041
]
4142
test = [
4243
'flake8~=6.1',
4344
'flake8-pyi~=24.1.0',
4445
'uvloop>=0.15.3; platform_system != "Windows" and python_version < "3.12.0"',
4546
'gssapi; platform_system == "Linux"',
4647
'k5test; platform_system == "Linux"',
48+
'sspilib; platform_system == "Windows"',
4749
'mypy~=1.8.0',
4850
]
4951
docs = [

0 commit comments

Comments
 (0)