Skip to content

Commit 5a62f64

Browse files
committed
WL14213: Support GSSAPI - Kerberos auth
The purpose of this Worklog is to add support for the SASL authentication protocol using the GSSAPI (Kerberos) authentication method for the pure python implementation. This changes require the GSSAPI pypi module https://pypi.org/project/gssapi/ which provides both low-level and high level wrappers around the GSSAPI C libraries. The SASL mechanism is handled by C/py in connection module. The GSSAPI pypi module requires MIT kerberos installed in the system in order to work and be able to request tickets to authentificate the user with the MySQL server when the user is IDENTIFIED WITH authentication_ldap_sasl and the authentication_ldap_sasl plugin is configured to use the "GSSAPI" mechanism.
1 parent 5ca544c commit 5a62f64

File tree

4 files changed

+343
-7
lines changed

4 files changed

+343
-7
lines changed

lib/mysql/connector/authentication.py

Lines changed: 156 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,22 @@
3434
import logging
3535
import struct
3636
from uuid import uuid4
37+
try:
38+
import gssapi
39+
except:
40+
gssapi = None
3741

3842
from . import errors
3943
from .catch23 import PY2, isstr, UNICODE_TYPES, BYTE_TYPES
4044
from .utils import (normalize_unicode_string as norm_ustr,
4145
validate_normalized_unicode_string as valid_norm)
4246

43-
_LOGGER = logging.getLogger(__name__)
47+
if PY2:
48+
from urllib import quote
49+
else:
50+
from urllib.parse import quote
4451

52+
_LOGGER = logging.getLogger(__name__)
4553

4654
class BaseAuthPlugin(object):
4755
"""Base class for authentication plugins
@@ -280,7 +288,7 @@ class MySQLLdapSaslPasswordAuthPlugin(BaseAuthPlugin):
280288
server, the second server respond needs to be passed to auth_finalize()
281289
to finish the authentication process.
282290
"""
283-
sasl_mechanisms = ['SCRAM-SHA-1', 'SCRAM-SHA-256']
291+
sasl_mechanisms = ['SCRAM-SHA-1', 'SCRAM-SHA-256', 'GSSAPI']
284292
requires_ssl = False
285293
plugin_name = 'authentication_ldap_sasl_client'
286294
def_digest_mode = sha1
@@ -350,6 +358,144 @@ def _first_message(self):
350358
cfm = cfm.encode('utf8')
351359
return cfm
352360

