Skip to content

Commit 52854c7

Browse files
committed
WL11073: Add caching_sha2_password authentication plugin
This patch adds caching_sha2_password plugin. This authentication mode requires SSL to function. There are 2 steps: 1. client sends a scramble in the form: XOR(SHA2(password), SHA2(SHA2(SHA2(password)), Nonce)) Nonce is provided by the server. This information can be sent over both secure and unsecure medium. 2. If server responds with Error packet, raise Error. Else if server responds with fast_auth_success, Step 2a. Else Step 2b. 2a. the server sends OK packet. Login is a success. 2b. Only this step requires SSL. Client must send the password to the server. To send the password to the server, the connection must be SSL. Once this password is validated, the user is stored in the cache and login is faster next time. Server can now send an OK packet or Error packet after validating user.
1 parent 3482c80 commit 52854c7

File tree

7 files changed

+224
-39
lines changed

7 files changed

+224
-39
lines changed

lib/mysql/connector/authentication.py

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,11 @@
2323

2424
"""Implementing support for MySQL Authentication Plugins"""
2525

26-
from hashlib import sha1
26+
from hashlib import sha1, sha256
2727
import struct
2828

2929
from . import errors
30-
from .catch23 import PY2, isstr
30+
from .catch23 import PY2, isstr, UNICODE_TYPES
3131

3232

3333
class BaseAuthPlugin(object):
@@ -173,6 +173,81 @@ def prepare_password(self):
173173
return password + b'\x00'
174174

175175

