Skip to content

Commit 3482c80

Browse files
committed
WL10771: Add SHA256 authentication
This patch adds 2 new authentication modes: Plain and External. The External authentication mechanism, at the time of writing, is not recognized by the server and will not work. The user can specify the authentication mode with the newly added option `auth` which can take the following values: 1. plain or Auth.PLAIN 2. external or Auth.EXTERNAL 3. mysql41 or Auth.MYSQL41 `Auth` is a new enum added to `mysqlx.constants` module. If the user does not specify the authentication mechanism, Plain authentication is used if the connection mode is secure, or else Mysql41 is used. Tests have been added for regression.
1 parent fc2c541 commit 3482c80

File tree

6 files changed

+126
-14
lines changed

6 files changed

+126
-14
lines changed

lib/mysqlx/__init__.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
PasswordHandler)
3131
from .compat import STRING_TYPES, urlparse, unquote, parse_qsl
3232
from .connection import Session
33-
from .constants import SSLMode
33+
from .constants import SSLMode, Auth
3434
from .crud import Schema, Collection, Table, View
3535
from .dbdoc import DbDoc
3636
from .errors import (Error, Warning, InterfaceError, DatabaseError,
@@ -51,7 +51,7 @@
5151
_PRIORITY = re.compile(r'^\(address=(.+),priority=(\d+)\)$', re.VERBOSE)
5252
ssl_opts = ["ssl-cert", "ssl-ca", "ssl-key", "ssl-crl"]
5353
sess_opts = ssl_opts + ["user", "password", "schema", "host", "port",
54-
"routers", "socket", "ssl-mode"]
54+
"routers", "socket", "ssl-mode", "auth"]
5555

