# Copyright (c) 2014, 2024, Oracle and/or its affiliates. # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2.0, as # published by the Free Software Foundation. # # This program is designed to work with certain software (including # but not limited to OpenSSL) that is licensed under separate terms, # as designated in a particular file or component or in included license # documentation. The authors of MySQL hereby grant you an # additional permission to link the program and your derivative works # with the separately licensed software that they have either included with # the program or referenced in the documentation. # # Without limiting anything contained in the foregoing, this file, # which is part of MySQL Connector/Python, is also subject to the # Universal FOSS Exception, version 1.0, a copy of which can be found at # http://oss.oracle.com/licenses/universal-foss-exception. # # This program is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. # See the GNU General Public License, version 2.0, for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA """Implementing support for MySQL Authentication Plugins""" from __future__ import annotations import copy from typing import TYPE_CHECKING, Any, Dict, Optional from .errors import InterfaceError, NotSupportedError, get_exception from .logger import logger from .plugins import MySQLAuthPlugin, get_auth_plugin from .protocol import ( AUTH_SWITCH_STATUS, DEFAULT_CHARSET_ID, DEFAULT_MAX_ALLOWED_PACKET, ERR_STATUS, EXCHANGE_FURTHER_STATUS, MFA_STATUS, OK_STATUS, MySQLProtocol, ) from .types import HandShakeType if TYPE_CHECKING: from .network import MySQLSocket class MySQLAuthenticator: """Implements the authentication phase.""" def __init__(self) -> None: """Constructor.""" self._username: str = "" self._passwords: Dict[int, str] = {} self._plugin_config: Dict[str, Any] = {} self._ssl_enabled: bool = False self._auth_strategy: Optional[MySQLAuthPlugin] = None self._auth_plugin_class: Optional[str] = None @property def ssl_enabled(self) -> bool: """Signals whether or not SSL is enabled.""" return self._ssl_enabled @property def plugin_config(self) -> Dict[str, Any]: """Custom arguments that are being provided to the authentication plugin when called. The parameters defined here will override the ones defined in the auth plugin itself. The plugin config is a read-only property - the plugin configuration provided when invoking `authenticate()` is recorded and can be queried by accessing this property. Returns: dict: The latest plugin configuration provided when invoking `authenticate()`. """ return self._plugin_config def setup_ssl( self, sock: MySQLSocket, host: str, ssl_options: Optional[Dict[str, Any]], charset: int = DEFAULT_CHARSET_ID, client_flags: int = 0, max_allowed_packet: int = DEFAULT_MAX_ALLOWED_PACKET, ) -> bytes: """Sets up an SSL communication channel. Args: sock: Pointer to the socket connection. host: Server host name. ssl_options: SSL and TLS connection options (see `network.MySQLSocket.build_ssl_context`). charset: Client charset (see [1]), only the lower 8-bits. client_flags: Integer representing client capabilities flags. max_allowed_packet: Maximum packet size. Returns: ssl_request_payload: Payload used to carry out SSL authentication. References: [1]: https://dev.mysql.com/doc/dev/mysql-server/latest/\ page_protocol_basic_character_set.html#a_protocol_character_set """ if ssl_options is None: ssl_options = {} # SSL connection request packet ssl_request_payload = MySQLProtocol.make_auth_ssl( charset=charset, client_flags=client_flags, max_allowed_packet=max_allowed_packet, ) sock.send(ssl_request_payload) logger.debug("Building SSL context") ssl_context = sock.build_ssl_context( ssl_ca=ssl_options.get("ca"), ssl_cert=ssl_options.get("cert"), ssl_key=ssl_options.get("key"), ssl_verify_cert=ssl_options.get("verify_cert", False), ssl_verify_identity=ssl_options.get("verify_identity", False), tls_versions=ssl_options.get("tls_versions"), tls_cipher_suites=ssl_options.get("tls_ciphersuites"), ) logger.debug("Switching to SSL") sock.switch_to_ssl(ssl_context, host) logger.debug("SSL has been enabled") self._ssl_enabled = True return ssl_request_payload def _switch_auth_strategy( self, new_strategy_name: str, strategy_class: Optional[str] = None, username: Optional[str] = None, password_factor: int = 1, ) -> None: """Switches the authorization plugin. Args: new_strategy_name: New authorization plugin name to switch to. strategy_class: New authorization plugin class to switch to (has higher precedence than the authorization plugin name). username: Username to be used - if not defined, the username provided when `authentication()` was invoked is used. password_factor: Up to three levels of authentication (MFA) are allowed, hence you can choose the password corresponding to the 1st, 2nd, or 3rd factor - 1st is the default. """ if username is None: username = self._username if strategy_class is None: strategy_class = self._auth_plugin_class logger.debug("Switching to strategy %s", new_strategy_name) self._auth_strategy = get_auth_plugin( plugin_name=new_strategy_name, auth_plugin_class=strategy_class )( username, self._passwords.get(password_factor, ""), ssl_enabled=self.ssl_enabled, ) def _mfa_n_factor( self, sock: MySQLSocket, pkt: bytes, ) -> Optional[bytes]: """Handles MFA (Multi-Factor Authentication) response. Up to three levels of authentication (MFA) are allowed. Args: sock: Pointer to the socket connection. pkt: MFA response. Returns: ok_packet: If last server's response is an OK packet. None: If last server's response isn't an OK packet and no ERROR was raised. Raises: InterfaceError: If got an invalid N factor. errors.ErrorTypes: If got an ERROR response. """ n_factor = 2 while pkt[4] == MFA_STATUS: if n_factor not in self._passwords: raise InterfaceError( "Failed Multi Factor Authentication (invalid N factor)" ) new_strategy_name, auth_data = MySQLProtocol.parse_auth_next_factor(pkt) self._switch_auth_strategy(new_strategy_name, password_factor=n_factor) logger.debug("MFA %i factor %s", n_factor, self._auth_strategy.name) pkt = self._auth_strategy.auth_switch_response( sock, auth_data, **self._plugin_config ) if pkt[4] == EXCHANGE_FURTHER_STATUS: auth_data = MySQLProtocol.parse_auth_more_data(pkt) pkt = self._auth_strategy.auth_more_response( sock, auth_data, **self._plugin_config ) if pkt[4] == OK_STATUS: logger.debug("MFA completed succesfully") return pkt if pkt[4] == ERR_STATUS: raise get_exception(pkt) n_factor += 1 logger.warning("MFA terminated with a no ok packet") return None def _handle_server_response( self, sock: MySQLSocket, pkt: bytes, ) -> Optional[bytes]: """Handles server's response. Args: sock: Pointer to the socket connection. pkt: Server's response after completing the `HandShakeResponse`. Returns: ok_packet: If last server's response is an OK packet. None: If last server's response isn't an OK packet and no ERROR was raised. Raises: errors.ErrorTypes: If got an ERROR response. NotSupportedError: If got Authentication with old (insecure) passwords. """ if pkt[4] == AUTH_SWITCH_STATUS and len(pkt) == 5: raise NotSupportedError( "Authentication with old (insecure) passwords " "is not supported. For more information, lookup " "Password Hashing in the latest MySQL manual" ) if pkt[4] == AUTH_SWITCH_STATUS: logger.debug("Server's response is an auth switch request") new_strategy_name, auth_data = MySQLProtocol.parse_auth_switch_request(pkt) self._switch_auth_strategy(new_strategy_name) pkt = self._auth_strategy.auth_switch_response( sock, auth_data, **self._plugin_config ) if pkt[4] == EXCHANGE_FURTHER_STATUS: logger.debug("Exchanging further packets") auth_data = MySQLProtocol.parse_auth_more_data(pkt) pkt = self._auth_strategy.auth_more_response( sock, auth_data, **self._plugin_config ) if pkt[4] == OK_STATUS: logger.debug("%s completed succesfully", self._auth_strategy.name) return pkt if pkt[4] == MFA_STATUS: logger.debug("Starting multi-factor authentication") logger.debug("MFA 1 factor %s", self._auth_strategy.name) return self._mfa_n_factor(sock, pkt) if pkt[4] == ERR_STATUS: raise get_exception(pkt) return None def authenticate( self, sock: MySQLSocket, handshake: HandShakeType, username: str = "", password1: str = "", password2: str = "", password3: str = "", database: Optional[str] = None, charset: int = DEFAULT_CHARSET_ID, client_flags: int = 0, max_allowed_packet: int = DEFAULT_MAX_ALLOWED_PACKET, auth_plugin: Optional[str] = None, auth_plugin_class: Optional[str] = None, conn_attrs: Optional[Dict[str, str]] = None, is_change_user_request: bool = False, **plugin_config: Any, ) -> bytes: """Performs the authentication phase. During re-authentication you must set `is_change_user_request` to True. Args: sock: Pointer to the socket connection. handshake: Initial handshake. username: Account's username. password1: Account's password factor 1. password2: Account's password factor 2. password3: Account's password factor 3. database: Initial database name for the connection. charset: Client charset (see [1]), only the lower 8-bits. client_flags: Integer representing client capabilities flags. max_allowed_packet: Maximum packet size. auth_plugin: Authorization plugin name. auth_plugin_class: Authorization plugin class (has higher precedence than the authorization plugin name). conn_attrs: Connection attributes. is_change_user_request: Whether is a `change user request` operation or not. plugin_config: Custom configuration to be passed to the auth plugin when invoked. The parameters defined here will override the ones defined in the auth plugin itself. Returns: ok_packet: OK packet. Raises: InterfaceError: If OK packet is NULL. References: [1]: https://dev.mysql.com/doc/dev/mysql-server/latest/\ page_protocol_basic_character_set.html#a_protocol_character_set """ # update credentials, plugin config and plugin class self._username = username self._passwords = {1: password1, 2: password2, 3: password3} self._plugin_config = copy.deepcopy(plugin_config) self._auth_plugin_class = auth_plugin_class # client's handshake response response_payload, self._auth_strategy = MySQLProtocol.make_auth( handshake=handshake, username=username, password=password1, database=database, charset=charset, client_flags=client_flags, max_allowed_packet=max_allowed_packet, auth_plugin=auth_plugin, auth_plugin_class=auth_plugin_class, conn_attrs=conn_attrs, is_change_user_request=is_change_user_request, ssl_enabled=self.ssl_enabled, plugin_config=self.plugin_config, ) # client sends transaction response send_args = (0, 0) if is_change_user_request else (None, None) sock.send(response_payload, *send_args) # server replies back pkt = bytes(sock.recv()) ok_pkt = self._handle_server_response(sock, pkt) if ok_pkt is None: raise InterfaceError("Got a NULL ok_pkt") from None return ok_pkt