<?php
/*
** Copyright (C) 2001-2024 Zabbix SIA
**
** This program is free software: you can redistribute it and/or modify it under the terms of
** the GNU Affero General Public License as published by the Free Software Foundation, version 3.
**
** 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 Affero General Public License for more details.
**
** You should have received a copy of the GNU Affero General Public License along with this program.
** If not, see <https://www.gnu.org/licenses/>.
**/


class CControllerAuthenticationUpdate extends CController {

	/**
	 * @var CControllerResponseRedirect
	 */
	private $response;

	private const PROVISION_ENABLED_FIELDS = ['group_basedn', 'group_member', 'group_membership',  'group_name',
		'user_username', 'user_lastname', 'uer_ref_attr', 'provision_groups', 'provision_media'
	];

	protected function init() {
		$this->response = new CControllerResponseRedirect((new CUrl('zabbix.php'))
			->setArgument('action', 'authentication.edit')
			->getUrl()
		);
	}

	protected function checkInput() {
		global $ALLOW_HTTP_AUTH;

		$fields = [
			'form_refresh' =>					'int32',
			'authentication_type' =>			'in '.ZBX_AUTH_INTERNAL.','.ZBX_AUTH_LDAP,
			'disabled_usrgrpid' =>				'id',
			'ldap_auth_enabled' =>				'in '.ZBX_AUTH_LDAP_DISABLED.','.ZBX_AUTH_LDAP_ENABLED,
			'ldap_servers' =>					'array',
			'ldap_default_row_index' =>			'int32',
			'ldap_case_sensitive' =>			'in '.ZBX_AUTH_CASE_INSENSITIVE.','.ZBX_AUTH_CASE_SENSITIVE,
			'ldap_removed_userdirectoryids' =>	'array_id',
			'ldap_jit_status' =>				'in '.JIT_PROVISIONING_DISABLED.','.JIT_PROVISIONING_ENABLED,
			'jit_provision_interval' =>			'db config.jit_provision_interval|time_unit_year '.implode(':', [SEC_PER_HOUR, 25 * SEC_PER_YEAR]),
			'saml_auth_enabled' =>				'in '.ZBX_AUTH_SAML_DISABLED.','.ZBX_AUTH_SAML_ENABLED,
			'saml_jit_status' =>				'in '.JIT_PROVISIONING_DISABLED.','.JIT_PROVISIONING_ENABLED,
			'idp_entityid' =>					'db userdirectory_saml.idp_entityid',
			'sso_url' =>						'db userdirectory_saml.sso_url',
			'slo_url' =>						'db userdirectory_saml.slo_url',
			'username_attribute' =>				'db userdirectory_saml.username_attribute',
			'sp_entityid' =>					'db userdirectory_saml.sp_entityid',
			'nameid_format' =>					'db userdirectory_saml.nameid_format',
			'sign_messages' =>					'in 0,1',
			'sign_assertions' =>				'in 0,1',
			'sign_authn_requests' =>			'in 0,1',
			'sign_logout_requests' =>			'in 0,1',
			'sign_logout_responses' =>			'in 0,1',
			'encrypt_nameid' =>					'in 0,1',
			'encrypt_assertions' =>				'in 0,1',
			'saml_case_sensitive' =>			'in '.ZBX_AUTH_CASE_INSENSITIVE.','.ZBX_AUTH_CASE_SENSITIVE,
			'saml_provision_status' =>			'in '.JIT_PROVISIONING_DISABLED.','.JIT_PROVISIONING_ENABLED,
			'saml_group_name' =>				'db userdirectory_saml.group_name',
			'saml_user_username' =>				'db userdirectory_saml.user_username',
			'saml_user_lastname' =>				'db userdirectory_saml.user_lastname',
			'saml_provision_groups' =>			'array',
			'saml_provision_media' =>			'array',
			'scim_status' =>					'in '.ZBX_AUTH_SCIM_PROVISIONING_DISABLED.','.ZBX_AUTH_SCIM_PROVISIONING_ENABLED,
			'passwd_min_length' =>				'int32',
			'passwd_check_rules' =>				'array',
			'mfa_status' =>						'in '.MFA_DISABLED.','.MFA_ENABLED,
			'mfa_methods' =>					'array',
			'mfa_default_row_index' =>			'int32',
			'mfa_removed_mfaids' =>				'array_id'
		];

		if ($ALLOW_HTTP_AUTH) {
			$fields += [
				'http_auth_enabled' =>		'in '.ZBX_AUTH_HTTP_DISABLED.','.ZBX_AUTH_HTTP_ENABLED,
				'http_login_form' =>		'in '.ZBX_AUTH_FORM_ZABBIX.','.ZBX_AUTH_FORM_HTTP,
				'http_strip_domains' =>		'db config.http_strip_domains',
				'http_case_sensitive' =>	'in '.ZBX_AUTH_CASE_INSENSITIVE.','.ZBX_AUTH_CASE_SENSITIVE
			];
		}

		$ret = $this->validateInput($fields);

		if ($ret) {
			$ret = $this->validateLdap() && $this->validateSamlAuth() && $this->validateMfa();
		}

		if (!$ret) {
			if (CMessageHelper::getTitle() === null) {
				CMessageHelper::setErrorTitle(_('Cannot update authentication'));
			}
			$this->response->setFormData($this->getInputAll());
			$this->setResponse($this->response);
		}

		return $ret;
	}

