<?php

// Copyright (C) 2010-2024 Combodo SAS
//
//   This file is part of iTop.
//
//   iTop 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, either version 3 of the License, or
//   (at your option) any later version.
//
//   iTop 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 iTop. If not, see <http://www.gnu.org/licenses/>

/**
 * Class LoginWebPage
 *
 * @copyright   Copyright (C) 2010-2024 Combodo SAS
 * @license     http://opensource.org/licenses/AGPL-3.0
 */

use Combodo\iTop\Application\Branding;
use Combodo\iTop\Application\WebPage\ErrorPage;
use Combodo\iTop\Application\WebPage\NiceWebPage;
use Combodo\iTop\Service\Events\EventData;
use Combodo\iTop\Service\Events\EventService;
use Combodo\iTop\Service\Session\Session;

/**
 * Web page used for displaying the login form
 */

class LoginWebPage extends NiceWebPage
{
	public const EXIT_PROMPT = 0;
	public const EXIT_HTTP_401 = 1;
	public const EXIT_RETURN = 2;

	public const EXIT_CODE_OK = 0;
	public const EXIT_CODE_MISSINGLOGIN = 1;
	public const EXIT_CODE_MISSINGPASSWORD = 2;
	public const EXIT_CODE_WRONGCREDENTIALS = 3;
	public const EXIT_CODE_MUSTBEADMIN = 4;
	public const EXIT_CODE_PORTALUSERNOTAUTHORIZED = 5;
	public const EXIT_CODE_NOTAUTHORIZED = 6;

	// Login FSM States
	public const LOGIN_STATE_START = 'start';                          // Entry state
	public const LOGIN_STATE_MODE_DETECTION = 'login mode detection';  // Detect which login plugin to use
	public const LOGIN_STATE_READ_CREDENTIALS = 'read credentials';    // Read the credentials
	public const LOGIN_STATE_CHECK_CREDENTIALS = 'check credentials';  // Check if the credentials are valid
	public const LOGIN_STATE_CREDENTIALS_OK = 'credentials ok';        // User provisioning
	public const LOGIN_STATE_USER_OK = 'user ok';                      // Additional check (2FA)
	public const LOGIN_STATE_CONNECTED = 'connected';                  // User connected
	public const LOGIN_STATE_SET_ERROR = 'prepare for error';	        // Internal state to trigger ERROR state
	public const LOGIN_STATE_ERROR = 'error';                          // An error occurred, next state will be NONE

	// Login FSM Returns
	public const LOGIN_FSM_RETURN = 0;           // End the FSM OK (connected)
	public const LOGIN_FSM_ERROR = 1;        // Error signaled
	public const LOGIN_FSM_CONTINUE = 2;     // Continue FSM

	protected static $sHandlerClass = __class__;
	private static $iOnExit;

	public static function RegisterHandler($sClass)
	{
		self::$sHandlerClass = $sClass;
	}

	/**
	 * @return \LoginWebPage
	 */
	public static function NewLoginWebPage()
	{
		return new self::$sHandlerClass();
	}

	protected static $m_sLoginFailedMessage = '';

	public function __construct($sTitle = null)
	{
		if ($sTitle === null) {
			$sTitle = Dict::S('UI:Login:Title');
		}

		parent::__construct($sTitle);
		$this->SetStyleSheet();
		$this->no_cache();
		$this->add_http_headers();
	}

	public function SetStyleSheet()
	{
		$this->LinkStylesheetFromAppRoot('css/login.css');
		$this->LinkStylesheetFromAppRoot('css/font-awesome/css/all.min.css');
	}

	/**
	 * @inheritDoc
	 * @since 3.2.0
	 */
	protected function GetFaviconAbsoluteUrl()
	{
		return Branding::GetLoginFavIconAbsoluteUrl();
	}

	public static function SetLoginFailedMessage($sMessage)
	{
		self::$m_sLoginFailedMessage = $sMessage;
	}

	/**
	 * @param $oUser
	 * @param array $aProfiles
	 *
	 * @return array
	 * @throws \CoreException
	 * @throws \CoreUnexpectedValue
	 */
	public static function SynchronizeProfiles(&$oUser, array $aProfiles, $sOrigin)
	{
		$oProfilesSet = $oUser->Get('profile_list');
		//delete old profiles
		$aExistingProfiles = [];
		while ($oProfile = $oProfilesSet->Fetch()) {
			array_push($aExistingProfiles, $oProfile->Get('profileid'));
			$iArrayKey = array_search($oProfile->Get('profileid'), $aProfiles);
			if (!$iArrayKey) {
				$oProfilesSet->RemoveItem($oProfile->Get('profileid'));
			} else {
				unset($aProfiles[$iArrayKey]);
			}
		}
		//add profiles not already linked with user
		foreach ($aProfiles as $iProfileId) {
			$oProfilesSet->AddItem(MetaModel::NewObject('URP_UserProfile', ['profileid' => $iProfileId, 'reason' => $sOrigin]));
		}
		$oUser->Set('profile_list', $oProfilesSet);
	}

