Skip to content

Commit 9cebcd2

Browse files
committed
WL#14860: Support FIDO authentication (c-ext)
This worklog implements the FIDO U2F authentication, which is available since MySQL 8.0.27. It allows users to create password-less single or multi-factor authentication accounts that can connect to a MySQL server without using a password for the corresponding authenticator factor. This functionality is only available in the C extension implementation.
1 parent 7cd137f commit 9cebcd2

File tree

7 files changed

+153
-46
lines changed

7 files changed

+153
-46
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.29
1212
=======
1313

14+
- WL#14860: Support FIDO authentication (c-ext)
1415
- WL#14824: Remove Python 3.6 support
1516
- WL#14679: Allow custom class for data type conversion in Django backend
1617
- BUG#33729842: Character set 'utf8mb3' support

cpydist/__init__.py

Lines changed: 31 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright (c) 2020, 2021, Oracle and/or its affiliates.
1+
# Copyright (c) 2020, 2022, Oracle and/or its affiliates.
22
#
33
# This program is free software; you can redistribute it and/or modify
44
# it under the terms of the GNU General Public License, version 2.0, as
@@ -249,42 +249,34 @@ def _copy_vendor_libraries(self):
249249
if self.with_mysql_capi:
250250
plugin_ext = "dll" if os.name == "nt" else "so"
251251
plugin_path = os.path.join(self.with_mysql_capi, "lib", "plugin")
252-
253-
# authentication_ldap_sasl_client
254-
plugin_name = (
255-
"authentication_ldap_sasl_client.{}".format(plugin_ext)
256-
)
257-
plugin_full_path = os.path.join(plugin_path, plugin_name)
258-
self.log.debug("ldap plugin_path: '%s'", plugin_full_path)
259-
if os.path.exists(plugin_full_path):
260-
bundle_plugin_libs = True
261-
vendor_libs.append(
262-
(plugin_path, [os.path.join("plugin", plugin_name)])
263-
)
264-
265-
# authentication_kerberos_client
266-
plugin_name = (
267-
"authentication_kerberos_client.{}".format(plugin_ext)
268-
)
269-
plugin_full_path = os.path.join(plugin_path, plugin_name)
270-
self.log.debug("kerberos plugin_path: '%s'", plugin_full_path)
271-
if os.path.exists(plugin_full_path):
272-
bundle_plugin_libs = True
273-
vendor_libs.append(
274-
(plugin_path, [os.path.join("plugin", plugin_name)])
275-
)
276-
277-
# authentication_oci_client
278-
plugin_name = (
279-
"authentication_oci_client.{}".format(plugin_ext)
280-
)
281-
plugin_full_path = os.path.join(plugin_path, plugin_name)
282-
self.log.debug("OCI IAM plugin_path: '%s'", plugin_full_path)
283-
if os.path.exists(plugin_full_path):
284-
bundle_plugin_libs = True
285-
vendor_libs.append(
286-
(plugin_path, [os.path.join("plugin", plugin_name)])
252+
plugin_list = [
253+
(
254+
"LDAP",
255+
"authentication_ldap_sasl_client.{}".format(plugin_ext),
256+
),
257+
(
258+
"Kerberos",
259+
"authentication_kerberos_client.{}".format(plugin_ext),
260+
),
261+
(
262+
"OCI IAM",
263+
"authentication_oci_client.{}".format(plugin_ext),
264+
),
265+
(
266+
"FIDO",
267+
"authentication_fido_client.{}".format(plugin_ext),
268+
),
269+
]
270+
for plugin_name, plugin_file in plugin_list:
271+
plugin_full_path = os.path.join(plugin_path, plugin_file)
272+
self.log.debug(
273+
"%s plugin_path: '%s'", plugin_name, plugin_full_path,
287274
)
275+
if os.path.exists(plugin_full_path):
276+
bundle_plugin_libs = True
277+
vendor_libs.append(
278+
(plugin_path, [os.path.join("plugin", plugin_file)])
279+
)
288280

289281
# vendor libraries
290282
if bundle_plugin_libs and os.name == "nt":
@@ -365,7 +357,9 @@ def _copy_vendor_libraries(self):
365357
sasl_plugin_libs_w = [
366358
"libsasl2.*.*", "libgssapi_krb5.*.*", "libgssapi_krb5.*.*",
367359
"libkrb5.*.*", "libk5crypto.*.*", "libkrb5support.*.*",
368-
"libcrypto.*.*.*", "libssl.*.*.*", "libcom_err.*.*"]
360+
"libcrypto.*.*.*", "libssl.*.*.*", "libcom_err.*.*",
361+
"libfido2.*.*",
362+
]
369363
sasl_plugin_libs = []
370364
for sasl_lib in sasl_plugin_libs_w:
371365
lib_path_entries = glob(os.path.join(

lib/mysql/connector/abstracts.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright (c) 2014, 2021, Oracle and/or its affiliates.
1+
# Copyright (c) 2014, 2022, Oracle and/or its affiliates.
22
#
33
# This program is free software; you can redistribute it and/or modify
44
# it under the terms of the GNU General Public License, version 2.0, as
@@ -32,6 +32,8 @@
3232
from decimal import Decimal
3333
from time import sleep
3434
from datetime import date, datetime, time, timedelta
35+
from inspect import signature
36+
import importlib
3537
import os
3638
import re
3739
import weakref
@@ -108,6 +110,7 @@ def __init__(self, **kwargs):
108110
self._ssl_disabled = DEFAULT_CONFIGURATION["ssl_disabled"]
109111
self._force_ipv6 = False
110112
self._oci_config_file = None
113+
self._fido_callback = None
111114

112115
self._use_unicode = True
113116
self._get_warnings = False
@@ -651,6 +654,33 @@ def config(self, **kwargs):
651654
raise errors.InterfaceError(KRB_SERVICE_PINCIPAL_ERROR.format(
652655
error="is incorrectly formatted"))
653656

657+
if self._fido_callback:
658+
# Import the callable if it's a str
659+
if isinstance(self._fido_callback, str):
660+
try:
661+
module, callback = self._fido_callback.rsplit(".", 1)
662+
except ValueError:
663+
raise errors.ProgrammingError(
664+
f"No callable named '{self._fido_callback}'"
665+
)
666+
try:
667+
module = importlib.import_module(module)
668+
self._fido_callback = getattr(module, callback)
669+
except (AttributeError, ModuleNotFoundError) as err:
670+
raise errors.ProgrammingError(f"{err}")
671+
# Check if it's a callable
672+
if not callable(self._fido_callback):
673+
raise errors.ProgrammingError(
674+
"Expected a callable for 'fido_callback'"
675+
)
676+
# Check the callable signature if has only 1 positional argument
677+
params = len(signature(self._fido_callback).parameters)
678+
if params != 1:
679+
raise errors.ProgrammingError(
680+
"'fido_callback' requires 1 positional argument, but the "
681+
f"callback provided has {params}"
682+
)
683+
654684
def _add_default_conn_attrs(self):
655685
"""Add the default connection attributes."""
656686
pass

lib/mysql/connector/connection_cext.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright (c) 2014, 2021, Oracle and/or its affiliates.
1+
# Copyright (c) 2014, 2022, Oracle and/or its affiliates.
22
#
33
# This program is free software; you can redistribute it and/or modify
44
# it under the terms of the GNU General Public License, version 2.0, as
@@ -212,6 +212,7 @@ def _open_connection(self):
212212
"local_infile": self._allow_local_infile,
213213
"load_data_local_dir": self._allow_local_infile_in_path,
214214
"oci_config_file": self._oci_config_file,
215+
"fido_callback": self._fido_callback,
215216
}
216217

217218
tls_versions = self._ssl.get('tls_versions')

lib/mysql/connector/constants.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright (c) 2009, 2021, Oracle and/or its affiliates. All rights reserved.
1+
# Copyright (c) 2009, 2022, Oracle and/or its affiliates. All rights reserved.
22
#
33
# This program is free software; you can redistribute it and/or modify
44
# it under the terms of the GNU General Public License, version 2.0, as
@@ -89,7 +89,8 @@
8989
'dns_srv': False,
9090
'use_pure': False,
9191
'krb_service_principal': None,
92-
'oci_config_file': None
92+
'oci_config_file': None,
93+
'fido_callback': None,
9394
}
9495