	/**
	 * Validate LDAP settings.
	 *
	 * @return bool
	 */
	private function validateLdap(): bool {
		if ($this->getInput('ldap_auth_enabled', DB::getDefault('config', 'ldap_auth_enabled')) ==
				ZBX_AUTH_LDAP_ENABLED) {
			$ldap_status = (new CFrontendSetup())->checkPhpLdapModule();

			if ($ldap_status['result'] != CFrontendSetup::CHECK_OK) {
				error($ldap_status['error']);

				return false;
			}

			$ldap_servers = $this->getInput('ldap_servers', []);

			if (!$ldap_servers) {
				error(_('At least one LDAP server must exist.'));

				return false;
			}

			if (!$this->hasInput('ldap_default_row_index')
					|| !array_key_exists($this->getInput('ldap_default_row_index'), $ldap_servers)) {
				error(_('Default LDAP server must be specified.'));

				return false;
			}

			foreach ($ldap_servers as $ldap_server) {
				if (!array_key_exists('provision_status', $ldap_server)
						|| $ldap_server['provision_status'] != JIT_PROVISIONING_ENABLED) {
					continue;
				}

				if (!array_key_exists('provision_groups', $ldap_server)
						|| !$this->validateProvisionGroups($ldap_server['provision_groups'])) {
					error(_('Invalid LDAP JIT provisioning user group mapping configuration.'));

					return false;
				}

				if (array_key_exists('provision_media', $ldap_server)
						&& !$this->validateProvisionMedia($ldap_server['provision_media'])) {
					error(_('Invalid LDAP JIT provisioning media type mapping configuration.'));

					return false;
				}
			}
		}
		elseif ($this->getInput('authentication_type', DB::getDefault('config', 'authentication_type')) ==
				ZBX_AUTH_LDAP) {
			error(_s('Incorrect value for field "%1$s": %2$s.', 'authentication_type', _('LDAP is not configured')));

			return false;
		}

		return true;
	}