176+
class MySQLCachingSHA2PasswordAuthPlugin(BaseAuthPlugin):
177+
"""Class implementing the MySQL caching_sha2_password authentication plugin
178+
179+
Note that encrypting using RSA is not supported since the Python
180+
Standard Library does not provide this OpenSSL functionality.
181+
"""
182+
requires_ssl = False
183+
plugin_name = 'caching_sha2_password'
184+
perform_full_authentication = 4
185+
fast_auth_success = 3
186+
187+
def _scramble(self):
188+
""" Returns a scramble of the password using a Nonce sent by the
189+
server.
190+
191+
The scramble is of the form:
192+
XOR(SHA2(password), SHA2(SHA2(SHA2(password)), Nonce))
193+
"""
194+
if not self._auth_data:
195+
raise errors.InterfaceError("Missing authentication data (seed)")
196+
197+
if not self._password:
198+
return b''
199+
200+
password = self._password.encode('utf-8') \
201+
if isinstance(self._password, UNICODE_TYPES) else self._password
202+
203+
if PY2:
204+
password = buffer(password) # pylint: disable=E0602
205+
try:
206+
auth_data = buffer(self._auth_data) # pylint: disable=E0602
207+
except TypeError:
208+
raise errors.InterfaceError("Authentication data incorrect")
209+
else:
210+
password = password
211+
auth_data = self._auth_data
212+
213+
hash1 = sha256(password).digest()
214+
hash2 = sha256()
215+
hash2.update(sha256(hash1).digest())
216+
hash2.update(auth_data)
217+
hash2 = hash2.digest()
218+
if PY2:
219+
xored = [ord(h1) ^ ord(h2) for (h1, h2) in zip(hash1, hash2)]
220+
else:
221+
xored = [h1 ^ h2 for (h1, h2) in zip(hash1, hash2)]
222+
hash3 = struct.pack('32B', *xored)
223+
224+
return hash3
225+
226+
def prepare_password(self):
227+
if len(self._auth_data) > 1:
228+
return self._scramble()
229+
elif self._auth_data[0] == self.perform_full_authentication:
230+
return self._full_authentication()
231+
232+
def _full_authentication(self):
233+
"""Returns password as as clear text"""
234+
if not self._ssl_enabled:
235+
raise errors.InterfaceError("{name} requires SSL".format(
236+
name=self.plugin_name))
237+
238+
if not self._password:
239+
return b'\x00'
240+
password = self._password
241+
242+
if PY2:
243+
if isinstance(password, unicode): # pylint: disable=E0602
244+
password = password.encode('utf8')
245+
elif isinstance(password, str):
246+
password = password.encode('utf8')
247+
248+
return password + b'\x00'
249+
250+
176251
def get_auth_plugin(plugin_name):
177252
"""Return authentication class based on plugin name
178253

lib/mysql/connector/connection.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,8 @@ def _auth_switch_request(self, username=None, password=None):
170170
Raises NotSupportedError when we get the old, insecure password
171171
reply back. Raises any error coming from MySQL.
172172
"""
173+
auth = None
174+
new_auth_plugin = self._auth_plugin or self._handshake["auth_plugin"]
173175
packet = self._socket.recv()
174176
if packet[4] == 254 and len(packet) == 5:
175177
raise errors.NotSupportedError(
@@ -185,10 +187,19 @@ def _auth_switch_request(self, username=None, password=None):
185187
response = auth.auth_response()
186188
self._socket.send(response)
187189
packet = self._socket.recv()
188-
if packet[4] != 1:
189-
return self._handle_ok(packet)
190-
else:
191-
auth_data = self._protocol.parse_auth_more_data(packet)
190+
191+
if packet[4] == 1:
192+
auth_data = self._protocol.parse_auth_more_data(packet)
193+
auth = get_auth_plugin(new_auth_plugin)(
194+
auth_data, password=password, ssl_enabled=self._ssl_active)
195+
if new_auth_plugin == "caching_sha2_password":
196+
response = auth.auth_response()
197+
if response:
198+
self._socket.send(response)
199+
packet = self._socket.recv()
200+
201+
if packet[4] == 0:
202+
return self._handle_ok(packet)
192203
elif packet[4] == 255:
193204
raise errors.get_exception(packet)
194205

tests/cext/test_cext_api.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,7 @@ def test_commit(self):
458458
def test_change_user(self):
459459
connect_kwargs = self.connect_kwargs.copy()
460460
connect_kwargs['unix_socket'] = None
461+
connect_kwargs['ssl_disabled'] = False
461462
cmy1 = MySQL(buffered=True)
462463
cmy1.connect(**connect_kwargs)
463464
cmy2 = MySQL(buffered=True)
@@ -477,7 +478,7 @@ def test_change_user(self):
477478
pass
478479

479480
stmt = ("CREATE USER '{user}'@'{host}' IDENTIFIED WITH "
480-
"mysql_native_password").format(**new_user)
481+
"caching_sha2_password").format(**new_user)
481482
cmy1.query(stmt)
482483
cmy1.query("SET old_passwords = 0")
483484
res = cmy1.query("SET PASSWORD FOR '{user}'@'{host}' = "

tests/mysqld.py

Lines changed: 34 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -417,7 +417,6 @@ def _get_bootstrap_cmd(self):
417417
'--no-defaults',
418418
'--basedir=%s' % self._basedir,
419419
'--datadir=%s' % self._datadir,
420-
'--log-warnings=0',
421420
'--max_allowed_packet=8M',
422421
'--default-storage-engine=myisam',
423422
'--net_buffer_length=16K',
@@ -431,6 +430,9 @@ def _get_bootstrap_cmd(self):
431430
else:
432431
cmd.append("--bootstrap")
433432

433+
if self._version < (8, 0, 3):
434+
cmd.append('--log-warnings=0')
435+
434436
if self._version[0:2] < (5, 5):
435437
cmd.append('--language={0}/english'.format(self._lc_messages_dir))
436438
else:
@@ -467,34 +469,36 @@ def bootstrap(self):
467469
extra_sql = [
468470
"CREATE DATABASE myconnpy;"
469471
]
470-
defaults = ("'root'{0}, "
471-
"'Y','Y','Y','Y','Y','Y','Y','Y','Y','Y','Y','Y',"
472-
"'Y','Y','Y','Y','Y','Y','Y','Y','Y','Y','Y','Y',"
473-
"'Y','Y','Y','Y','Y','','','','',0,0,0,0,"
474-
"@@default_authentication_plugin,'','N',"
475-
"CURRENT_TIMESTAMP,NULL{1}")
476-
477-
hosts = ["::1", "127.0.0.1"]
478-
if self._version[0:3] < (8, 0, 1):
479-
# because we use --initialize-insecure for 8.0 above
480-
# which already creates root @ localhost
481-
hosts.append("localhost")
482-
483-
insert = "INSERT INTO mysql.user VALUES {0};".format(
484-
", ".join("('{0}', {{0}})".format(host) for host in hosts))
485-
486-
if self._version[0:3] >= (8, 0, 1):
487-
# No password column, has account_locked, Create_role_priv and
488-
# Drop_role_priv columns
489-
defaults = defaults.format("", ", 'N', 'Y', 'Y'")
490-
elif self._version[0:3] >= (5, 7, 6):
491-
# No password column, has account_locked column
492-
defaults = defaults.format("", ", 'N'")
493-
elif self._version[0:3] >= (5, 7, 5):
494-
# The password column
495-
defaults = defaults.format(", ''", "")
496-
497-
extra_sql.append(insert.format(defaults))
472+
473+
if self._version < (8, 0, 1):
474+
defaults = ("'root'{0}, "
475+
"'Y','Y','Y','Y','Y','Y','Y','Y','Y','Y','Y','Y',"
476+
"'Y','Y','Y','Y','Y','Y','Y','Y','Y','Y','Y','Y',"
477+
"'Y','Y','Y','Y','Y','','','','',0,0,0,0,"
478+
"@@default_authentication_plugin,'','N',"
479+
"CURRENT_TIMESTAMP,NULL{1}")
480+
481+
hosts = ["::1", "127.0.0.1", "localhost"]
482+
483+
insert = "INSERT INTO mysql.user VALUES {0};".format(
484+
", ".join("('{0}', {{0}})".format(host) for host in hosts))
485+
486+
if self._version[0:3] >= (5, 7, 6):
487+
# No password column, has account_locked column
488+
defaults = defaults.format("", ", 'N'")
489+
elif self._version[0:3] >= (5, 7, 5):
490+
# The password column
491+
defaults = defaults.format(", ''", "")
492+
493+
extra_sql.append(insert.format(defaults))
494+
else:
495+
extra_sql.extend([
496+
"CREATE USER 'root'@'127.0.01';",
497+
"GRANT ALL ON *.* TO 'root'@'127.0.0.1';",
498+
"CREATE USER 'root'@'::1';",
499+
"GRANT ALL ON *.* TO 'root'@'::1';"
500+
])
501+
498502
bootstrap_log = os.path.join(self._topdir, 'bootstrap.log')
499503
try:
500504
self._create_directories()
@@ -583,6 +587,7 @@ def update_config(self, **kwargs):
583587
'serverid': self._serverid,
584588
'lc_messages_dir': _convert_forward_slash(
585589
self._lc_messages_dir),
590+
'ssl': 1,
586591
}
587592