9596
CNX_POOL_ARGS = ('pool_name', 'pool_size', 'pool_reset_session')

src/mysql_capi.c

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (c) 2014, 2021, Oracle and/or its affiliates.
2+
* Copyright (c) 2014, 2022, Oracle and/or its affiliates.
33
*
44
* This program is free software; you can redistribute it and/or modify
55
* it under the terms of the GNU General Public License, version 2.0, as
@@ -68,6 +68,29 @@ PyObject* MySQL_connected(MySQL *self);
6868
#define VERSION_OFFSET_MAJOR 10000
6969
#define VERSION_OFFSET_MINOR 100
7070

71+
// Python FIDO messages callback
72+
static PyObject *fido_callback = NULL;
73+
74+
void
75+
fido_messages_callback(const char *msg) {
76+
if (fido_callback && fido_callback != Py_None)
77+
{
78+
PyGILState_STATE state = PyGILState_Ensure();
79+
PyObject *args = Py_BuildValue("(z)", msg);
80+
PyObject *result = PyObject_Call(fido_callback, args, NULL);
81+
Py_DECREF(args);
82+
if (result)
83+
{
84+
Py_DECREF(result);
85+
}
86+
PyGILState_Release(state);
87+
}
88+
else
89+
{
90+
printf("%s", msg);
91+
}
92+
}
93+
7194
/**
7295
Helper function printing a string as hexadecimal.
7396
*/
@@ -1204,11 +1227,11 @@ MySQL_connect(MySQL *self, PyObject *args, PyObject *kwds)
12041227
"port", "unix_socket", "client_flags", "ssl_ca", "ssl_cert", "ssl_key",
12051228
"ssl_cipher_suites", "tls_versions", "tls_cipher_suites", "ssl_verify_cert",
12061229
"ssl_verify_identity", "ssl_disabled", "compress", "conn_attrs",
1207-
"local_infile", "load_data_local_dir", "oci_config_file",
1230+
"local_infile", "load_data_local_dir", "oci_config_file", "fido_callback",
12081231
NULL
12091232
};
12101233