	/**
	 * Validate SAML authentication settings.
	 *
	 * @return bool
	 */
	private function validateSamlAuth() {
		if ($this->getInput('saml_auth_enabled', ZBX_AUTH_SAML_ENABLED) == ZBX_AUTH_SAML_DISABLED) {
			return true;
		}

		$openssl_status = (new CFrontendSetup())->checkPhpOpenSsl();
		if ($openssl_status['result'] != CFrontendSetup::CHECK_OK) {
			error($openssl_status['error']);

			return false;
		}

		$this->getInputs($saml_fields, [
			'idp_entityid',
			'sso_url',
			'username_attribute',
			'sp_entityid'
		]);

		if ($this->getInput('saml_provision_status', JIT_PROVISIONING_DISABLED) == JIT_PROVISIONING_ENABLED) {
			$saml_fields['saml_group_name'] = $this->getInput('saml_group_name', '');

			if (!$this->validateProvisionGroups($this->getInput('saml_provision_groups', []))) {
				error(_('Invalid SAML JIT provisioning user group mapping configuration.'));

				return false;
			}

			if (!$this->validateProvisionMedia($this->getInput('saml_provision_media', []))) {
				error(_('Invalid SAML JIT provisioning media type mapping configuration.'));

				return false;
			}
		}

		foreach ($saml_fields as $field_name => $field_value) {
			if ($field_value === '') {
				error(_s('Incorrect value for field "%1$s": %2$s.', $field_name, _('cannot be empty')));

				return false;
			}
		}

		return true;
	}

	/**
	 * Validate MFA settings.
	 *
	 * @return bool
	 */
	private function validateMfa(): bool {
		$default_mfa = $this->hasInput('mfa_default_row_index') ? $this->getInput('mfa_default_row_index') : null;
		$error = $this->getInput('mfa_status', MFA_DISABLED) == MFA_ENABLED
			&& !array_key_exists($default_mfa, $this->getInput('mfa_methods', []));

		if ($error) {
			error(_('Default MFA method must be specified.'));
		}

		return !$error;
	}

	/**
	 * Validate is user allowed to change configuration.
	 *
	 * @return bool
	 */
	protected function checkPermissions() {
		return $this->checkAccess(CRoleHelper::UI_ADMINISTRATION_AUTHENTICATION);
	}

	/**
	 * In case of error, convert array back to integer (string) so edit form does not fail.
	 *
	 * @return array
	 */
	public function getInputAll() {
		$input = parent::getInputAll();
		$rules = $input['passwd_check_rules'];
		$input['passwd_check_rules'] = 0x00;

		foreach ($rules as $rule) {
			$input['passwd_check_rules'] |= $rule;
		}

		// CNewValidator thinks int32 must be a string type integer.
		$input['passwd_check_rules'] = (string) $input['passwd_check_rules'];

		return $input;
	}

	protected function doAction() {
		$result = false;

		try {
			DBstart();

			$result = $this->processSamlConfiguration();

			$ldap_userdirectoryid = 0;
			if ($result) {
				$ldap_servers = $this->getInput('ldap_servers', []);

				if ($ldap_servers) {
					$ldap_userdirectoryids = $this->processLdapServers($ldap_servers);
					$ldap_default_row_index = $this->getInput('ldap_default_row_index');

					if (!$ldap_userdirectoryids) {
						$result = false;
					}
					else {
						$ldap_userdirectoryid = $ldap_userdirectoryids[$ldap_default_row_index];
					}
				}
			}

			$mfaid = 0;
			if ($result) {
				$mfa_methods = $this->getInput('mfa_methods', []);

				if ($mfa_methods) {
					$mfaids = $this->processMfaMethods($mfa_methods);

					if (!$mfaids) {
						$result = false;
					}
					else {
						$mfaid = $mfaids[$this->getInput('mfa_default_row_index', 0)];
					}
				}
			}

			if ($result) {
				$result = $this->processGeneralAuthenticationSettings($ldap_userdirectoryid, $mfaid);
			}

			if ($result && $this->hasInput('ldap_removed_userdirectoryids')) {
				$result = (bool) API::UserDirectory()->delete($this->getInput('ldap_removed_userdirectoryids'));
			}

			if ($result && $this->hasInput('mfa_removed_mfaids')) {
				$result = (bool) API::Mfa()->delete($this->getInput('mfa_removed_mfaids'));
			}

			if (!$result) {
				throw new Exception();
			}

			$result = DBend(true);
		}
		catch (Exception $e) {
			DBend(false);
		}

		if ($result) {
			CMessageHelper::setSuccessTitle(_('Authentication settings updated'));
		}
		else {
			if (CMessageHelper::getTitle() === null) {
				CMessageHelper::setErrorTitle(_('Cannot update authentication'));
			}
			$this->response->setFormData($this->getInputAll());
		}

		$this->setResponse($this->response);
	}