	public function DisplayLoginHeader($bMainAppLogo = false)
	{
		$sVersionShort = Dict::Format('UI:iTopVersion:Short', ITOP_APPLICATION, ITOP_VERSION);
		$sIconUrl = Utils::GetConfig()->Get('app_icon_url');
		$sDisplayIcon = Branding::GetLoginLogoAbsoluteUrl();
		$this->add("<div id=\"login-logo\"><a href=\"".htmlentities(
			$sIconUrl,
			ENT_QUOTES,
			self::PAGES_CHARSET
		)."\"><img title=\"$sVersionShort\" src=\"$sDisplayIcon\"></a></div>\n");
	}

	public function DisplayLoginForm($bFailedLogin = false)
	{
		$oTwigContext = new LoginTwigRenderer();
		$aPostedVars = array_merge(['login_mode', 'loginop'], $oTwigContext->GetPostedVars());

		$sMessage = Dict::S('UI:Login:IdentifyYourself');

		// Error message
		if ($bFailedLogin) {
			if (self::$m_sLoginFailedMessage != '') {
				$sMessage = self::$m_sLoginFailedMessage;
			} else {
				$sMessage = Dict::S('UI:Login:IncorrectLoginPassword');
			}
		}

		// Keep the OTHER parameters posted
		$aPreviousPostedVars = [];
		foreach ($_POST as $sPostedKey => $postedValue) {
			if (!in_array($sPostedKey, $aPostedVars)) {
				if (is_array($postedValue)) {
					foreach ($postedValue as $sKey => $sValue) {
						$sName = "{$sPostedKey}[{$sKey}]";
						$aPreviousPostedVars[$sName] = $sValue;
					}
				} else {
					$aPreviousPostedVars[$sPostedKey] = $postedValue;
				}
			}
		}

		$aVars = [
			'bFailedLogin' => $bFailedLogin,
			'sMessage' => $sMessage,
			'aPreviousPostedVars' => $aPreviousPostedVars,
		];
		$aVars = array_merge($aVars, $oTwigContext->GetDefaultVars());

		$oTwigContext->Render($this, 'login.html.twig', $aVars);
	}

	public function DisplayForgotPwdForm($bFailedToReset = false, $sFailureReason = null)
	{
		$sAuthUser = utils::ReadParam('auth_user', '', true, 'raw_data');

		$oTwigContext = new LoginTwigRenderer();
		$aVars = $oTwigContext->GetDefaultVars();
		$aVars['sAuthUser'] = $sAuthUser;
		$aVars['bFailedToReset'] = $bFailedToReset;
		$aVars['sFailureReason'] = $sFailureReason;

		$oTwigContext->Render($this, 'forgotpwdform.html.twig', $aVars);
	}

	protected function ForgotPwdGo()
	{
		$sAuthUser = utils::ReadParam('auth_user', '', true, 'raw_data');

		try {
			UserRights::Login($sAuthUser); // Set the user's language (if possible!)
			/** @var UserInternal $oUser */
			$oUser = UserRights::GetUserObject();

			if ($oUser != null) {
				if (!MetaModel::IsValidAttCode(get_class($oUser), 'reset_pwd_token')) {
					throw new ForgotPasswordUserInputException('External accounts do not allow password reset');
				}
				if (!$oUser->CanChangePassword()) {
					throw new ForgotPasswordUserInputException('The account does not allow password reset');
				}

				$sTo = $oUser->GetResetPasswordEmail(); // throws Exceptions if not allowed
				if ($sTo == '') {
					throw new ForgotPasswordUserInputException('Missing email address for this account');
				}

				// This token allows the user to change the password without knowing the previous one
				$sToken = bin2hex(random_bytes(32));
				$oUser->Set('reset_pwd_token', $sToken);
				CMDBObject::SetTrackInfo('Reset password');
				$oUser->AllowWrite(true);
				$oUser->DBUpdate();

				$oEmail = new Email();
				$oEmail->SetRecipientTO($sTo);
				$sFrom = MetaModel::GetConfig()->Get('forgot_password_from');
				$sFrom = utils::IsNullOrEmptyString($sFrom) ? MetaModel::GetConfig()->Get('email_default_sender_address') : $sFrom;
				$oEmail->SetRecipientFrom($sFrom);
				$oEmail->SetSubject(Dict::S('UI:ResetPwd-EmailSubject', $oUser->Get('login')));
				$sResetUrl = utils::GetAbsoluteUrlAppRoot().'pages/UI.php?loginop=reset_pwd&auth_user='.urlencode($oUser->Get('login')).'&token='.urlencode($sToken);
				$oEmail->SetBody(Dict::Format('UI:ResetPwd-EmailBody', $sResetUrl, $oUser->Get('login')));
				$iRes = $oEmail->Send($aIssues, true /* force synchronous exec */);
				switch ($iRes) {
					//case EMAIL_SEND_PENDING:
					case EMAIL_SEND_OK:
						break;

					case EMAIL_SEND_ERROR:
					default:
						throw new ForgotPasswordApplicationException('Failed to send the password reset email for '.$oUser->Get('friendlyname').': '.implode(', ', $aIssues));
				}
			}

		} catch (ForgotPasswordApplicationException $e) {
			IssueLog::Error('Failed to process the forgot password request for user "'.$sAuthUser.'" [reason='.get_class($e).']: '.$e->getMessage());
		} catch (ForgotPasswordUserInputException $e) {
			IssueLog::Info('Failed to process the forgot password request for user "'.$sAuthUser.'" [reason='.get_class($e).']: '.$e->getMessage());
		} catch (\Throwable $e) {
			IssueLog::Error('Unexpected error while processing the forgot password request for user "'.$sAuthUser.'": '.$e->getMessage());
		}

		$oTwigContext = new LoginTwigRenderer();
		$aVars = $oTwigContext->GetDefaultVars();
		$oTwigContext->Render($this, 'forgotpwdsent.html.twig', $aVars);
	}

