Skip to content

Commit 9384136

Browse files
committed
WL#15348: Support MIT Kerberos library on Windows
The support for Kerberos authentication on Windows was implemented by WL#14664 in release 8.0.27 for the C extension and WL#14665 in release 8.0.29 for pure Python. That implementation was based on Windows SSPI. This worklog implements the GSSAPI through MIT Kerberos libraries on Windows, just like was implemented by WL#14440 in the release 8.0.26 on Linux. Change-Id: I5cec8bc104aedcfa183996366323e980608f22f0
1 parent 56cb0fe commit 9384136

File tree

14 files changed

+277
-85
lines changed

14 files changed

+277
-85
lines changed

CHANGES.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Full release notes:
1111
v8.0.32
1212
=======
1313

14+
- WL#15348: Support MIT Kerberos library on Windows
1415
- WL#15036: Support for type hints
1516

1617
v8.0.31

cpydist/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -345,7 +345,10 @@ def _copy_vendor_libraries(self):
345345
src = os.path.join(src_folder, filename)
346346
dst = os.path.join(os.getcwd(), self.vendor_folder, dst_folder)
347347
self.log.info("copying %s -> %s", src, dst)
348-
self.log.info("shutil res: %s", shutil.copy(src, dst))
348+
try:
349+
self.log.info("shutil res: %s", shutil.copy(src, dst))
350+
except shutil.SameFileError:
351+
pass
349352

350353
if os.name == "nt":
351354
self.distribution.package_data = {"mysql": ["vendor/plugin/*"]}

lib/mysql/connector/abstracts.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,7 @@ def __init__(self) -> None:
190190

191191
self._ssl_active: bool = False
192192
self._auth_plugin: Optional[str] = None
193+
self._auth_plugin_class: Optional[str] = None
193194
self._pool_config_version: Any = None
194195
self.converter: Optional[MySQLConverter] = None
195196
self._converter_class: Optional[Type[MySQLConverter]] = None
@@ -711,6 +712,24 @@ def config(self, **kwargs: Any) -> None:
711712