	private function processGeneralAuthenticationSettings(int $ldap_userdirectoryid, int $mfaid): bool {
		global $ALLOW_HTTP_AUTH;

		$auth_params = [
			CAuthenticationHelper::AUTHENTICATION_TYPE,
			CAuthenticationHelper::DISABLED_USER_GROUPID,
			CAuthenticationHelper::LDAP_AUTH_ENABLED,
			CAuthenticationHelper::LDAP_USERDIRECTORYID,
			CAuthenticationHelper::LDAP_CASE_SENSITIVE,
			CAuthenticationHelper::LDAP_JIT_STATUS,
			CAuthenticationHelper::JIT_PROVISION_INTERVAL,
			CAuthenticationHelper::SAML_AUTH_ENABLED,
			CAuthenticationHelper::SAML_JIT_STATUS,
			CAuthenticationHelper::SAML_CASE_SENSITIVE,
			CAuthenticationHelper::PASSWD_MIN_LENGTH,
			CAuthenticationHelper::PASSWD_CHECK_RULES,
			CAuthenticationHelper::MFA_STATUS,
			CAuthenticationHelper::MFAID
		];

		$fields = [
			'authentication_type' => ZBX_AUTH_INTERNAL,
			'disabled_usrgrpid' => 0,
			'ldap_auth_enabled' => ZBX_AUTH_LDAP_DISABLED,
			'ldap_userdirectoryid' => $ldap_userdirectoryid,
			'saml_auth_enabled' => ZBX_AUTH_SAML_DISABLED,
			'passwd_min_length' => DB::getDefault('config', 'passwd_min_length'),
			'passwd_check_rules' => DB::getDefault('config', 'passwd_check_rules'),
			'mfa_status' => MFA_DISABLED,
			'mfaid' => $mfaid
		];

		if ($ALLOW_HTTP_AUTH) {
			$auth_params = array_merge($auth_params, [
				CAuthenticationHelper::HTTP_AUTH_ENABLED,
				CAuthenticationHelper::HTTP_LOGIN_FORM,
				CAuthenticationHelper::HTTP_STRIP_DOMAINS,
				CAuthenticationHelper::HTTP_CASE_SENSITIVE
			]);

			$fields['http_auth_enabled'] = ZBX_AUTH_HTTP_DISABLED;

			if ($this->getInput('http_auth_enabled', ZBX_AUTH_HTTP_DISABLED) == ZBX_AUTH_HTTP_ENABLED) {
				$fields += [
					'http_case_sensitive' => 0,
					'http_login_form' => 0,
					'http_strip_domains' => ''
				];
			}
		}

		if ($this->getInput('ldap_auth_enabled', ZBX_AUTH_LDAP_DISABLED) == ZBX_AUTH_LDAP_ENABLED) {
			$fields += [
				'ldap_jit_status' => JIT_PROVISIONING_DISABLED,
				'ldap_case_sensitive' => ZBX_AUTH_CASE_INSENSITIVE
			];

			if ($this->getInput('ldap_jit_status', JIT_PROVISIONING_DISABLED) == JIT_PROVISIONING_ENABLED) {
				$fields['jit_provision_interval'] = DB::getDefault('config', 'jit_provision_interval');
			}
		}

		if ($this->getInput('saml_auth_enabled', ZBX_AUTH_SAML_DISABLED) == ZBX_AUTH_SAML_ENABLED) {
			$fields += [
				'saml_case_sensitive' => ZBX_AUTH_CASE_INSENSITIVE,
				'saml_jit_status' => JIT_PROVISIONING_DISABLED
			];
		}

		$auth = [];
		foreach ($auth_params as $param) {
			$auth[$param] = CAuthenticationHelper::get($param);
		}

		$data = $fields + $auth;
		$this->getInputs($data, array_keys($fields));

		$rules = $data['passwd_check_rules'];
		$data['passwd_check_rules'] = 0x00;

		foreach ($rules as $rule) {
			$data['passwd_check_rules'] |= $rule;
		}

		$data = array_diff_assoc($data, $auth);
		$result = true;

		if ($data) {
			$result = (bool) API::Authentication()->update($data);

			if ($result && array_key_exists('authentication_type', $data)) {
				$this->invalidateSessions();
			}

			CAuthenticationHelper::reset();
		}

		return $result;
	}