	public function DisplayResetPwdForm($sErrorMessage = null)
	{
		$sAuthUser = utils::ReadParam('auth_user', '', false, 'raw_data');
		$sToken = utils::ReadParam('token', '', false, 'raw_data');

		UserRights::Login($sAuthUser); // Set the user's language
		$oUser = UserRights::GetUserObject();

		$oTwigContext = new LoginTwigRenderer();
		$aVars = $oTwigContext->GetDefaultVars();

		$aVars['sAuthUser'] = $sAuthUser;
		$aVars['sToken'] = $sToken;
		$aVars['sErrorMessage'] = $sErrorMessage;

		if (($oUser == null)) {
			$aVars['bNoUser'] = true;
		} else {
			$aVars['bNoUser'] = false;
			$aVars['sUserName'] = $oUser->GetFriendlyName();
			$oEncryptedToken = $oUser->Get('reset_pwd_token');

			if (!$oEncryptedToken->CheckPassword($sToken)) {
				$aVars['bBadToken'] = true;
			} else {
				$aVars['bBadToken'] = false;
			}
		}

		$oTwigContext->Render($this, 'resetpwdform.html.twig', $aVars);
	}

	public function DoResetPassword()
	{
		$sAuthUser = utils::ReadParam('auth_user', '', false, 'raw_data');
		$sToken = utils::ReadParam('token', '', false, 'raw_data');
		$sNewPwd = utils::ReadPostedParam('new_pwd', '', 'raw_data');

		UserRights::Login($sAuthUser); // Set the user's language
		/** @var \UserLocal $oUser */
		$oUser = UserRights::GetUserObject();

		$oTwigContext = new LoginTwigRenderer();
		$aVars = $oTwigContext->GetDefaultVars();

		$aVars['sAuthUser'] = $sAuthUser;
		$aVars['sToken'] = $sToken;
		if (($oUser == null)) {
			$aVars['bNoUser'] = true;
		} else {
			$aVars['bNoUser'] = false;
			$oEncryptedToken = $oUser->Get('reset_pwd_token');

			if (!$oEncryptedToken->CheckPassword($sToken)) {
				$aVars['bBadToken'] = true;
			} else {
				$aVars['bBadToken'] = false;
				// Trash the token and change the password
				$oUser->Set('reset_pwd_token', new ormPassword());
				$oUser->AllowWrite(true);
				$oUser->SetPassword($sNewPwd); // Does record the change into the DB
				$aVars['sUrl'] = utils::GetAbsoluteUrlAppRoot();
			}
		}

		$oTwigContext->Render($this, 'resetpwddone.html.twig', $aVars);
	}

	public function DisplayChangePwdForm($bFailedLogin = false, $sIssue = null)
	{
		$oTwigContext = new LoginTwigRenderer();
		$aVars = $oTwigContext->GetDefaultVars();
		$aVars['bFailedLogin'] = $bFailedLogin;
		$aVars['sIssue'] = $sIssue;
		$oTwigContext->Render($this, 'changepwdform.html.twig', $aVars);
	}

	public function DisplayLogoutPage($bPortal, $sTitle = null)
	{
		$sUrl = utils::GetAbsoluteUrlAppRoot();
		$sUrl .= $bPortal ? 'portal/' : 'pages/UI.php';
		$sTitle = empty($sTitle) ? Dict::S('UI:LogOff:ThankYou') : $sTitle;
		$sMessage = Dict::S('UI:LogOff:ClickHereToLoginAgain');

		$oTwigContext = new LoginTwigRenderer();
		$aVars = $oTwigContext->GetDefaultVars();
		$aVars['sUrl'] = $sUrl;
		$aVars['sTitle'] = $sTitle;
		$aVars['sMessage'] = $sMessage;

		$oTwigContext->Render($this, 'logout.html.twig', $aVars);
		$this->output();
	}

	public static function ResetSession()
	{
		// Unset all of the session variables.
		Session::UnsetAll();
		UserRights::_ResetSessionCache();
		// If it's desired to kill the session, also delete the session cookie.
		// Note: This will destroy the session, and not just the session data!
	}

	public static function SecureConnectionRequired()
	{
		return MetaModel::GetConfig()->GetSecureConnectionRequired();
	}

