# 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
