Skip to content

Commit 69375b1

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 69375b1

File tree

6 files changed

+186
-35
lines changed

6 files changed

+186
-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

+44-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,33 @@ 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+
# AUTH_REQUIRED_SSPI is the same as AUTH_REQUIRED_GSS, except that
641+
# it uses protocol negotiation with SSPI clients. Both methods use
642+
# AUTH_REQUIRED_GSS_CONTINUE for subsequent authentication steps.
643+
if self.gss_ctx is not None:
644+
self.result_type = RESULT_FAILED
645+
self.result = apg_exc.InterfaceError(
646+
'duplicate GSSAPI/SSPI authentication request')
647+
else:
648+
if self.con_params.gsslib == 'gssapi':
649+
self._auth_gss_init_gssapi()
650+
else:
651+
self._auth_gss_init_sspi(status == AUTH_REQUIRED_SSPI)
652+
self.auth_msg = self._auth_gss_step(None)
641653

642654
elif status == AUTH_REQUIRED_GSS_CONTINUE:
643655
server_response = self.buffer.consume_message()
644656
self.auth_msg = self._auth_gss_step(server_response)
645657

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-
653658
else:
654659
self.result_type = RESULT_FAILED
655660
self.result = apg_exc.InterfaceError(
656661
'unsupported authentication method requested by the '
657-
'server: {}'.format(status))
662+
'server: {!r}'.format(AUTH_METHOD_NAME.get(status, status)))
658663

659-
if status not in [AUTH_SASL_CONTINUE, AUTH_SASL_FINAL,
660-
AUTH_REQUIRED_GSS_CONTINUE]:
664+
if status not in (AUTH_SASL_CONTINUE, AUTH_SASL_FINAL,
665+
AUTH_REQUIRED_GSS_CONTINUE):
661666
self.buffer.discard_message()
662667

663668
cdef _auth_password_message_cleartext(self):
@@ -714,25 +719,43 @@ cdef class CoreProtocol:
714719

715720
return msg
716721

717-
cdef _auth_gss_init(self):
722+
cdef _auth_gss_init_gssapi(self):
718723
try:
719724
import gssapi
720725
except ModuleNotFoundError:
721-
raise RuntimeError(
726+
raise apg_exc.InterfaceError(
722727
'gssapi module not found; please install asyncpg[gssapi] to '
723-
'use asyncpg with Kerberos or GSSAPI authentication'
728+
'use asyncpg with Kerberos/GSSAPI/SSPI authentication'
724729
) from None
725730

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

732756
host = self.address[0]
733757
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')
758+
return f'{service_name}/{host_cname}'
736759

737760
cdef _auth_gss_step(self, bytes server_response):
738761
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)