	/**
	 * Guess if a string looks like an UTF-8 string based on some ranges of multi-bytes encoding
	 * @param string $sString
	 * @return bool True if the string contains some typical UTF-8 multi-byte sequences
	 */
	public static function LooksLikeUTF8($sString)
	{
		return preg_match('%(?:
        			[\xC2-\xDF][\x80-\xBF]             # non-overlong 2-byte
        			|\xE0[\xA0-\xBF][\x80-\xBF]        # excluding overlongs
        			|[\xE1-\xEC\xEE\xEF][\x80-\xBF]{2} # straight 3-byte
        			|\xED[\x80-\x9F][\x80-\xBF]        # excluding surrogates
        			|\xF0[\x90-\xBF][\x80-\xBF]{2}     # planes 1-3
        			|[\xF1-\xF3][\x80-\xBF]{3}         # planes 4-15
        			|\xF4[\x80-\x8F][\x80-\xBF]{2}     # plane 16
        	)+%xs', $sString);
	}
	/**
	 * Attempt a login
	 *
	 * @param int iOnExit What action to take if the user is not logged on (one of the class constants EXIT_...)
	 *
	 * @return int|void One of the class constants EXIT_CODE_...
	 * @throws \Exception
	 */
	protected static function Login($iOnExit)
	{
		self::$iOnExit = $iOnExit;
		if (self::SecureConnectionRequired() && !utils::IsConnectionSecure()) {
			// Non secured URL... request for a secure connection
			throw new Exception('Secure connection required!');
		}
		$bLoginDebug = MetaModel::GetConfig()->Get('login_debug');

		if (Session::Get('login_state') == self::LOGIN_STATE_ERROR) {
			Session::Set('login_state', self::LOGIN_STATE_START);
		}
		$sLoginState = Session::Get('login_state');

		$sSessionLog = '';
		if ($bLoginDebug) {
			IssueLog::Info("---------------------------------");
			IssueLog::Info($_SERVER['REQUEST_URI']);
			IssueLog::Info("--> Entering Login FSM with state: [$sLoginState]");
			$sSessionLog = session_id().' '.utils::GetSessionLog();
			IssueLog::Info("SESSION: $sSessionLog");
		}

		$iErrorCode = self::EXIT_CODE_OK;

		// Finite state machine loop
		while (true) {
			try {
				$aLoginPlugins = self::GetLoginPluginList();
				if (empty($aLoginPlugins)) {
					throw new Exception("Missing login classes");
				}

				/** @var iLoginFSMExtension $oLoginFSMExtensionInstance */
				foreach ($aLoginPlugins as $oLoginFSMExtensionInstance) {
					if ($bLoginDebug) {
						$sCurrSessionLog = session_id().' '.utils::GetSessionLog();
						if ($sCurrSessionLog != $sSessionLog) {
							$sSessionLog = $sCurrSessionLog;
							IssueLog::Info("SESSION: $sSessionLog");
						}
						IssueLog::Info("Login: state: [$sLoginState] call: ".get_class($oLoginFSMExtensionInstance));
					}
					$iResponse = $oLoginFSMExtensionInstance->LoginAction($sLoginState, $iErrorCode);
					if ($iResponse == self::LOGIN_FSM_RETURN) {
						EventService::FireEvent(new EventData(EVENT_LOGIN, null, ['code' => $iErrorCode, 'state' => $sLoginState]));
						Session::WriteClose();
						return $iErrorCode; // Asked to exit FSM, generally login OK
					}
					if ($iResponse == self::LOGIN_FSM_ERROR) {
						EventService::FireEvent(new EventData(EVENT_LOGIN, null, ['code' => $iErrorCode, 'state' => $sLoginState]));
						$sLoginState = self::LOGIN_STATE_SET_ERROR; // Next state will be error
						// An error was detected, skip the other plugins turn
						break;
					}
					// The plugin has nothing to do for this state, continue to the next plugin
				}

				// Every plugin has nothing else to do in this state, go forward
				$sLoginState = self::AdvanceLoginFSMState($sLoginState);
				Session::Set('login_state', $sLoginState);
			} catch (Exception $e) {
				EventService::FireEvent(new EventData(EVENT_LOGIN, null, ['state' => $_SESSION['login_state']]));
				IssueLog::Error($e->getTraceAsString());
				static::ResetSession();
				die($e->getMessage());
			}
		}
	}

	/**
	 * Get plugins list ordered by config 'allowed_login_types'
	 * Use the login mode to filter plugins
	 *
	 * @param string $sInterface 'iLoginFSMExtension' or 'iLogoutExtension'
	 * @param bool $bFilterWithMode if false do not filter the plugin list with login mode
	 *
	 * @return array of plugins
	 */
	public static function GetLoginPluginList($sInterface = 'iLoginFSMExtension', $bFilterWithMode = true)
	{
		$aAllPlugins = [];

		if ($bFilterWithMode) {
			$sCurrentLoginMode = Session::Get('login_mode', '');
		} else {
			$sCurrentLoginMode = '';
		}

		/** @var iLoginExtension $oLoginExtensionInstance */
		foreach (MetaModel::EnumPlugins($sInterface) as $oLoginExtensionInstance) {
			$aLoginModes = $oLoginExtensionInstance->ListSupportedLoginModes();
			$aLoginModes = (is_array($aLoginModes) ? $aLoginModes : []);
			foreach ($aLoginModes as $sLoginMode) {
				// Keep only the plugins for the current login mode + before + after
				if (empty($sCurrentLoginMode) || ($sLoginMode == $sCurrentLoginMode) || ($sLoginMode == 'before') || ($sLoginMode == 'after')) {
					if (!isset($aAllPlugins[$sLoginMode])) {
						$aAllPlugins[$sLoginMode] = [];
					}
					$aAllPlugins[$sLoginMode][] = $oLoginExtensionInstance;
					break; // Stop here to avoid registering a plugin twice
				}
			}
		}

		// Order and filter by the config list of allowed types (allowed_login_types)
		$aAllowedLoginModes = array_merge(['before'], MetaModel::GetConfig()->GetAllowedLoginTypes(), ['after']);
		$aPlugins = [];
		foreach ($aAllowedLoginModes as $sAllowedMode) {
			if (isset($aAllPlugins[$sAllowedMode])) {
				$aPlugins = array_merge($aPlugins, $aAllPlugins[$sAllowedMode]);
			}
		}
		return $aPlugins;
	}

