Skip to content

Commit 1829fbb

Browse files
committed
WL11680: 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 0e2bdee commit 1829fbb

File tree

6 files changed

+189
-9
lines changed

6 files changed

+189
-9
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ commit.txt
77
*.diff
88
dist/
99
build/
10+
mysql-vendor/
1011
MANIFEST
1112
cpy_server*/
1213
*_output.txt

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/test_bugs.py

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

@@ -4197,6 +4198,7 @@ def _drop_user(self, host, user):
41974198

41984199
def test_unicode_password(self):
41994200
config = tests.get_mysql_config()
4201+
config.pop('unix_socket')
42004202
config['user'] = self.user
42014203
config['password'] = self.password
42024204
try:
@@ -4292,7 +4294,7 @@ def _disable_ssl(self):
42924294
self.server.stop()
42934295
self.server.wait_down()
42944296

4295-
self.server.start(ssl_ca='', ssl_cert='', ssl_key='')
4297+
self.server.start(ssl_ca='', ssl_cert='', ssl_key='', ssl=0)
42964298
self.server.wait_up()
42974299
time.sleep(1)
42984300

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

0 commit comments

Comments
 (0)