361+
def _first_message_krb(self):
362+
"""Get a TGT Authentication request and initiates security context.
363+
364+
This method will contact the Kerberos KDC in order of obtain a TGT.
365+
"""
366+
_LOGGER.debug("# user name: %s", self._username)
367+
user_name = gssapi.raw.names.import_name(self._username.encode('utf8'),
368+
name_type=gssapi.NameType.user)
369+
370+
# Use defaults store = {'ccache': 'FILE:/tmp/krb5cc_1000'}#, 'keytab':'/etc/some.keytab' }
371+
# Attempt to retrieve credential from default cache file.
372+
try:
373+
cred = gssapi.Credentials()
374+
_LOGGER.debug("# Stored credentials found, if password was given it"
375+
" will be ignored.")
376+
try:
377+
# validate credentials has not expired.
378+
cred.lifetime
379+
except gssapi.raw.exceptions.ExpiredCredentialsError as err:
380+
_LOGGER.warning(" Credentials has expired: %s", err)
381+
cred.acquire(user_name)
382+
raise errors.InterfaceError("Credentials has expired: {}".format(err))
383+
except gssapi.raw.misc.GSSError as err:
384+
if not self._password:
385+
_LOGGER.error(" Unable to retrieve stored credentials: %s", err)
386+
raise errors.InterfaceError(
387+
"Unable to retrieve stored credentials error: {}".format(err))
388+
else:
389+
try:
390+
_LOGGER.debug("# Attempt to retrieve credentials with "
391+
"given password")
392+
acquire_cred_result = gssapi.raw.acquire_cred_with_password(
393+
user_name, self._password.encode('utf8'), usage="initiate")
394+
cred = acquire_cred_result[0]
395+
except gssapi.raw.misc.GSSError as err:
396+
_LOGGER.error(" Unable to retrieve credentials with the given "
397+
"password: %s", err)
398+
raise errors.ProgrammingError(
399+
"Unable to retrieve credentials with the given password: "
400+
"{}".format(err))
401+
402+
flags_l = (gssapi.RequirementFlag.mutual_authentication,
403+
gssapi.RequirementFlag.extended_error,
404+
gssapi.RequirementFlag.delegate_to_peer
405+
)
406+
407+
service_principal = "ldap/ldapauth"
408+
_LOGGER.debug("# service principal: %s", service_principal)
409+
servk = gssapi.Name(service_principal, name_type=gssapi.NameType.kerberos_principal)
410+
self.target_name = servk
411+
self.ctx = gssapi.SecurityContext(name=servk,
412+
creds=cred,
413+
flags=sum(flags_l),
414+
usage='initiate')
415+
416+
try:
417+
initial_client_token = self.ctx.step()
418+
except gssapi.raw.misc.GSSError as err:
419+
_LOGGER.error("Unable to initiate security context: %s", err)
420+
raise errors.InterfaceError("Unable to initiate security context: {}".format(err))
421+
422+
_LOGGER.debug("# initial client token: %s", initial_client_token)
423+
return initial_client_token
424+
425+
426+
def auth_continue_krb(self, tgt_auth_challenge):
427+
"""Continue with the Kerberos TGT service request.
428+
429+
With the TGT authentication service given response generate a TGT
430+
service request. This method must be invoked sequentially (in a loop)
431+
until the security context is completed and an empty response needs to
432+
be send to acknowledge the server.
433+
434+
Args:
435+
tgt_auth_challenge the challenge for the negotiation.
436+
437+
Returns: tuple (bytearray TGS service request,
438+
bool True if context is completed otherwise False).
439+
"""
440+
_LOGGER.debug("tgt_auth challenge: %s", tgt_auth_challenge)
441+
442+
resp = self.ctx.step(tgt_auth_challenge)
443+
_LOGGER.debug("# context step response: %s", resp)
444+
_LOGGER.debug("# context completed?: %s", self.ctx.complete)
445+
446+
return resp, self.ctx.complete
447+
448+
def auth_accept_close_handshake(self, message):
449+
"""Accept handshake and generate closing handshake message for server.
450+
451+
This method verifies the server authenticity from the given message
452+
and included signature and generates the closing handshake for the
453+
server.
454+
455+
When this method is invoked the security context is already established
456+
and the client and server can send GSSAPI formated secure messages.
457+
458+
To finish the authentication handshake the server sends a message
459+
with the security layer availability and the maximum buffer size.
460+
461+
Since the connector only uses the GSSAPI authentication mechanism to
462+
authenticate the user with the server, the server will verify clients
463+
message signature and terminate the GSSAPI authentication and send two
464+
messages; an authentication acceptance b'\x01\x00\x00\x08\x01' and a
465+
OK packet (that must be received after sent the returned message from
466+
this method).
467+
468+
Args:
469+
message a wrapped hssapi message from the server.
470+
471+
Returns: bytearray closing handshake message to be send to the server.
472+
"""
473+
if not self.ctx.complete:
474+
raise errors.ProgrammingError("Security context is not completed.")
475+
_LOGGER.debug("# servers message: %s", message)
476+
_LOGGER.debug("# GSSAPI flags in use: %s", self.ctx.actual_flags)
477+
try:
478+
unwraped = self.ctx.unwrap(message)
479+
_LOGGER.debug("# unwraped: %s", unwraped)
480+
except gssapi.raw.exceptions.BadMICError as err:
481+
_LOGGER.debug("Unable to unwrap server message: %s", err)
482+
raise errors.InterfaceError("Unable to unwrap server message: {}"
483+
"".format(err))
484+
485+
_LOGGER.debug("# unwrapped server message: %s", unwraped)
486+
# The message contents for the clients closing message:
487+
# - security level 1 byte, must be always 1.
488+
# - conciliated buffer size 3 bytes, without importance as no
489+
# further GSSAPI messages will be sends.
490+
response = bytearray(b"\x01\x00\x00\00")
491+
# Closing handshake must not be encrypted.
492+
_LOGGER.debug("# message response: %s", response)
493+
wraped = self.ctx.wrap(response, encrypt=False)
494+
_LOGGER.debug("# wrapped message response: %s, length: %d",
495+
wraped[0], len(wraped[0]))
496+
497+
return wraped.message
498+
353499
def auth_response(self):
354500
"""This method will prepare the fist message to the server.
355501
@@ -364,6 +510,14 @@ def auth_response(self):
364510
auth_mechanism, '", "'.join(self.sasl_mechanisms[:-1]),
365511
self.sasl_mechanisms[-1]))
366512

513+
if b'GSSAPI' in self._auth_data:
514+
if not gssapi:
515+
raise errors.ProgrammingError(
516+
"Module gssapi is required for GSSAPI authentication "
517+
"mechanism but was not found. Unable to authenticate "
518+
"with the server")
519+
return self._first_message_krb()
520+
367521
if self._auth_data == b'SCRAM-SHA-256':
368522
self.def_digest_mode = sha256
369523

lib/mysql/connector/connection.py

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -246,10 +246,12 @@ def _auth_switch_request(self, username=None, password=None):
246246
username=self._user, password=password,
247247
ssl_enabled=self._ssl_active)
248248
response = auth.auth_response()
249+
_LOGGER.debug("# request size: %s", len(response))
249250
self._socket.send(response)
250251
packet = self._socket.recv()
251-
252-
if packet[5] == 114 and packet[6] == 61: # 'r' and '='
252+
_LOGGER.debug("server response packet: %s", packet)
253+
if new_auth_plugin == "authentication_ldap_sasl_client" \
254+
and len(packet) >= 6 and packet[5] == 114 and packet[6] == 61: # 'r' and '='
253255
# Continue with sasl authentication
254256
dec_response = packet[5:]
255257
cresponse = auth.auth_continue(dec_response)
@@ -259,6 +261,41 @@ def _auth_switch_request(self, username=None, password=None):
259261
if auth.auth_finalize(packet[5:]):
260262
# receive packed OK
261263
packet = self._socket.recv()
264+
elif new_auth_plugin == "authentication_ldap_sasl_client":
265+
rcode_size = 5 # header size for the response status code.
266+
_LOGGER.debug("# Continue with sasl GSSAPI authentication")
267+
_LOGGER.debug("# response header: %s", packet[:rcode_size+1])
268+
_LOGGER.debug("# response size: %s", len(packet))
269+
270+
_LOGGER.debug("# Negotiate a service request")
271+
complete = False
272+
tries = 0 # To avoid a infinite loop attempt no more than feedback messages
273+
while not complete and tries < 5:
274+
_LOGGER.debug("%s Attempt %s %s", "-" * 20, tries + 1, "-" * 20)
275+
_LOGGER.debug("<< server response: %s", packet)
276+
_LOGGER.debug("# response code: %s", packet[:rcode_size + 1])
277+
step, complete = auth.auth_continue_krb(packet[rcode_size:])
278+
_LOGGER.debug(" >> response to server: %s", step)
279+
self._socket.send(step or b'')
280+
packet = self._socket.recv()
281+
tries += 1
282+
if not complete:
283+
raise errors.InterfaceError(
284+
"Unable to fulfill server request after %s attempts. "
285+
"Last server response: %s", tries, packet)
286+
_LOGGER.debug(" last GSSAPI response from server: %s length: %d",
287+
packet, len(packet))
288+
last_step = auth.auth_accept_close_handshake(packet[rcode_size:])
289+
_LOGGER.debug(" >> last response to server: %s length: %d",
290+
last_step, len(last_step))
291+
self._socket.send(last_step)
292+
# Receive final handshake from server
293+
packet = self._socket.recv()
294+
_LOGGER.debug("<< final handshake from server: %s", packet)
295+
296+
# receive OK packet from server.
297+
packet = self._socket.recv()
298+
_LOGGER.debug("<< ok packet from server: %s", packet)
262299

263300
if packet[4] == 1:
264301
auth_data = self._protocol.parse_auth_more_data(packet)

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,5 +143,6 @@
143143
extras_require={
144144
"dns-srv": ["dnspython>=1.16.0"],
145145
"compression": ["lz4>=2.1.6", "zstandard>=0.12.0"],
146+
"gssapi": ["gssapi>=1.6.9"],
146147
}
147148
)

0 commit comments

Comments
 (0)