	/**
	 * Advance Login Finite State Machine to the next step
	 *
	 * @param string $sLoginState Current step
	 *
	 * @return string next step
	 */
	private static function AdvanceLoginFSMState($sLoginState)
	{
		switch ($sLoginState) {
			case self::LOGIN_STATE_START:
				return self::LOGIN_STATE_MODE_DETECTION;

			case self::LOGIN_STATE_MODE_DETECTION:
				return self::LOGIN_STATE_READ_CREDENTIALS;

			case self::LOGIN_STATE_READ_CREDENTIALS:
				return self::LOGIN_STATE_CHECK_CREDENTIALS;

			case self::LOGIN_STATE_CHECK_CREDENTIALS:
				return self::LOGIN_STATE_CREDENTIALS_OK;

			case self::LOGIN_STATE_CREDENTIALS_OK:
				return self::LOGIN_STATE_USER_OK;

			case self::LOGIN_STATE_USER_OK:
				return self::LOGIN_STATE_CONNECTED;

			case self::LOGIN_STATE_CONNECTED:
			case self::LOGIN_STATE_ERROR:
				return self::LOGIN_STATE_START;

			case self::LOGIN_STATE_SET_ERROR:
				return self::LOGIN_STATE_ERROR;
		}

		// Default reset to NONE
		return self::LOGIN_STATE_START;
	}

	/**
	 * Login API: Check that credentials correspond to a valid user
	 * Used only during login process when the password is known
	 *
	 * @api
	 *
	 * @param string $sAuthUser
	 * @param string $sAuthPassword
	 * @param string $sAuthentication ('internal' or 'external')
	 *
	 * @return bool (true if User OK)
	 *
	 */
	public static function CheckUser($sAuthUser, $sAuthPassword = '', $sAuthentication = 'external')
	{
		$oUser = self::FindUser($sAuthUser, true, ucfirst(strtolower($sAuthentication)));
		if (is_null($oUser)) {
			return false;
		}

		return $oUser->CheckCredentials($sAuthPassword);
	}

	/**
	 * Login API: Store User info in the session when connection is OK
	 *
	 * @api
	 *
	 * @param $sAuthUser
	 * @param $sAuthentication
	 * @param $sLoginMode
	 *
	 * @throws ArchivedObjectException
	 * @throws CoreCannotSaveObjectException
	 * @throws CoreException
	 * @throws CoreUnexpectedValue
	 * @throws CoreWarning
	 * @throws MySQLException
	 * @throws OQLException
	 * @throws \Exception
	 */
	public static function OnLoginSuccess($sAuthUser, $sAuthentication, $sLoginMode)
	{
		// User is Ok, let's save it in the session and proceed with normal login
		$bLoginSuccess = UserRights::Login($sAuthUser, $sAuthentication); // Login & set the user's language
		if (!$bLoginSuccess) {
			throw new Exception("Bad user");
		}
		if (MetaModel::GetConfig()->Get('log_usage')) {
			$oLog = new EventLoginUsage();
			$oLog->Set('userinfo', UserRights::GetUser());
			$oLog->Set('user_id', UserRights::GetUserObject()->GetKey());
			$oLog->Set('message', 'Successful login');
			$oLog->DBInsertNoReload();
		}

		Session::Set('auth_user', $sAuthUser);
		Session::Set('login_mode', $sLoginMode);
		UserRights::_InitSessionCache();
	}

	/**
	 * Login API: Check that an already logger User is still valid
	 *
	 * @api
	 *
	 * @param int $iErrorCode
	 *
	 * @return int LOGIN_FSM_RETURN_OK or LOGIN_FSM_RETURN_ERROR
	 */
	public static function CheckLoggedUser(&$iErrorCode)
	{
		if (Session::IsSet('auth_user')) {
			// Already authenticated
			$bRet = UserRights::Login(Session::Get('auth_user')); // Login & set the user's language
			if ($bRet) {
				$iErrorCode = self::EXIT_CODE_OK;
				return self::LOGIN_FSM_RETURN;
			}
		}
		// The user account is no longer valid/enabled
		$iErrorCode = self::EXIT_CODE_WRONGCREDENTIALS;

		return self::LOGIN_FSM_ERROR;
	}