	/**
	 * Updates existing LDAP servers, creates new ones, removes deleted ones.
	 *
	 * @param array $ldap_servers
	 *
	 * @return array
	 */
	private function processLdapServers(array $ldap_servers): array {
		$ins_ldap_servers = [];
		$upd_ldap_servers = [];
		$userdirectoryid_map = [];

		foreach ($ldap_servers as $row_index => $ldap_server) {
			if (!array_key_exists('provision_status', $ldap_server)
					|| $ldap_server['provision_status'] != JIT_PROVISIONING_ENABLED) {
				$ldap_server = array_diff_key($ldap_server, array_flip(self::PROVISION_ENABLED_FIELDS));
			}

			if (array_key_exists('userdirectoryid', $ldap_server)) {
				$userdirectoryid_map[$row_index] = $ldap_server['userdirectoryid'];
				$upd_ldap_servers[] = $ldap_server + ['provision_media' => []];
			}
			else {
				$userdirectoryid_map[$row_index] = null;
				$ins_ldap_servers[] = ['idp_type' => IDP_TYPE_LDAP] + $ldap_server;
			}
		}

		$result = $upd_ldap_servers ? API::UserDirectory()->update($upd_ldap_servers) : [];
		$result = $result !== false && $ins_ldap_servers ? API::UserDirectory()->create($ins_ldap_servers) : $result;

		if ($result) {
			foreach ($userdirectoryid_map as $row_index => $userdirectoryid) {
				if ($userdirectoryid === null) {
					$userdirectoryid_map[$row_index] = array_shift($result['userdirectoryids']);
				}
			}

			return $userdirectoryid_map;
		}
		else {
			return [];
		}
	}

	/**
	 * Updates existing MFA methods, creates new ones, removes deleted ones.
	 *
	 * @param array $mfa_methods
	 *
	 * @return array
	 */
	private function processMfaMethods(array $mfa_methods): array {
		$ins_mfa_methods = [];
		$upd_mfa_methods = [];
		$mfaid_map = [];

		foreach ($mfa_methods as $row_index => $mfa_method) {
			if (array_key_exists('mfaid', $mfa_method)) {
				$mfaid_map[$row_index] = $mfa_method['mfaid'];
				$upd_mfa_methods[] = $mfa_method;
			}
			else {
				$mfaid_map[$row_index] = null;
				$ins_mfa_methods[] = $mfa_method;
			}
		}

		$result = $upd_mfa_methods ? API::Mfa()->update($upd_mfa_methods) : [];
		$result = ($result !== false && $ins_mfa_methods) ? API::Mfa()->create($ins_mfa_methods) : $result;

		if (!$result) {
			return [];
		}

		if ($ins_mfa_methods) {
			foreach ($mfaid_map as $row_index => $mfaid) {
				if ($mfaid === null) {
					$mfaid_map[$row_index] = array_shift($result['mfaids']);
				}
			}
		}

		return $mfaid_map;
	}