712713
if self._client_flags & ClientFlag.CONNECT_ARGS:
713714
self._add_default_conn_attrs()
715+
716+
if "kerberos_auth_mode" in config and config["kerberos_auth_mode"] is not None:
717+
if not isinstance(config["kerberos_auth_mode"], str):
718+
raise InterfaceError("'kerberos_auth_mode' must be of type str")
719+
kerberos_auth_mode = config["kerberos_auth_mode"].lower()
720+
if kerberos_auth_mode == "sspi":
721+
if os.name != "nt":
722+
raise InterfaceError(
723+
"'kerberos_auth_mode=SSPI' is only available on Windows"
724+
)
725+
self._auth_plugin_class = "MySQLSSPIKerberosAuthPlugin"
726+
elif kerberos_auth_mode == "gssapi":
727+
self._auth_plugin_class = "MySQLKerberosAuthPlugin"
728+
else:
729+
raise InterfaceError(
730+
"Invalid 'kerberos_auth_mode' mode. Please use 'SSPI' or 'GSSAPI'"
731+
)
732+
714733
if (
715734
"krb_service_principal" in config
716735
and config["krb_service_principal"] is not None

lib/mysql/connector/authentication.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
import logging
3333

3434
from functools import lru_cache
35-
from typing import Type
35+
from typing import Optional, Type
3636

3737
from .errors import NotSupportedError, ProgrammingError
3838
from .plugins import BaseAuthPlugin
@@ -47,27 +47,33 @@
4747
@lru_cache(maxsize=10, typed=False)
4848
def get_auth_plugin(
4949
plugin_name: str,
50+
auth_plugin_class: Optional[str] = None,
5051
) -> Type[BaseAuthPlugin]: # AUTH_PLUGIN_CLASS_TYPES:
5152
"""Return authentication class based on plugin name
5253
5354
This function returns the class for the authentication plugin plugin_name.
5455
The returned class is a subclass of BaseAuthPlugin.
5556
56-
Raises NotSupportedError when plugin_name is not supported.
57+
Args:
58+
plugin_name (str): Authentication plugin name.
59+
auth_plugin_class (str): Authentication plugin class name.
5760
58-
Returns subclass of BaseAuthPlugin.
61+
Raises:
62+
NotSupportedError: When plugin_name is not supported.
63+
64+
Returns:
65+
Subclass of `BaseAuthPlugin`.
5966
"""
6067
package = DEFAULT_PLUGINS_PKG
6168
if plugin_name:
6269
try:
6370
_LOGGER.info("package: %s", package)
6471
_LOGGER.info("plugin_name: %s", plugin_name)
6572
plugin_module = importlib.import_module(f".{plugin_name}", package)
66-
_LOGGER.info(
67-
"AUTHENTICATION_PLUGIN_CLASS: %s",
68-
plugin_module.AUTHENTICATION_PLUGIN_CLASS,
69-
)
70-
return getattr(plugin_module, plugin_module.AUTHENTICATION_PLUGIN_CLASS)
73+
if not auth_plugin_class or not hasattr(plugin_module, auth_plugin_class):
74+
auth_plugin_class = plugin_module.AUTHENTICATION_PLUGIN_CLASS
75+
_LOGGER.info("AUTHENTICATION_PLUGIN_CLASS: %s", auth_plugin_class)
76+
return getattr(plugin_module, auth_plugin_class)
7177
except ModuleNotFoundError as err:
7278
_LOGGER.warning("Requested Module was not found: %s", err)
7379
except ValueError as err:

lib/mysql/connector/connection.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,7 @@ def _do_auth(
309309
ssl_enabled=self._ssl_active,
310310
auth_plugin=self._auth_plugin,
311311
conn_attrs=conn_attrs,
312+
auth_plugin_class=self._auth_plugin_class,
312313
)
313314
self._socket.send(packet)
314315
self._auth_switch_request(username, password)
@@ -344,7 +345,7 @@ def _auth_switch_request(
344345
new_auth_plugin,
345346
auth_data,
346347
) = self._protocol.parse_auth_switch_request(packet)
347-
auth = get_auth_plugin(new_auth_plugin)(
348+
auth = get_auth_plugin(new_auth_plugin, self._auth_plugin_class)(
348349
auth_data,
349350
username=username or self._user,
350351
password=password,
@@ -354,7 +355,7 @@ def _auth_switch_request(
354355

355356
if packet[4] == 1:
356357
auth_data = self._protocol.parse_auth_more_data(packet)
357-
auth = get_auth_plugin(new_auth_plugin)(
358+
auth = get_auth_plugin(new_auth_plugin, self._auth_plugin_class)(
358359
auth_data, password=password, ssl_enabled=self.is_secure
359360
)
360361
if new_auth_plugin == "caching_sha2_password":
@@ -386,7 +387,7 @@ def _handle_mfa(self, packet: bytes) -> Optional[OkPacketType]:
386387
_LOGGER.debug("# MFA N Factor #%d", self._mfa_nfactor)
387388

388389
packet, auth_plugin = self._protocol.parse_auth_next_factor(packet[4:])
389-
auth = get_auth_plugin(auth_plugin)(
390+
auth = get_auth_plugin(auth_plugin, self._auth_plugin_class)(
390391
None,
391392
username=self._user,
392393
password=password,
@@ -396,7 +397,7 @@ def _handle_mfa(self, packet: bytes) -> Optional[OkPacketType]:
396397

397398
if packet[4] == 1:
398399
auth_data = self._protocol.parse_auth_more_data(packet)
399-
auth = get_auth_plugin(auth_plugin)(
400+
auth = get_auth_plugin(auth_plugin, self._auth_plugin_class)(
400401
auth_data, password=password, ssl_enabled=self.is_secure
401402
)
402403
if auth_plugin == "caching_sha2_password":
@@ -563,10 +564,9 @@ def _open_connection(self) -> None:
563564
564565
Raises on errors.
565566
"""
566-
if self._auth_plugin == "authentication_kerberos_client" and os.name != "nt":
567-
if not self._user:
568-
cls = get_auth_plugin(self._auth_plugin)
569-
self._user = cls.get_user_from_credentials()
567+
if self._auth_plugin == "authentication_kerberos_client" and not self._user:
568+
cls = get_auth_plugin(self._auth_plugin, self._auth_plugin_class)
569+
self._user = cls.get_user_from_credentials()
570570

571571
self._protocol = MySQLProtocol()
572572
self._socket = self._get_connection()

lib/mysql/connector/connection_cext.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,9 @@ def _open_connection(self) -> None:
280280
}
281281
)
282282

283+
if os.name == "nt" and self._auth_plugin_class == "MySQLKerberosAuthPlugin":
284+
cnx_kwargs["use_kerberos_gssapi"] = True
285+
283286
try:
284287
self._cmysql.connect(**cnx_kwargs)
285288
self._cmysql.converter_str_fallback = self._converter_str_fallback

lib/mysql/connector/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@
9191
"krb_service_principal": None,
9292
"oci_config_file": None,
9393
"fido_callback": None,
94+
"kerberos_auth_mode": None,
9495
}
9596

9697
CNX_POOL_ARGS: Tuple[str, str, str] = ("pool_name", "pool_size", "pool_reset_session")

lib/mysql/connector/plugins/authentication_kerberos_client.py

Lines changed: 48 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
import os
3636
import struct
3737

38+
from pathlib import Path
3839
from typing import Any, Optional, Tuple
3940

4041
from .. import errors
@@ -63,7 +64,9 @@
6364

6465
_LOGGER = logging.getLogger(__name__)
6566

66-
AUTHENTICATION_PLUGIN_CLASS = "MySQLKerberosAuthPlugin"
67+
AUTHENTICATION_PLUGIN_CLASS = (
68+
"MySQLSSPIKerberosAuthPlugin" if os.name == "nt" else "MySQLKerberosAuthPlugin"
69+
)
6770

6871

6972
# pylint: disable=c-extension-no-member,no-member
@@ -86,23 +89,59 @@ def get_user_from_credentials() -> str:
8689
except gssapi.raw.misc.GSSError:
8790
return getpass.getuser()
8891

89-
def _acquire_cred_with_password(self, upn: str) -> gssapi.raw.creds.Creds:
90-
"""Acquire credentials through provided password."""
91-
_LOGGER.debug("Attempt to acquire credentials through provided password")
92+
@staticmethod
93+
def get_store() -> dict:
94+
"""Get a credentials store dictionary.
9295
93-
username = gssapi.raw.names.import_name(
94-
upn.encode("utf-8"), name_type=gssapi.NameType.user
96+
Returns:
97+
dict: Credentials store dictionary with the krb5 ccache name.
98+
99+
Raises:
100+
errors.InterfaceError: If 'KRB5CCNAME' environment variable is empty.
101+
"""
102+
krb5ccname = os.environ.get(
103+
"KRB5CCNAME",
104+
f"/tmp/krb5cc_{os.getuid()}"
105+
if os.name == "posix"
106+
else Path("%TEMP%").joinpath("krb5cc"),
95107
)
108+
if not krb5ccname:
109+
raise errors.InterfaceError(
110+
"The 'KRB5CCNAME' environment variable is set to empty"
111+
)
112+
_LOGGER.debug("Using krb5 ccache name: FILE:%s", krb5ccname)
113+
store = {b"ccache": f"FILE:{krb5ccname}".encode("utf-8")}
114+
return store
115+
116+
def _acquire_cred_with_password(self, upn: str) -> gssapi.raw.creds.Creds:
117+
"""Acquire and store credentials through provided password.
118+
119+
Args:
120+
upn (str): User Principal Name.
121+
122+
Returns:
123+
gssapi.raw.creds.Creds: GSSAPI credentials.
124+
"""
125+
_LOGGER.debug("Attempt to acquire credentials through provided password")
126+
user = gssapi.Name(upn, gssapi.NameType.user)
127+
password = self._password.encode("utf-8")
96128

97129
try:
98130
acquire_cred_result = gssapi.raw.acquire_cred_with_password(
99-
username, self._password.encode("utf-8"), usage="initiate"
131+
user, password, usage="initiate"
132+
)
133+
creds = acquire_cred_result.creds
134+
gssapi.raw.store_cred_into(
135+
self.get_store(),
136+
creds=creds,
137+
mech=gssapi.MechType.kerberos,
138+
overwrite=True,
139+
set_default=True,
100140
)
101141
except gssapi.raw.misc.GSSError as err:
102142
raise errors.ProgrammingError(
103143
f"Unable to acquire credentials with the given password: {err}"
104144
)
105-
creds = acquire_cred_result[0]
106145
return creds
107146

108147
@staticmethod
@@ -152,7 +191,7 @@ def auth_response(self, auth_data: Optional[bytes] = None) -> Optional[bytes]:
152191
_LOGGER.debug("Username: %s", self._username)
153192

154193
try:
155-
# Attempt to retrieve credentials from default cache file
194+
# Attempt to retrieve credentials from cache file
156195
creds: Any = gssapi.Credentials(usage="initiate")
157196
creds_upn = str(creds.name)
158197

@@ -410,10 +449,3 @@ def auth_continue(
410449
_LOGGER.debug("Context completed?: %s", self.clientauth.authenticated)
411450

412451
return resp, self.clientauth.authenticated
413-
414-
415-
# pylint: enable=c-extension-no-member,no-member
416-
417-
418-
if os.name == "nt":
419-
MySQLKerberosAuthPlugin = MySQLSSPIKerberosAuthPlugin # type: ignore[assignment]

lib/mysql/connector/protocol.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ def _auth_response(
8282
username: Optional[StrOrBytes],
8383
password: Optional[str],
8484
database: Optional[str],
85+
auth_plugin_class: str,
8586
auth_plugin: str,
8687
auth_data: Optional[bytes],
8788
ssl_enabled: bool,
@@ -91,7 +92,7 @@ def _auth_response(
9192
return b"\x00"
9293

9394
try:
94-
auth = get_auth_plugin(auth_plugin)(
95+
auth = get_auth_plugin(auth_plugin, auth_plugin_class)(
9596
auth_data,
9697
username=username,
9798
password=password,
@@ -124,6 +125,7 @@ def make_auth(
124125
ssl_enabled: bool = False,
125126
auth_plugin: Optional[str] = None,
126127
conn_attrs: Optional[ConnAttrsType] = None,
128+
auth_plugin_class: Optional[str] = None,
127129
) -> bytes:
128130
"""Make a MySQL Authentication packet"""
129131
if handshake is None:
@@ -160,6 +162,7 @@ def make_auth(
160162
username,
161163
password,
162164
database,
165+
auth_plugin_class,
163166
auth_plugin,
164167
auth_data,
165168
ssl_enabled,
@@ -231,6 +234,7 @@ def make_change_user(
231234
ssl_enabled: bool = False,
232235
auth_plugin: Optional[str] = None,
233236
conn_attrs: Optional[ConnAttrsType] = None,
237+
auth_plugin_class: Optional[str] = None,
234238
) -> bytes:
235239
"""Make a MySQL packet with the Change User command"""
236240

@@ -262,6 +266,7 @@ def make_change_user(
262266
username,
263267
password,
264268
database,
269+
auth_plugin_class,
265270
auth_plugin,
266271
auth_data,
267272
ssl_enabled,

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ skip_glob = [
4343
'lib/mysql/connector/locales/eng',
4444
'lib/mysqlx/locales/eng',
4545
'lib/mysqlx/protobuf',
46-
'venv/*'
46+
'venv/*',
4747
]
4848

4949
[tool.black]

src/mysql_capi.c

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1098,7 +1098,7 @@ MySQL_connect(MySQL *self, PyObject *args, PyObject *kwds)
10981098
*tls_versions = NULL, *tls_cipher_suites = NULL;
10991099
PyObject *charset_name = NULL, *compress = NULL, *ssl_verify_cert = NULL,
11001100
*ssl_verify_identity = NULL, *ssl_disabled = NULL, *conn_attrs = NULL,
1101-
*key = NULL, *value = NULL;
1101+
*key = NULL, *value = NULL, *use_kerberos_gssapi = Py_False;
11021102
const char *auth_plugin, *plugin_dir;
11031103
unsigned long client_flags = 0;
11041104
unsigned int port = 3306, tmp_uint;
@@ -1144,16 +1144,17 @@ MySQL_connect(MySQL *self, PyObject *args, PyObject *kwds)
11441144
"load_data_local_dir",
11451145
"oci_config_file",
11461146
"fido_callback",
1147+
"use_kerberos_gssapi",
11471148
NULL};
11481149

11491150
if (!PyArg_ParseTupleAndKeywords(
1150-
args, kwds, "|zzzzzzzkzkzzzzzzO!O!O!O!O!izzO", kwlist, &host, &user, &password,
1151+
args, kwds, "|zzzzzzzkzkzzzzzzO!O!O!O!O!izzOO", kwlist, &host, &user, &password,
11511152
&password1, &password2, &password3, &database, &port, &unix_socket, &client_flags,
11521153
&ssl_ca, &ssl_cert, &ssl_key, &ssl_cipher_suites, &tls_versions,
11531154
&tls_cipher_suites, &PyBool_Type, &ssl_verify_cert, &PyBool_Type,
11541155
&ssl_verify_identity, &PyBool_Type, &ssl_disabled, &PyBool_Type, &compress,
11551156
&PyDict_Type, &conn_attrs, &local_infile, &load_data_local_dir, &oci_config_file,
1156-
&fido_callback)) {
1157+
&fido_callback, &use_kerberos_gssapi)) {
11571158
return NULL;
11581159
}
11591160

@@ -1381,6 +1382,23 @@ MySQL_connect(MySQL *self, PyObject *args, PyObject *kwds)
13811382
(const void *)(&fido_messages_callback));
13821383
}
13831384

1385+
#ifdef MS_WINDOWS
1386+
if (use_kerberos_gssapi == Py_True) {
1387+
struct st_mysql_client_plugin *kerberos_plugin = \
1388+
mysql_client_find_plugin(&self->session,
1389+
"authentication_kerberos_client", MYSQL_CLIENT_AUTHENTICATION_PLUGIN);
1390+
if (!kerberos_plugin) {
1391+
raise_with_string(
1392+
PyUnicode_FromString("The Kerberos authentication plugin could not be loaded"),
1393+
NULL);
1394+
return NULL;
1395+
}
1396+
1397+
mysql_plugin_options(kerberos_plugin,
1398+
"plugin_authentication_kerberos_client_mode", "GSSAPI");
1399+
}
1400+
#endif
1401+
13841402
Py_BEGIN_ALLOW_THREADS
13851403
res = mysql_real_connect(&self->session, host, user, password,
13861404
database, port, unix_socket, client_flags);

0 commit comments

Comments
 (0)