	/**
	 * Exit with an HTTP 401 error
	 */
	public static function HTTP401Error()
	{
		header('WWW-Authenticate: Basic realm="'.Dict::Format('UI:iTopVersion:Short', ITOP_APPLICATION, ITOP_VERSION));
		header('HTTP/1.0 401 Unauthorized');
		header('Content-type: text/html; charset='.self::PAGES_CHARSET);
		// Note: displayed when the user will click on Cancel
		echo '<p><strong>'.Dict::S('UI:Login:Error:AccessRestricted').'</strong></p>';
		exit;
	}

	public static function SetLoginModeAndReload($sNewLoginMode)
	{
		if (Session::Get('login_mode') == $sNewLoginMode) {
			return;
		}
		Session::Set('login_mode', $sNewLoginMode);
		self::HTTPReload();
	}

	public static function HTTPReload()
	{
		$sOriginURL = utils::GetCurrentAbsoluteUrl();
		if (!utils::StartsWith($sOriginURL, utils::GetAbsoluteUrlAppRoot())) {
			// If the found URL does not start with the configured AppRoot URL
			$sOriginURL = utils::GetAbsoluteUrlAppRoot().'pages/UI.php';
		}
		self::HTTPRedirect($sOriginURL);
	}

	public static function HTTPRedirect($sURL)
	{
		header('HTTP/1.1 307 Temporary Redirect');
		header('Location: '.$sURL);
		exit;
	}

	/**
	 * Provisioning API: Find a User
	 *
	 * @api
	 *
	 * @param string $sAuthUser
	 * @param bool $bMustBeValid
	 * @param string $sType
	 *
	 * @return \User|null
	 */
	public static function FindUser($sAuthUser, $bMustBeValid = true, $sType = 'External')
	{
		try {
			$aArgs = ['login' => $sAuthUser];
			$sUserClass = "User$sType";
			$oSearch = DBObjectSearch::FromOQL("SELECT $sUserClass WHERE login = :login");
			if ($bMustBeValid) {
				$oSearch->AddCondition('status', 'enabled');
			}
			$oSet = new DBObjectSet($oSearch, [], $aArgs);
			if ($oSet->CountExceeds(0)) {
				/** @var User $oUser */
				$oUser = $oSet->Fetch();

				return $oUser;
			}
		} catch (Exception $e) {
			IssueLog::Error($e->getMessage());
		}
		return null;
	}

	/**
	 * Provisioning API: Find a Person by email
	 *
	 * @api
	 *
	 * @param string $sEmail
	 *
	 * @return \Person|null
	 */
	public static function FindPerson($sEmail)
	{
		/** @var \Person $oPerson */
		$oPerson = null;
		try {
			$oSearch = new DBObjectSearch('Person');
			$oSearch->AddCondition('email', $sEmail);
			$oSet = new DBObjectSet($oSearch);
			if ($oSet->CountExceeds(1)) {
				throw new Exception(Dict::S('UI:Login:Error:MultipleContactsHaveSameEmail'));
			}
			$oPerson = $oSet->Fetch();
		} catch (Exception $e) {
			IssueLog::Error($e->getMessage());
		}
		return $oPerson;
	}

	/**
	 * Provisioning API: Create a person
	 *
	 * @api
	 *
	 * @param string $sFirstName
	 * @param string $sLastName
	 * @param string $sEmail
	 * @param string $sOrganization
	 * @param array $aAdditionalParams
	 *
	 * @return \Person
	 */
	public static function ProvisionPerson($sFirstName, $sLastName, $sEmail, $sOrganization, $aAdditionalParams = [])
	{
		/** @var Person $oPerson */
		$oPerson = null;
		try {
			CMDBObject::SetTrackOrigin('custom-extension');
			$sInfo = 'External User provisioning';
			if (Session::IsSet('login_mode')) {
				$sInfo .= " (".Session::Get('login_mode').")";
			}
			CMDBObject::SetTrackInfo($sInfo);

			$oPerson = MetaModel::NewObject('Person');
			$oPerson->Set('first_name', $sFirstName);
			$oPerson->Set('name', $sLastName);
			$oPerson->Set('email', $sEmail);
			$oOrg = MetaModel::GetObjectByName('Organization', $sOrganization, false);
			if (is_null($oOrg)) {
				throw new Exception(Dict::S('UI:Login:Error:WrongOrganizationName'));
			}
			$oPerson->Set('org_id', $oOrg->GetKey());
			foreach ($aAdditionalParams as $sAttCode => $sValue) {
				$oPerson->Set($sAttCode, $sValue);
			}
			$oPerson->DBInsert();
		} catch (Exception $e) {
			IssueLog::Error($e->getMessage());
		}
		return $oPerson;
	}