	/**
	 * Retrieves SAML configuration fields and creates or updates SAML configuration.
	 *
	 * @return bool
	 */
	private function processSamlConfiguration(): bool {
		if ($this->getInput('saml_auth_enabled', ZBX_AUTH_SAML_DISABLED) != ZBX_AUTH_SAML_ENABLED) {
			return true;
		}

		$saml_data = [
			'idp_entityid' => '',
			'sso_url' => '',
			'slo_url' => '',
			'username_attribute' => '',
			'sp_entityid' => '',
			'nameid_format' => '',
			'sign_messages' => 0,
			'sign_assertions' => 0,
			'sign_authn_requests' => 0,
			'sign_logout_requests' => 0,
			'sign_logout_responses' => 0,
			'encrypt_nameid' => 0,
			'encrypt_assertions' => 0,
			'provision_status' => JIT_PROVISIONING_DISABLED,
			'scim_status' => ZBX_AUTH_SCIM_PROVISIONING_DISABLED
		];
		$this->getInputs($saml_data, array_keys($saml_data));

		if ($this->getInput('saml_provision_status', JIT_PROVISIONING_DISABLED) == JIT_PROVISIONING_ENABLED) {
			$provisioning_fields = [
				'saml_provision_status' => JIT_PROVISIONING_ENABLED,
				'saml_group_name' => '',
				'saml_user_username' => '',
				'saml_user_lastname' => '',
				'saml_provision_groups' => [],
				'saml_provision_media' => []
			];
			$this->getInputs($provisioning_fields, array_keys($provisioning_fields));
			$provisioning_fields = CArrayHelper::renameKeys($provisioning_fields, [
				'saml_group_name' => 'group_name',
				'saml_user_username' => 'user_username',
				'saml_user_lastname' => 'user_lastname',
				'saml_provision_status' => 'provision_status',
				'saml_provision_groups' => 'provision_groups',
				'saml_provision_media' => 'provision_media'
			]);
			$saml_data = array_merge($saml_data, $provisioning_fields);
		}

		$db_saml = API::UserDirectory()->get([
			'output' => ['userdirectoryid'],
			'filter' => ['idp_type' => IDP_TYPE_SAML]
		]);

		if ($db_saml) {
			$result = API::UserDirectory()->update(['userdirectoryid' => $db_saml[0]['userdirectoryid']] + $saml_data);
		}
		else {
			$result = API::UserDirectory()->create($saml_data + ['idp_type' => IDP_TYPE_SAML]);
		}

		return $result !== false;
	}

	/**
	 * Mark all active GROUP_GUI_ACCESS_INTERNAL sessions, except current user sessions, as ZBX_SESSION_PASSIVE.
	 *
	 * @return bool
	 */
	private function invalidateSessions() {
		$result = true;
		$internal_auth_user_groups = API::UserGroup()->get([
			'output' => [],
			'filter' => [
				'gui_access' => GROUP_GUI_ACCESS_INTERNAL
			],
			'preservekeys' => true
		]);

		$internal_auth_users = API::User()->get([
			'output' => [],
			'usrgrpids' => array_keys($internal_auth_user_groups),
			'preservekeys' => true
		]);
		unset($internal_auth_users[CWebUser::$data['userid']]);

		if ($internal_auth_users) {
			$result = DB::update('sessions', [
				'values' => ['status' => ZBX_SESSION_PASSIVE],
				'where' => ['userid' => array_keys($internal_auth_users)]
			]);
		}

		return $result;
	}

	private function validateProvisionGroups(array $provision_group): bool {
		foreach ($provision_group as $group) {
			if (!is_array($group)) {
				return false;
			}

			if (!array_key_exists('user_groups', $group) || !is_array($group['user_groups'])
					|| !array_key_exists('roleid', $group) || !ctype_digit($group['roleid'])
					|| !array_key_exists('name', $group) || !is_string($group['name']) || $group['name'] === '') {
				return false;
			}
		}

		return true;
	}

	private function validateProvisionMedia(array $provision_media): bool {
		if (!$provision_media) {
			return true;
		}

		foreach ($provision_media as $media) {
			if (!is_array($media)
					|| !array_key_exists('name', $media) || !is_string($media['name']) || $media['name'] === ''
					|| !array_key_exists('attribute', $media) || !is_string($media['attribute'])
					|| $media['attribute'] === ''
					|| !array_key_exists('mediatypeid', $media) || !ctype_digit($media['mediatypeid'])) {
				return false;
			}
		}

		return true;
	}
}