5656
def _parse_address_list(path):
5757
"""Parses a list of host, port pairs
@@ -174,6 +174,13 @@ def _validate_settings(settings):
174174
not in [SSLMode.VERIFY_IDENTITY, SSLMode.VERIFY_CA]:
175175
raise InterfaceError("Must verify Server if CA is provided.")
176176

177+
if "auth" in settings:
178+
try:
179+
settings["auth"] = settings["auth"].lower()
180+
Auth.index(settings["auth"])
181+
except (AttributeError, ValueError):
182+
raise InterfaceError("Invalid Auth '{0}'".format(settings["auth"]))
183+
177184

178185
def _validate_hosts(settings):
179186
if "priority" in settings and settings["priority"]:

lib/mysqlx/authentication.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,30 @@ def build_authentication_response(self, data):
6363
hexlify(auth_response))
6464
else:
6565
return "{0}\0{1}\0".format("", self._username)
66+
67+
68+
class PlainAuthPlugin(object):
69+
def __init__(self, username, password):
70+
self._username = username
71+
self._password = password.encode("utf-8") \
72+
if isinstance(password, UNICODE_TYPES) else password
73+
74+
def name(self):
75+
return "Plain Authentication Plugin"
76+
77+
def auth_name(self):
78+
return "PLAIN"
79+
80+
def auth_data(self):
81+
return "\0{0}\0{1}".format(self._username, self._password)
82+
83+
84+
class ExternalAuthPlugin(object):
85+
def name(self):
86+
return "External Authentication Plugin"
87+
88+
def auth_name(self):
89+
return "EXTERNAL"
90+
91+
def initial_response(self):
92+
return ""

lib/mysqlx/connection.py

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,12 @@
3535

3636
from functools import wraps
3737

38-
from .authentication import MySQL41AuthPlugin
38+
from .authentication import (MySQL41AuthPlugin, PlainAuthPlugin,
39+
ExternalAuthPlugin)
3940
from .errors import InterfaceError, OperationalError, ProgrammingError
4041
from .compat import PY3, STRING_TYPES, UNICODE_TYPES
4142
from .crud import Schema
42-
from .constants import SSLMode
43+
from .constants import SSLMode, Auth
4344
from .helpers import get_item_or_attr
4445
from .protocol import Protocol, MessageReaderWriter
4546
from .result import Result, RowResult, DocResult
@@ -54,16 +55,20 @@ class SocketStream(object):
5455
def __init__(self):
5556
self._socket = None
5657
self._is_ssl = False
58+
self._is_socket = False
5759
self._host = None
5860

5961
def connect(self, params):
60-
if isinstance(params, tuple):
62+
try:
63+
self._socket = socket.create_connection(params)
6164
self._host = params[0]
62-
s_type = socket.AF_INET6 if ":" in params[0] else socket.AF_INET
63-
else:
64-
s_type = socket.AF_UNIX
65-
self._socket = socket.socket(s_type, socket.SOCK_STREAM)
66-
self._socket.connect(params)
65+
except ValueError:
66+
try:
67+
self._socket = socket.socket(socket.AF_UNIX)
68+
self._is_socket = True
69+
self._socket.connect(params)
70+
except AttributeError:
71+
raise InterfaceError("Unix socket unsupported.")
6772

6873
def read(self, count):
6974
if self._socket is None:
@@ -131,6 +136,17 @@ def set_ssl(self, ssl_mode, ssl_ca, ssl_crl, ssl_cert, ssl_key):
131136
"".format(err))
132137
self._is_ssl = True
133138

139+
@property
140+
def is_ssl(self):
141+
return self._is_ssl
142+
143+
@property
144+
def is_socket(self):
145+
return self._is_socket
146+
147+
def is_secure(self):
148+
return self._is_ssl or self.is_socket
149+
134150

135151
def catch_network_exception(func):
136152
@wraps(func)
@@ -237,7 +253,7 @@ def connect(self):
237253
def _handle_capabilities(self):
238254
if self.settings.get("ssl-mode") == SSLMode.DISABLED:
239255
return
240-
if "socket" in self.settings:
256+
if self.stream.is_socket:
241257
if self.settings.get("ssl-mode"):
242258
_LOGGER.warning("SSL not required when using Unix socket.")
243259
return
@@ -261,13 +277,34 @@ def _handle_capabilities(self):
261277
self.settings.get("ssl-key"))
262278

263279
def _authenticate(self):
280+
auth = self.settings.get("auth")
281+
if (not auth and self.stream.is_secure()) or auth == Auth.PLAIN:
282+
self._authenticate_plain()
283+
elif auth == Auth.EXTERNAL:
284+
self._authenticate_external()
285+
else:
286+
self._authenticate_mysql41()
287+
288+
def _authenticate_mysql41(self):
264289
plugin = MySQL41AuthPlugin(self._user, self._password)
265290
self.protocol.send_auth_start(plugin.auth_name())
266291
extra_data = self.protocol.read_auth_continue()
267292
self.protocol.send_auth_continue(
268293
plugin.build_authentication_response(extra_data))
269294
self.protocol.read_auth_ok()
270295

296+
def _authenticate_plain(self):
297+
plugin = PlainAuthPlugin(self._user, self._password)
298+
self.protocol.send_auth_start(plugin.auth_name(),
299+
auth_data=plugin.auth_data())
300+
self.protocol.read_auth_ok()
301+
302+
def _authenticate_external(self):
303+
plugin = ExternalAuthPlugin()
304+
self.protocol.send_auth_start(plugin.auth_name(),
305+
initial_response=plugin.initial_response())
306+
self.protocol.read_auth_ok()
307+
271308
@catch_network_exception
272309
def send_sql(self, sql, *args):
273310
if not isinstance(sql, STRING_TYPES):

lib/mysqlx/constants.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ def create_enum(name, fields, values=None):
4949
SSLMode = create_enum("SSLMode",
5050
("REQUIRED", "DISABLED", "VERIFY_CA", "VERIFY_IDENTITY"),
5151
("required", "disabled", "verify_ca", "verify_identity"))
52-
52+
Auth = create_enum("Auth", ("PLAIN", "EXTERNAL", "MYSQL41"),
53+
("plain", "external", "mysql41"))
5354

5455
__all__ = ["Algorithms", "Securities", "CheckOptions"]

lib/mysqlx/protocol.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,9 +97,13 @@ def set_capabilities(self, **kwargs):
9797
msg)
9898
return self.read_ok()
9999

100-
def send_auth_start(self, method):
100+
def send_auth_start(self, method, auth_data=None, initial_response=None):
101101
msg = Message("Mysqlx.Session.AuthenticateStart")
102102
msg["mech_name"] = method
103+
if auth_data is not None:
104+
msg["auth_data"] = auth_data
105+
if initial_response is not None:
106+
msg["initial_response"] = initial_response
103107
self._writer.write_message(mysqlxpb_enum(
104108
"Mysqlx.ClientMessages.Type.SESS_AUTHENTICATE_START"), msg)
105109

tests/test_mysqlx_connection.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ def test___init__(self):
179179
"username": "root",
180180
"password": ""
181181
}
182-
self.assertRaises(TypeError, mysqlx.Session, bad_config)
182+
self.assertRaises(InterfaceError, mysqlx.Session, bad_config)
183183

184184
host = self.connect_kwargs["host"]
185185
port = self.connect_kwargs["port"]
@@ -276,6 +276,42 @@ def test___init__(self):
276276
if os.path.exists(sys_file):
277277
os.remove(sys_file)
278278

279+
# SocketSteam.is_socket
280+
session = mysqlx.get_session(user=user, password=password,
281+
host=host, port=port)
282+
self.assertFalse(session._connection.stream.is_socket)
283+
284+
def test_auth(self):
285+
sess = mysqlx.get_session(self.connect_kwargs)
286+
sess.sql("CREATE USER 'native'@'%' IDENTIFIED WITH "
287+
"mysql_native_password BY 'test'").execute()
288+
sess.sql("CREATE USER 'sha256'@'%' IDENTIFIED WITH "
289+
"sha256_password BY 'sha256'").execute()
290+
291+
config = {'host': self.connect_kwargs['host'],
292+
'port': self.connect_kwargs['port']}
293+
294+
config['user'] = 'native'
295+
config['password'] = 'test'
296+
config['auth'] = 'plain'
297+
mysqlx.get_session(config)
298+
299+
config['auth'] = 'mysql41'
300+
mysqlx.get_session(config)
301+
302+
config['user'] = 'sha256'
303+
config['password'] = 'sha256'
304+
if tests.MYSQL_VERSION >= (8, 0, 1):
305+
config['auth'] = 'plain'
306+
mysqlx.get_session(config)
307+
308+
config['auth'] = 'mysql41'
309+
self.assertRaises(InterfaceError, mysqlx.get_session, config)
310+
311+
sess.sql("DROP USER 'native'@'%'").execute()
312+
sess.sql("DROP USER 'sha256'@'%'").execute()
313+
sess.close()
314+
279315
@unittest.skipIf(tests.MYSQL_VERSION < (5, 7, 15), "--mysqlx-socket option tests not available for this MySQL version")
280316
def test_mysqlx_socket(self):
281317
# Connect with unix socket

0 commit comments

Comments
 (0)