	/**
	 * Provisioning API: Create or update a User
	 *
	 * @api
	 *
	 * @param string $sAuthUser
	 * @param Person $oPerson
	 * @param array $aRequestedProfiles profiles to add to the new user
	 *
	 * @return \UserExternal|null
	 */
	public static function ProvisionUser($sAuthUser, $oPerson, $aRequestedProfiles)
	{
		if (!MetaModel::IsValidClass('URP_Profiles')) {
			IssueLog::Error("URP_Profiles is not a valid class. Automatic creation of Users is not supported in this context, sorry.");
			return null;
		}

		/** @var UserExternal $oUser */
		$oUser = null;
		try {
			CMDBObject::SetTrackOrigin('custom-extension');
			$sInfo = 'External User provisioning';
			if (Session::IsSet('login_mode')) {
				$sInfo .= " (".Session::Get('login_mode').")";
			}
			CMDBObject::SetTrackInfo($sInfo);

			$oUser = MetaModel::GetObjectByName('UserExternal', $sAuthUser, false);
			if (is_null($oUser)) {
				$oUser = MetaModel::NewObject('UserExternal');
				$oUser->Set('login', $sAuthUser);
				$oUser->Set('contactid', $oPerson->GetKey());
				$oUser->Set('language', MetaModel::GetConfig()->GetDefaultLanguage());
			}

			// read all the existing profiles
			$oProfilesSearch = new DBObjectSearch('URP_Profiles');
			$oProfilesSet = new DBObjectSet($oProfilesSearch);
			$aAllProfiles = [];
			while ($oProfile = $oProfilesSet->Fetch()) {
				$aAllProfiles[mb_strtolower($oProfile->GetName())] = $oProfile->GetKey();
			}

			$aProfiles = [];
			foreach ($aRequestedProfiles as $sRequestedProfile) {
				$sRequestedProfile = mb_strtolower($sRequestedProfile);
				if (isset($aAllProfiles[$sRequestedProfile])) {
					$aProfiles[] = $aAllProfiles[$sRequestedProfile];
				}
			}

			if (empty($aProfiles)) {
				throw new Exception(Dict::S('UI:Login:Error:NoValidProfiles'));
			}

			// Now synchronize the profiles
			$sOrigin = 'External User provisioning';
			if (Session::IsSet('login_mode')) {
				$sOrigin .= " (".Session::Get('login_mode').")";
			}
			$aExistingProfiles = self::SynchronizeProfiles($oUser, $aProfiles, $sOrigin);
			if ($oUser->IsModified()) {
				$oUser->DBWrite();
			}
		} catch (Exception $e) {
			IssueLog::Error($e->getMessage());
		}

		return $oUser;
	}

	/**
	 * Overridable: depending on the user, head toward a dedicated portal
	 * @param string|null $sRequestedPortalId
	 * @param int $iOnExit How to complete the call: redirect or return a code
	 */
	protected static function ChangeLocation($sRequestedPortalId = null, $iOnExit = self::EXIT_PROMPT)
	{
		$ret = call_user_func([self::$sHandlerClass, 'Dispatch'], $sRequestedPortalId);
		if ($ret === true) {
			return self::EXIT_CODE_OK;
		} elseif ($ret === false) {
			throw new Exception('Nowhere to go: Your combination of user Profiles denies you access to any '.ITOP_APPLICATION_SHORT.' portal. Please contact your administrator');
		} else {
			if ($iOnExit == self::EXIT_RETURN) {
				return self::EXIT_CODE_PORTALUSERNOTAUTHORIZED;
			} else {
				// No rights to be here, redirect to the portal
				header('Location: '.$ret);
				die();
			}
		}
		return self::EXIT_CODE_OK;
	}

	/**
	 * Check if the user is already authentified, if yes, then performs some additional validations:
	 * - if $bMustBeAdmin is true, then the user must be an administrator, otherwise an error is displayed
	 * - if $bIsAllowedToPortalUsers is false and the user has only access to the portal, then the user is redirected
	 * to the portal
	 *
	 * @param bool $bMustBeAdmin Whether or not the user must be an admin to access the current page
	 * @param bool $bIsAllowedToPortalUsers Whether or not the current page is considered as part of the portal
	 * @param int iOnExit What action to take if the user is not logged on (one of the class constants EXIT_...)
	 *
	 * @return int|mixed|string
	 * @throws \Exception
	 */
	public static function DoLogin($bMustBeAdmin = false, $bIsAllowedToPortalUsers = false, $iOnExit = self::EXIT_PROMPT)
	{
		$sRequestedPortalId = $bIsAllowedToPortalUsers ? 'legacy_portal' : 'backoffice';
		return self::DoLoginEx($sRequestedPortalId, $bMustBeAdmin, $iOnExit);
	}

