Skip to content

Commit 9f6feb3

Browse files
committed
WL14263: Add support for SCRAM-SHA-256
The purpose of this Worklog is to add support for the SASL authentication protocol using the SCRAM-SHA-256 authentication method.
1 parent 1d4f435 commit 9f6feb3

File tree

4 files changed

+245
-14
lines changed

4 files changed

+245
-14
lines changed

lib/mysql/connector/authentication.py

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
from uuid import uuid4
3737

3838
from . import errors
39-
from .catch23 import PY2, isstr, UNICODE_TYPES, STRING_TYPES
39+
from .catch23 import PY2, isstr, UNICODE_TYPES, BYTE_TYPES
4040
from .utils import (normalize_unicode_string as norm_ustr,
4141
validate_normalized_unicode_string as valid_norm)
4242

@@ -267,9 +267,9 @@ class MySQLLdapSaslPasswordAuthPlugin(BaseAuthPlugin):
267267
268268
The MySQL's ldap sasl authentication plugin support two authentication
269269
methods SCRAM-SHA-1 and GSSAPI (using Kerberos). This implementation only
270-
support SCRAM-SHA-1.
270+
support SCRAM-SHA-1 and SCRAM-SHA-256.
271271
272-
SCRAM-SHA-1
272+
SCRAM-SHA-1 amd SCRAM-SHA-256
273273
This method requires 2 messages from client and 2 responses from
274274
server.
275275
@@ -280,6 +280,7 @@ class MySQLLdapSaslPasswordAuthPlugin(BaseAuthPlugin):
280280
server, the second server respond needs to be passed to auth_finalize()
281281
to finish the authentication process.
282282
"""
283+
sasl_mechanisms = ['SCRAM-SHA-1', 'SCRAM-SHA-256']
283284
requires_ssl = False
284285
plugin_name = 'authentication_ldap_sasl_client'
285286
def_digest_mode = sha1
@@ -354,14 +355,17 @@ def auth_response(self):
354355
355356
Returns bytes to send to the server as the first message.
356357
"""
357-
# We only support SCRAM-SHA-1 authentication method.
358-
_LOGGER.debug("read_method_name_from_server: %s",
359-
self._auth_data.decode())
360-
if self._auth_data != b'SCRAM-SHA-1':
358+
auth_mechanism = self._auth_data.decode()
359+
_LOGGER.debug("read_method_name_from_server: %s", auth_mechanism)
360+
if auth_mechanism not in self.sasl_mechanisms:
361361
raise errors.InterfaceError(
362362
'The sasl authentication method "{}" requested from the server '
363-
'is not supported. Only "{}" is supported'.format(
364-
self._auth_data, "SCRAM-SHA-1"))
363+
'is not supported. Only "{}" and "{}" are supported'.format(
364+
auth_mechanism, '", "'.join(self.sasl_mechanisms[:-1]),
365+
self.sasl_mechanisms[-1]))
366+
367+
if self._auth_data == b'SCRAM-SHA-256':
368+
self.def_digest_mode = sha256
365369

366370
return self._first_message()
367371