1211-
if (!PyArg_ParseTupleAndKeywords(args, kwds, "|zzzzzzzkzkzzzzzzO!O!O!O!O!izz",
1234+
if (!PyArg_ParseTupleAndKeywords(args, kwds, "|zzzzzzzkzkzzzzzzO!O!O!O!O!izzO",
12121235
kwlist,
12131236
&host,
12141237
&user,
@@ -1233,7 +1256,8 @@ MySQL_connect(MySQL *self, PyObject *args, PyObject *kwds)
12331256
&PyDict_Type, &conn_attrs,
12341257
&local_infile,
12351258
&load_data_local_dir,
1236-
&oci_config_file))
1259+
&oci_config_file,
1260+
&fido_callback))
12371261
{
12381262
return NULL;
12391263
}
@@ -1468,6 +1492,34 @@ MySQL_connect(MySQL *self, PyObject *args, PyObject *kwds)
14681492
}
14691493
}
14701494

1495+
if (fido_callback && fido_callback != Py_None) {
1496+
/* load FIDO client authentication plugin if required */
1497+
struct st_mysql_client_plugin *fido_plugin =
1498+
mysql_client_find_plugin(&self->session, "authentication_fido_client",
1499+
MYSQL_CLIENT_AUTHENTICATION_PLUGIN);
1500+
if (!fido_plugin)
1501+
{
1502+
raise_with_string(
1503+
PyUnicode_FromString(
1504+
"The FIDO authentication plugin could not be loaded"), NULL
1505+
);
1506+
return NULL;
1507+
}
1508+
1509+
/* verify if the `fido_callback` is a proper callable */
1510+
if (!PyCallable_Check(fido_callback))
1511+
{
1512+
PyErr_SetString(
1513+
PyExc_TypeError, "Expected a callable for 'fido_callback'"
1514+
);
1515+
return NULL;
1516+
}
1517+
1518+
/* register callback */
1519+
mysql_plugin_options(fido_plugin, "fido_messages_callback",
1520+
(const void *)(&fido_messages_callback));
1521+
}
1522+
14711523
Py_BEGIN_ALLOW_THREADS
14721524
res= mysql_real_connect(&self->session, host, user, password, database,
14731525
port, unix_socket, client_flags);

tests/test_authentication.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# -*- coding: utf-8 -*-
22

3-
# Copyright (c) 2014, 2021, Oracle and/or its affiliates.
3+
# Copyright (c) 2014, 2022, Oracle and/or its affiliates.
44
#
55
# This program is free software; you can redistribute it and/or modify
66
# it under the terms of the GNU General Public License, version 2.0, as
@@ -1190,3 +1190,31 @@ def test_OCI_SDK_not_installed_error(self):
11901190
ProgrammingError,
11911191
auth_plugin.auth_response
11921192
)
1193+
1194+
1195+
@unittest.skipIf(
1196+
tests.MYSQL_VERSION < (8, 0, 29),
1197+
"Authentication with FIDO not supported"
1198+
)
1199+
class MySQLFIDOAuthPluginTests(tests.MySQLConnectorTests):
1200+
"""Test authentication.MySQLFIDOAuthPlugin.
1201+
1202+
Implemented by WL#14860: Support FIDO authentication (c-ext)
1203+
"""
1204+
1205+
@tests.foreach_cnx(CMySQLConnection)
1206+
def test_invalid_fido_callback(self):
1207+
"""Test invalid 'fido_callback' option."""
1208+
def my_callback():
1209+
...
1210+
1211+
test_cases = (
1212+
"abc", # No callable named 'abc'
1213+
"abc.abc", # module 'abc' has no attribute 'abc'
1214+
my_callback, # 1 positional argument required
1215+
)
1216+
config = tests.get_mysql_config()
1217+
config["auth_plugin"] = "authentication_fido_client"
1218+
for case in test_cases:
1219+
config["fido_callback"] = case
1220+
self.assertRaises(ProgrammingError, self.cnx.__class__, **config)

0 commit comments

Comments
 (0)