	/**
	 * Check if the user is already authentified, if yes, then performs some additional validations to redirect towards
	 * the desired "portal"
	 *
	 * @param string|null $sRequestedPortalId The requested "portal" interface, null for any
	 * @param bool $bMustBeAdmin Whether or not the user must be an admin to access the current page
	 * @param int iOnExit What action to take if the user is not logged on (one of the class constants EXIT_...)
	 *
	 * @return int|mixed|string
	 * @throws \Exception
	 */
	public static function DoLoginEx($sRequestedPortalId = null, $bMustBeAdmin = false, $iOnExit = self::EXIT_PROMPT)
	{
		$operation = utils::ReadParam('loginop', '');

		$sMessage = self::HandleOperations($operation); // May exit directly

		$iRet = self::Login($iOnExit);
		if ($iRet == self::EXIT_CODE_OK) {
			if ($bMustBeAdmin && !UserRights::IsAdministrator()) {
				if ($iOnExit == self::EXIT_RETURN) {
					return self::EXIT_CODE_MUSTBEADMIN;
				} else {
					require_once(APPROOT.'/setup/setuppage.class.inc.php');
					$oP = new ErrorPage(Dict::S('UI:PageTitle:FatalError'));
					$oP->add("<h1>".Dict::S('UI:Login:Error:AccessAdmin')."</h1>\n");
					$oP->p("<a href=\"".utils::GetAbsoluteUrlAppRoot()."pages/logoff.php\">".Dict::S('UI:LogOffMenu')."</a>");
					$oP->output();
					exit;
				}
			}
			$iRet = call_user_func([self::$sHandlerClass, 'ChangeLocation'], $sRequestedPortalId, $iOnExit);
		}
		if ($iOnExit == self::EXIT_RETURN) {
			return $iRet;
		} else {
			return $sMessage;
		}
	}
	protected static function HandleOperations($operation)
	{
		$sMessage = ''; // most of the operations never return, but some can return a message to be displayed
		if ($operation == 'logoff') {
			self::ResetSession();
			$oPage = self::NewLoginWebPage();
			$oPage->DisplayLoginForm(false /* not a failed attempt */);
			$oPage->output();
			exit;
		} elseif ($operation == 'forgot_pwd') {
			$oPage = self::NewLoginWebPage();
			$oPage->DisplayForgotPwdForm();
			$oPage->output();
			exit;
		} elseif ($operation == 'forgot_pwd_go') {
			$oPage = self::NewLoginWebPage();
			$oPage->ForgotPwdGo();
			$oPage->output();
			exit;
		} elseif ($operation == 'reset_pwd') {
			$oPage = self::NewLoginWebPage();
			$oPage->DisplayResetPwdForm();
			$oPage->output();
			exit;
		} elseif ($operation == 'do_reset_pwd') {

			try {
				$oPage = self::NewLoginWebPage();
				$oPage->DoResetPassword();
			} catch (CoreCannotSaveObjectException $e) {
				$oPage = self::NewLoginWebPage();
				$oPage->DisplayResetPwdForm($e->getIssue());
			}

			$oPage->output();
			exit;
		} elseif ($operation == 'change_pwd') {
			if (Session::IsSet('auth_user')) {
				$sAuthUser = Session::Get('auth_user');
				$sIssue = Session::Get('pwd_issue');
				Session::Unset('pwd_issue');
				$bFailedLogin = ($sIssue != null); // Force the "failed login" flag to display the "issue" message

				UserRights::Login($sAuthUser); // Set the user's language
				$oPage = self::NewLoginWebPage();
				$oPage->DisplayChangePwdForm($bFailedLogin, $sIssue);
				$oPage->output();
				exit;
			}
		} elseif ($operation == 'check_pwd_policy') {
			$sAuthUser = Session::Get('auth_user');
			UserRights::Login($sAuthUser); // Set the user's language

			$aPwdMap = [];

			foreach (['new_pwd', 'retype_new_pwd'] as $postedPwd) {
				$oUser = new UserLocal();
				$oUser->ValidatePassword($_POST[$postedPwd]);

				$aPwdMap[$postedPwd]['isValid'] = $oUser->IsPasswordValid();
				$aPwdMap[$postedPwd]['message'] = $oUser->getPasswordValidityMessage();
			}
			echo json_encode($aPwdMap);
			die();
		}
		if ($operation == 'do_change_pwd') {
			if (Session::IsSet('auth_user')) {
				$sAuthUser = Session::Get('auth_user');
				UserRights::Login($sAuthUser); // Set the user's language
				$sOldPwd = utils::ReadPostedParam('old_pwd', '', 'raw_data');
				$sNewPwd = utils::ReadPostedParam('new_pwd', '', 'raw_data');

				try {
					if (UserRights::CanChangePassword() && ((!UserRights::CheckCredentials($sAuthUser, $sOldPwd)) || (!UserRights::ChangePassword($sOldPwd, $sNewPwd)))) {
						$oPage = self::NewLoginWebPage();
						$oPage->DisplayChangePwdForm(true); // old pwd was wrong
						$oPage->output();
						exit;
					}
				} catch (CoreCannotSaveObjectException $e) {
					$oPage = self::NewLoginWebPage();
					$oPage->DisplayChangePwdForm(true, $e->getIssue()); // password policy was not met.
					$oPage->output();
					exit;
				}
				$sMessage = Dict::S('UI:Login:PasswordChanged');
			}
		}
		return $sMessage;
	}

	protected static function Dispatch($sRequestedPortalId)
	{
		if ($sRequestedPortalId === null) {
			return true;
		} // allowed to any portal => return true

		$aPortalsConf = PortalDispatcherData::GetData();
		$aDispatchers = [];
		foreach ($aPortalsConf as $sPortalId => $aConf) {
			$sHandlerClass = $aConf['handler'];
			$aDispatchers[$sPortalId] = new $sHandlerClass($sPortalId);
		}

		if (array_key_exists($sRequestedPortalId, $aDispatchers) && $aDispatchers[$sRequestedPortalId]->IsUserAllowed()) {
			return true;
		}
		foreach ($aDispatchers as $sPortalId => $oDispatcher) {
			if ($oDispatcher->IsUserAllowed()) {
				return $oDispatcher->GetUrl();
			}
		}
		return false; // nothing matched !!
	}

	/**
	 * @return mixed
	 */
	public static function getIOnExit()
	{
		return self::$iOnExit;
	}

} // End of class