@@ -440,11 +444,12 @@ def _validate_first_reponse(self, servers_first):
440444
First message from the server is in the form:
441445
<server_salt>,i=<iterations>
442446
"""
443-
self.servers_first = servers_first
444-
if not servers_first or not isinstance(servers_first, STRING_TYPES):
447+
if not servers_first or not isinstance(servers_first, BYTE_TYPES):
445448
raise errors.InterfaceError("Unexpected server message: {}"
446449
"".format(servers_first))
447450
try:
451+
servers_first = servers_first.decode()
452+
self.servers_first = servers_first
448453
r_server_nonce, s_salt, i_counter = servers_first.split(",")
449454
except ValueError:
450455
raise errors.InterfaceError("Unexpected server message: {}"

lib/mysql/connector/connection.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,7 @@ def _auth_switch_request(self, username=None, password=None):
251251

252252
if packet[5] == 114 and packet[6] == 61: # 'r' and '='
253253
# Continue with sasl authentication
254-
dec_response = packet[5:].decode()
254+
dec_response = packet[5:]
255255
cresponse = auth.auth_continue(dec_response)
256256
self._socket.send(cresponse)
257257
packet = self._socket.recv()

tests/test_authentication.py

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -282,13 +282,15 @@ def test_auth_response(self):
282282

283283
# verify an error is shown if server response is not well formated.
284284
with self.assertRaises(InterfaceError) as context:
285-
auth_plugin.auth_continue("r=/ZT33fXoR/BZT,s=IApa7ZwqQ/ZT,w54")
285+
auth_plugin.auth_continue(
286+
bytearray("r=/ZT33fXoR/BZT,s=IApa7ZwqQ/ZT,w54".encode()))
286287
self.assertIn("Incomplete reponse", context.exception.msg,
287288
"not the expected error {}".format(context.exception.msg))
288289

289290
# verify an error is shown if server does not authenticate response.
290291
with self.assertRaises(InterfaceError) as context:
291-
auth_plugin.auth_continue("r=/ZT33fXoR/BZT,s=IApa7ZwqQ/ZT,i=40")
292+
auth_plugin.auth_continue(
293+
bytearray("r=/ZT33fXoR/BZT,s=IApa7ZwqQ/ZT,i=40".encode()))
292294
self.assertIn("Unable to authenticate resp", context.exception.msg,
293295
"not the expected error {}".format(context.exception.msg))
294296

@@ -306,3 +308,99 @@ def test_auth_response(self):
306308
bytearray(b"v=5H6b+IApa7ZwqQ/ZT33fXoR/BTM="))
307309
self.assertIn("Unable to proof server identity", context.exception.msg,
308310
"not the expected error {}".format(context.exception.msg))
311+
312+
def test_auth_response256(self):
313+
# Test unsupported mechanism error message
314+
auth_data = b'UNKOWN-METHOD'
315+
auth_plugin = self.plugin_class(auth_data, username="user",
316+
password="spam")
317+
with self.assertRaises(InterfaceError) as context:
318+
auth_plugin.auth_response()
319+
self.assertIn('sasl authentication method "UNKOWN-METHOD"',
320+
context.exception.msg, "not the expected error {}"
321+
"".format(context.exception.msg))
322+
self.assertIn("is not supported", context.exception.msg,
323+
"not the expected error {}".format(context.exception.msg))
324+
with self.assertRaises(NotImplementedError) as context:
325+
auth_plugin.prepare_password()
326+
327+
# Test SCRAM-SHA-256 mechanism is accepted
328+
auth_data = b'SCRAM-SHA-256'
329+
330+
auth_plugin = self.plugin_class(auth_data, username="", password="")
331+
332+
# Verify the format of the first message from client.
333+
exp = b'n,a=,n=,r='
334+
client_first_nsg = auth_plugin.auth_response()
335+
self.assertTrue(client_first_nsg.startswith(exp),
336+
"got header: {}".format(auth_plugin.auth_response()))
337+
338+
auth_plugin = self.plugin_class(auth_data, username="user",
339+
password="spam")
340+
341+
# Verify the length of the client's nonce in r=
342+
cnonce = client_first_nsg[(len(b'n,a=,n=,r=')):]
343+
r_len = len(cnonce)
344+
self.assertEqual(32, r_len, "Unexpected legth {}".format(len(cnonce)))
345+
346+
# Verify the format of the first message from client.
347+
exp = b'n,a=user,n=user,r='
348+
client_first_nsg = auth_plugin.auth_response()
349+
self.assertTrue(client_first_nsg.startswith(exp),
350+
"got header: {}".format(auth_plugin.auth_response()))
351+
352+
# Verify the length of the client's nonce in r=
353+
cnonce = client_first_nsg[(len(exp)):]
354+
r_len = len(cnonce)
355+
self.assertEqual(32, r_len, "Unexpected cnonce legth {}, response {}"
356+
"".format(len(cnonce), client_first_nsg))
357+
358+
# Verify that a user name that requires character mapping is mapped
359+
auth_plugin = self.plugin_class(auth_data, username=u"u\u1680ser",
360+
password="spam")
361+
exp = b'n,a=u ser,n=u ser,r='
362+
client_first_nsg = auth_plugin.auth_response()
363+
self.assertTrue(client_first_nsg.startswith(exp),
364+
"got header: {}".format(auth_plugin.auth_response()))
365+
366+
# Verify the length of the client's nonce in r=
367+
cnonce = client_first_nsg[(len(exp)):]
368+
r_len = len(cnonce)
369+
self.assertEqual(32, r_len, "Unexpected legth {}".format(len(cnonce)))
370+
371+
bad_responses = [None, "", "v=5H6b+IApa7ZwqQ/ZT33fXoR/BTM=", b"", 123]
372+
for bad_res in bad_responses:
373+
# verify an error is shown if server response is not as expected.
374+
with self.assertRaises(InterfaceError) as context:
375+
auth_plugin.auth_continue(bad_res)
376+
self.assertIn("Unexpected server message", context.exception.msg,
377+
"not the expected: {}".format(context.exception.msg))
378+
379+
# verify an error is shown if server response is not well formated.
380+
with self.assertRaises(InterfaceError) as context:
381+
auth_plugin.auth_continue(
382+
bytearray(b"r=/ZT33fXoR/BZT,s=IApa7ZwqQ/ZT,w54"))
383+
self.assertIn("Incomplete reponse", context.exception.msg,
384+
"not the expected error {}".format(context.exception.msg))
385+
386+
# verify an error is shown if server does not authenticate response.
387+
with self.assertRaises(InterfaceError) as context:
388+
auth_plugin.auth_continue(
389+
bytearray(b"r=/ZT33fXoR/BZT,s=IApa7ZwqQ/ZT,i=40"))
390+
self.assertIn("Unable to authenticate resp", context.exception.msg,
391+
"not the expected error {}".format(context.exception.msg))
392+
393+
bad_proofs = [None, "", b"5H6b+IApa7ZwqQ/ZT33fXoR/BTM=", b"", 123]
394+
for bad_proof in bad_proofs:
395+
# verify an error is shown if server proof is not well formated.
396+
with self.assertRaises(InterfaceError) as context:
397+
auth_plugin.auth_finalize(bad_proof)
398+
self.assertIn("proof is not well formated.", context.exception.msg,
399+
"not the expected: {}".format(context.exception.msg))
400+
401+
# verify an error is shown it the server can not prove it self.
402+
with self.assertRaises(InterfaceError) as context:
403+
auth_plugin.auth_finalize(
404+
bytearray(b"v=5H6b+IApa7ZwqQ/ZT33fXoR/BTM="))
405+
self.assertIn("Unable to proof server identity", context.exception.msg,
406+
"not the expected error {}".format(context.exception.msg))

tests/test_connection.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2832,6 +2832,134 @@ def test_clear_text_pass(self):
28322832
self.assertRaises(InterfaceError, self.cnx.__class__, **conn_args)
28332833

28342834

2835+
@unittest.skipIf(tests.MYSQL_VERSION < (5, 7),
2836+
"Authentication with ldap_simple not supported")
2837+
#Skip if remote ldap server is not reachable.
2838+
@unittest.skipIf(not tests.is_host_reachable("100.103.19.5"),
2839+
"ldap server is not reachable")
2840+
@unittest.skipIf(not tests.is_plugin_available("authentication_ldap_sasl"),
2841+
"Plugin authentication_ldap_simple not available")
2842+
class WL14263(tests.MySQLConnectorTests):
2843+
"""WL#14110: Add support for SCRAM-SHA-256
2844+
"""
2845+
def setUp(self):
2846+
self.server = tests.MYSQL_SERVERS[0]
2847+
self.server_cnf = self.server._cnf
2848+
self.config = tests.get_mysql_config()
2849+
self.config.pop("unix_socket", None)
2850+
self.user = "sadmin"
2851+
self.host = "%"
2852+
2853+
cnx = connection.MySQLConnection(**self.config)
2854+
ext = "dll" if os.name == "nt" else "so"
2855+
plugin_name = "authentication_ldap_sasl.{}".format(ext)
2856+
2857+
ldap_sasl_config = {
2858+
"plugin-load-add": plugin_name,
2859+
"authentication_ldap_sasl_auth_method_name": "SCRAM-SHA-256",
2860+
"authentication_ldap_sasl_bind_base_dn": '"dc=my-domain,dc=com"',
2861+
"authentication_ldap_sasl_log_status": 5,
2862+
"authentication_ldap_sasl_server_host": "100.103.19.5", #ldap-mtr.no.oracle.com
2863+
"authentication_ldap_sasl_group_search_attr": "",
2864+
"authentication_ldap_sasl_user_search_attr": "cn",
2865+
}
2866+
cnf = "\n# ldap_sasl"
2867+
for key in ldap_sasl_config:
2868+
cnf = "{}\n{}={}".format(cnf, key, ldap_sasl_config[key])
2869+
self.server_cnf += cnf
2870+
2871+
cnx.close()
2872+
self.server.stop()
2873+
self.server.wait_down()
2874+
2875+
self.server.start(my_cnf=self.server_cnf)
2876+
self.server.wait_up()
2877+
sleep(1)
2878+
2879+
cnx = connection.MySQLConnection(**self.config)
2880+
2881+
try:
2882+
cnx.cmd_query("DROP USER '{}'@'{}'".format(self.user, self.host))
2883+
cnx.cmd_query("DROP USER '{}'@'{}'".format("common", self.host))
2884+
except:
2885+
pass
2886+
2887+
cnx.cmd_query("CREATE USER '{}'@'{}' IDENTIFIED "
2888+
"WITH authentication_ldap_sasl"
2889+
"".format(self.user, self.host))
2890+
2891+
cnx.cmd_query("CREATE USER '{}'@'{}'"
2892+
"".format("common", self.host))
2893+
cnx.cmd_query("GRANT ALL ON *.* TO '{}'@'{}'"
2894+
"".format("common", self.host))
2895+
2896+
cnx.close()
2897+
2898+
def tearDown(self):
2899+
return
2900+
cnx = connection.MySQLConnection(**self.config)
2901+
try:
2902+
cnx.cmd_query("DROP USER '{}'@'{}'".format(self.user, self.host))
2903+
cnx.cmd_query("DROP USER '{}'@'{}'".format("common", self.host))
2904+
except:
2905+
pass
2906+
cnx.cmd_query("UNINSTALL PLUGIN authentication_ldap_sasl")
2907+
cnx.close()
2908+
2909+
@tests.foreach_cnx()
2910+
def test_authentication_ldap_sasl_client(self):
2911+
"""test_authentication_ldap_sasl_client_with_SCRAM-SHA-1"""
2912+
# Not running with c-ext if plugin libraries are not setup
2913+
if self.cnx.__class__ == CMySQLConnection and \
2914+
os.getenv('TEST_AUTHENTICATION_LDAP_SASL_CLIENT_CEXT', None) is None:
2915+
return
2916+
conn_args = {
2917+
"user": "sadmin",
2918+
"host": self.config["host"],
2919+
"port": self.config["port"],
2920+
"password": "perola",
2921+
}
2922+
2923+
# Atempt connection with wrong password
2924+
bad_pass_args = conn_args.copy()
2925+
bad_pass_args["password"] = "wrong_password"
2926+
with self.assertRaises(ProgrammingError) as context:
2927+
_ = self.cnx.__class__(**bad_pass_args)
2928+
self.assertIn("Access denied for user", context.exception.msg,
2929+
"not the expected error {}".format(context.exception.msg))
2930+
2931+
# Atempt connection with correct password
2932+
cnx = self.cnx.__class__(**conn_args)
2933+
cnx.cmd_query('SELECT USER()')
2934+
res = cnx.get_rows()[0][0][0]
2935+
self.assertIn(self.user, res, "not the expected user {}".format(res))
2936+
cnx.close()
2937+
2938+
# Force unix_socket to None
2939+
conn_args["unix_socket"] = None
2940+
cnx = self.cnx.__class__(**conn_args)
2941+
cnx.cmd_query('SELECT USER()')
2942+
res = cnx.get_rows()[0][0][0]
2943+
self.assertIn(self.user, res, "not the expected user {}".format(res))
2944+
cnx.close()
2945+
2946+
# Attempt connection with verify certificate set to True
2947+
conn_args.update({
2948+
'ssl_ca': os.path.abspath(
2949+
os.path.join(tests.SSL_DIR, 'tests_CA_cert.pem')),
2950+
'ssl_cert': os.path.abspath(
2951+
os.path.join(tests.SSL_DIR, 'tests_client_cert.pem')),
2952+
'ssl_key': os.path.abspath(
2953+
os.path.join(tests.SSL_DIR, 'tests_client_key.pem')),
2954+
})
2955+
conn_args["ssl_verify_cert"] = True
2956+
cnx = self.cnx.__class__(**conn_args)
2957+
cnx.cmd_query('SELECT USER()')
2958+
res = cnx.get_rows()[0][0][0]
2959+
self.assertIn(self.user, res, "not the expected user {}".format(res))
2960+
cnx.close()
2961+
2962+
28352963
class WL13334(tests.MySQLConnectorTests):
28362964
"""WL#13334: Failover and multihost
28372965
"""

0 commit comments

Comments
 (0)