588593
for arg in self._extra_args:

tests/test_bugs.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,7 @@ class Bug519301(tests.MySQLConnectorTests):
385385
@foreach_cnx()
386386
def test_auth(self):
387387
config = self.config.copy()
388+
config.pop('unix_socket')
388389
config['user'] = 'ham'
389390
config['password'] = 'spam'
390391

@@ -4196,6 +4197,7 @@ def _drop_user(self, host, user):
41964197

41974198
def test_unicode_password(self):
41984199
config = tests.get_mysql_config()
4200+
config.pop('unix_socket')
41994201
config['user'] = self.user
42004202
config['password'] = self.password
42014203
try:
@@ -4291,7 +4293,7 @@ def _disable_ssl(self):
42914293
self.server.stop()
42924294
self.server.wait_down()
42934295

4294-
self.server.start(ssl_ca='', ssl_cert='', ssl_key='')
4296+
self.server.start(ssl_ca='', ssl_cert='', ssl_key='', ssl=0)
42954297
self.server.wait_up()
42964298
time.sleep(1)
42974299

tests/test_connection.py

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -742,6 +742,7 @@ def recv(self):
742742
def test__do_auth(self):
743743
"""Authenticate with the MySQL server"""
744744
self.cnx._socket.sock = tests.DummySocket()
745+
self.cnx._handshake["auth_plugin"] = "mysql_native_password"
745746
flags = constants.ClientFlag.get_default()
746747
kwargs = {
747748
'username': 'ham',
@@ -781,6 +782,94 @@ def test__do_auth(self):
781782
self.assertRaises(errors.ProgrammingError,
782783
self.cnx._do_auth, **kwargs)
783784

785+
@unittest.skipIf(not tests.SSL_AVAILABLE, "Python has no SSL support")
786+
@unittest.skipIf(tests.MYSQL_VERSION < (8, 0, 3),
787+
"caching_sha2_password plugin not supported by server.")
788+
def test_caching_sha2_password(self):
789+
"""Authenticate with the MySQL server using caching_sha2_password"""
790+
self.cnx._socket.sock = tests.DummySocket()
791+
flags = constants.ClientFlag.get_default()
792+
flags |= constants.ClientFlag.SSL
793+
kwargs = {
794+
'username': 'ham',
795+
'password': 'spam',
796+
'database': 'test',
797+
'charset': 33,
798+
'client_flags': flags,
799+
'ssl_options': {
800+
'ca': os.path.join(tests.SSL_DIR, 'tests_CA_cert.pem'),
801+
'cert': os.path.join(tests.SSL_DIR, 'tests_client_cert.pem'),
802+
'key': os.path.join(tests.SSL_DIR, 'tests_client_key.pem'),
803+
},
804+
}
805+
806+
self.cnx._handshake['auth_plugin'] = 'caching_sha2_password'
807+
self.cnx._handshake['auth_data'] = b'h4i6oP!OLng9&PD@WrYH'
808+
self.cnx._socket.switch_to_ssl = \
809+
lambda ca, cert, key, verify_cert, cipher: None
810+
811+
# Test perform_full_authentication
812+
# Exchange:
813+
# Client Server
814+
# ------ ------
815+
# make_ssl_auth
816+
# first_auth
817+
# full_auth
818+
# second_auth
819+
# OK
820+
self.cnx._socket.sock.reset()
821+
self.cnx._socket.sock.add_packets([
822+
bytearray(b'\x02\x00\x00\x03\x01\x04'), # full_auth request
823+
bytearray(b'\x07\x00\x00\x05\x00\x00\x00\x02\x00\x00\x00') # OK
824+
])
825+
self.cnx._do_auth(**kwargs)
826+
packets = self.cnx._socket.sock._client_sends
827+
self.assertEqual(3, len(packets))
828+
ssl_pkt = self.cnx._protocol.make_auth_ssl(
829+
charset=kwargs['charset'], client_flags=kwargs['client_flags'])
830+
# Check the SSL request packet
831+
self.assertEqual(packets[0][4:], ssl_pkt)
832+
auth_pkt = self.cnx._protocol.make_auth(
833+
self.cnx._handshake, kwargs['username'],
834+
kwargs['password'], kwargs['database'],
835+
charset=kwargs['charset'],
836+
client_flags=kwargs['client_flags'],
837+
ssl_enabled=True)
838+
# Check the first_auth packet
839+
self.assertEqual(packets[1][4:], auth_pkt)
840+
# Check the second_auth packet
841+
self.assertEqual(packets[2][4:],
842+
bytearray(kwargs["password"].encode('utf-8') + b"\x00"))
843+
844+
# Test fast_auth_success
845+
# Exchange:
846+
# Client Server
847+
# ------ ------
848+
# make_ssl_auth
849+
# first_auth
850+
# fast_auth
851+
# OK
852+
self.cnx._socket.sock.reset()
853+
self.cnx._socket.sock.add_packets([
854+
bytearray(b'\x02\x00\x00\x03\x01\x03'), # fast_auth success
855+
bytearray(b'\x07\x00\x00\x05\x00\x00\x00\x02\x00\x00\x00') # OK
856+
])
857+
self.cnx._do_auth(**kwargs)
858+
packets = self.cnx._socket.sock._client_sends
859+
self.assertEqual(2, len(packets))
860+
ssl_pkt = self.cnx._protocol.make_auth_ssl(
861+
charset=kwargs['charset'], client_flags=kwargs['client_flags'])
862+
# Check the SSL request packet
863+
self.assertEqual(packets[0][4:], ssl_pkt)
864+
auth_pkt = self.cnx._protocol.make_auth(
865+
self.cnx._handshake, kwargs['username'],
866+
kwargs['password'], kwargs['database'],
867+
charset=kwargs['charset'],
868+
client_flags=kwargs['client_flags'],
869+
ssl_enabled=True)
870+
# Check the first auth packet
871+
self.assertEqual(packets[1][4:], auth_pkt)
872+
784873
@unittest.skipIf(not tests.SSL_AVAILABLE, "Python has no SSL support")
785874
def test__do_auth_ssl(self):
786875
"""Authenticate with the MySQL server using SSL"""
@@ -812,7 +901,8 @@ def test__do_auth_ssl(self):
812901
self.cnx._handshake, kwargs['username'],
813902
kwargs['password'], kwargs['database'],
814903
charset=kwargs['charset'],
815-
client_flags=kwargs['client_flags']),
904+
client_flags=kwargs['client_flags'],
905+
ssl_enabled=True),
816906
]
817907
self.cnx._socket.switch_to_ssl = \
818908
lambda ca, cert, key, verify_cert, cipher: None

unittests.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,6 @@
148148
innodb_flush_log_at_trx_commit = 2
149149
innodb_log_file_size = 1Gb
150150
general_log_file = general_{name}.log
151-
ssl
152151
"""
153152

154153
# Platform specifics
@@ -169,6 +168,8 @@
169168
))
170169
MYSQL_DEFAULT_BASE = os.path.join('/', 'usr', 'local', 'mysql')
171170

171+
MY_CNF += "\nssl={ssl}"
172+
172173
MYSQL_DEFAULT_TOPDIR = _TOPDIR
173174

174175
_UNITTESTS_CMD_ARGS = {

0 commit comments

Comments
 (0)