<?php
/**
 * @brief		IPS Connect Login Handler
 *
 * @copyright	(c) 2001 - SVN_YYYY Invision Power Services, Inc.
 *
 * @package		IPS Social Suite
 * @since		18 Mar 2013
 * @version		SVN_VERSION_NUMBER
 */

namespace IPS\Login;

/* To prevent PHP errors (extending class does not exist) revealing path */
if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
	header( ( isset( $_SERVER['SERVER_PROTOCOL'] ) ? $_SERVER['SERVER_PROTOCOL'] : 'HTTP/1.0' ) . ' 403 Forbidden' );
	exit;
}

/**
 * IPS Connect Login Handler
 */
class _Ipsconnect extends LoginAbstract
{
	/**
	 * @brief	Authentication types
	 */
	public $authTypes;

	/**
	 * Initiate
	 *
	 * @return	void
	 */
	public function init()
	{
		$this->authTypes = ( !empty( $this->settings['auth_types'] ) ) ? $this->settings['auth_types'] : \IPS\Login::AUTH_TYPE_USERNAME;
	}

	/**
	 * @brief	We are coming from a login request, so do not attempt to register us on the master if a local account is created
	 */
	protected $skipMasterRegistration	= FALSE;

	/**
	 * Authenticate
	 *
	 * @param	array	$values	Values from form
	 * @return	\IPS\Member
	 * @throws	\IPS\Login\Exception
	 */
	public function authenticate( $values )
	{
		try
		{
			/* Are we being returned from a cross-domain login? */
			if( \IPS\Request::i()->loginProcess == 'ipsconnect' AND \IPS\Request::i()->id AND \IPS\Request::i()->key )
			{
				$member	= \IPS\Member::load( \IPS\Request::i()->id, 'ipsconnect_id' );

				if( $member->member_id AND \IPS\Login::compareHashes( \IPS\Request::i()->key, md5( $this->settings['key'] . $member->ipsconnect_id ) ) )
				{
					return $member;
				}
			}

			/* First, we need to fetch the user's salt to prevent sending password in plaintext */
			$response = \IPS\Http\Url::external( $this->settings['url'] )
				->setQueryString( array( 'do' => 'fetchSalt', 'key' => md5( $this->settings['key'] . $values['auth'] ), 'idType' => $this->authTypes, 'id' => $values['auth'] ) )
				->request()
				->get()
				->decodeJson();

			/* No response or status is not 'SUCCESS' */
			if ( empty( $response ) OR empty( $response['status'] ) OR $response['status'] != 'SUCCESS' OR !$response['pass_salt'] )
			{
				throw new \IPS\Login\Exception( 'generic_error', \IPS\Login\Exception::INTERNAL_ERROR );
			}

			/* Now create a dummy user to get password hash */
			$testUser	= new \IPS\Member;
			$testUser->members_pass_salt	= $response['pass_salt'];
			$password	= $testUser->encryptedPassword( $values['password'] );

			/* Now try to login */
			$response = \IPS\Http\Url::external( $this->settings['url'] )
				->setQueryString( array( 'do' => 'login', 'key' => md5( $this->settings['key'] . $values['auth'] ), 'idType' => $this->authTypes, 'id' => $values['auth'], 'password' => $password ) )
				->request()
				->get()
				->decodeJson();
				
			/* Are we validating? */
			if( $response['connect_status'] == 'VALIDATING' )
			{
				throw new \IPS\Login\Exception( \IPS\Member::loggedIn()->language()->addToStack('login_err_remote_validate', FALSE, array( 'sprintf' => array( $response['connect_revalidate_url'] ) ) ) );
			}

			/* No response or status is not 'SUCCESS' */
			if ( empty( $response ) OR empty( $response['status'] ) OR $response['status'] != 'SUCCESS' )
			{
				throw new \IPS\Login\Exception( 'generic_error', \IPS\Login\Exception::INTERNAL_ERROR );
			}

			/* No connect status means account not found */
			if( empty( $response['connect_status'] ) )
			{
				throw new \IPS\Login\Exception( \IPS\Member::loggedIn()->language()->addToStack('login_err_no_account', FALSE, array( 'sprintf' => array( \IPS\Member::loggedIn()->language()->addToStack( $this->getLoginType( $this->authTypes ) ) ) ) ), \IPS\Login\Exception::NO_ACCOUNT );
			}

			/* Load the local account now, or create it if it does not exist */
			$member	= \IPS\Member::load( $response['connect_id'], 'ipsconnect_id' );
			if( !$member->member_id )
			{
				$member	= \IPS\Member::load( $response['email'], 'email' );
			}
						
			/* Update any changes on existing accounts */
			if ( $member->member_id )
			{
				if( $response['connect_id'] != $member->ipsconnect_id )
				{
					$member->ipsconnect_id	= $response['connect_id'];
				}

				if( $response['email'] != $member->email )
				{
					$member->email	= $response['email'];
				}

				if( $response['name'] != $member->name AND \IPS\CONNECT_NOSYNC_NAMES === FALSE )
				{
					$member->name	= $response['name'];
				}

				$member->save();
			}
			
			/* Otherwise create the account if it does not exist yet */
			else
			{
				$this->skipMasterRegistration	= TRUE;

				$member = $this->createOrUpdateAccount( $member, array(
					'ipsconnect_id'		=> $response['connect_id'],
					'members_pass_salt'	=> $testUser->members_pass_salt,
					'members_pass_hash'	=> $password
				), $response['name'] ?: NULL, $response['email'] ?: NULL );

				$this->skipMasterRegistration	= FALSE;
			}
			
			/* Do we have any custom callbacks we need to call? */
			$this->connectSuccessful( $member );

			/* If we are still here and successful, redirect to master to log us in to each installation */
			$url = \IPS\Http\Url::internal( '' );
			\IPS\Request::i()->ref		= (string) \IPS\Http\Url::external( $this->settings['url'] )->setQueryString( 
				array( 
					'do'		=> 'crossLogin', 
					'key'		=> md5( $this->settings['key'] . $member->ipsconnect_id ), 
					'url'		=> \IPS\Settings::i()->base_url . '/applications/core/interface/ipsconnect/ipsconnect.php', 
					'id'		=> $member->ipsconnect_id, 
					'returnTo'	=> (string) $url->setQueryString( 
						array( 
							'loginProcess'	=> 'ipsconnect', 
							'key'			=> md5( $this->settings['key'] . $member->ipsconnect_id ), 
							'id'			=> $member->ipsconnect_id, 
							'ref'			=> (string) \IPS\Login::getDestination() 
						)
					)
				)
			);

			return $member;
		}
		/* Have to catch \RuntimeException to catch the BAD_JSON throw from Response.php */
		catch ( \RuntimeException $e )
		{
			throw new \IPS\Login\Exception( 'generic_error', \IPS\Login\Exception::INTERNAL_ERROR );
		}

		/* If we are still here, throw a generic bad password exception */
		throw new \IPS\Login\Exception( 'login_err_bad_password', \IPS\Login\Exception::BAD_PASSWORD );
	}

	/**
	 * Generic method called upon a successful login - useful for plugins to do "something" when login is successful
	 *
	 * @param	\IPS\Member	$member		Member that is logged in
	 * @return	void
	 * @note	By default this method does nothing, but is abstracted to allow easy extending
	 */
	public function connectSuccessful( \IPS\Member $member )
	{
		return;
	}

	/**
	 * Fetch the URL to redirect to following registration, if any
	 *
	 * @param	\IPS\Member	$member		The member that just registered
	 * @return	\IPS\Http\Url
	 */
	public function getRegistrationDestination( $member )
	{
		return \IPS\Http\Url::external( $this->settings['url'] )->setQueryString( array( 'do' => 'crossLogin', 'key' => md5( $this->settings['key'] . $member->ipsconnect_id ), 'url' => \IPS\Settings::i()->base_url . '/applications/core/interface/ipsconnect/ipsconnect.php', 'id' => $member->ipsconnect_id, 'returnTo' => (string) \IPS\Http\Url::internal( '', 'front' ) ) );
	}
	
	/**
	 * ACP Settings Form
	 *
	 * @param	string	$url	URL to redirect user to after successful submission
	 * @return	array	List of settings to save - settings will be stored to core_login_handlers.login_settings DB field
	 * @code
	 	return array( 'savekey'	=> new \IPS\Helpers\Form\[Type]( ... ), ... );
	 * @endcode
	 */
	public function acpForm()
	{
		return array(
			'url'			=>  new \IPS\Helpers\Form\Text( 'login_ipsconnect_url', $this->settings['url'], TRUE ),
			'key'			=>  new \IPS\Helpers\Form\Text( 'login_ipsconnect_key', $this->settings['key'], TRUE ),
			'auth_types'	=> new \IPS\Helpers\Form\Select( 'login_auth_types', isset( $this->settings['auth_types'] ) ? $this->settings['auth_types'] : \IPS\Login::AUTH_TYPE_USERNAME, TRUE, array( 'options' => array(
				\IPS\Login::AUTH_TYPE_USERNAME => 'username',
				\IPS\Login::AUTH_TYPE_EMAIL	=> 'email_address',
				\IPS\Login::AUTH_TYPE_USERNAME + \IPS\Login::AUTH_TYPE_EMAIL => 'username_or_email',
			) ) )
		);
	}
	
	/**
	 * Can a member sign in with this login handler?
	 * Used to ensure when a user disassociates a social login that they have some other way of logging in
	 *
	 * @param	\IPS\Member	$member	The member
	 * @return	bool
	 */
	public function canProcess( \IPS\Member $member )
	{
		return (bool) $this->_getConnectId( $member );
	}
	
	/**
	 * Can a member change their email/password with this login handler?
	 *
	 * @param	string		$type	'username' or 'email' or 'password'
	 * @param	\IPS\Member	$member	The member
	 * @return	bool
	 */
	public function canChange( $type, \IPS\Member $member )
	{
		return (bool) $this->_getConnectId( $member );
	}

	/**
	 * Test Settings
	 *
	 * @return	bool
	 * @throws	\InvalidArgumentException
	 */
	public function testSettings()
	{
		if( !$this->settings['url'] or $this->settings['url'] == (string) \IPS\Http\Url::internal( 'applications/core/interface/ipsconnect/ipsconnect.php', 'none' ) )
		{
			throw new \InvalidArgumentException( \IPS\Member::loggedIn()->language()->addToStack('login_error_ipsconnect_self') );
		}

		try
		{
			$response = \IPS\Http\Url::external( $this->settings['url'] )
				->setQueryString( array( 'do' => 'verifySettings', 'key' => $this->settings['key'], 'url' => \IPS\Settings::i()->base_url . '/applications/core/interface/ipsconnect/ipsconnect.php', 'ourKey' => \IPS\CONNECT_MASTER_KEY ?: md5( md5( \IPS\Settings::i()->sql_user . \IPS\Settings::i()->sql_pass ) . \IPS\Settings::i()->board_start ) ) )
				->request()
				->get()
				->decodeJson();
				
			if ( empty( $response ) OR empty( $response['status'] ) OR $response['status'] != 'SUCCESS' )
			{
				throw new \InvalidArgumentException( \IPS\Member::loggedIn()->language()->addToStack('login_3p_bad', FALSE, array( 'sprintf' => array( \IPS\Member::loggedIn()->language()->addToStack('login_handler_Ipsconnect') ) ) ) );
			}
		}
		/* Have to catch \RuntimeException to catch the BAD_JSON throw from Response.php */
		catch ( \RuntimeException $e )
		{
			throw new \InvalidArgumentException( \IPS\Member::loggedIn()->language()->addToStack('login_3p_bad', FALSE, array( 'sprintf' => array( \IPS\Member::loggedIn()->language()->addToStack('login_handler_Ipsconnect') ) ) ) );
		}

		return TRUE;
	}

	/**
	 * Retrieve the user's associated IPS Connect ID
	 *
	 * @param	\IPS\Member	$member		The member
	 * @return	int|NULL	The IPS Connect ID or NULL if none found
	 */
	protected function _getConnectId( \IPS\Member $member )
	{
		/* Already have it? */
		if( $member->ipsconnect_id )
		{
			return $member->ipsconnect_id;
		}

		/* An incomplete member may not have an email address, which won't return an ID */
		if( !$member->email )
		{
			return NULL;
		}

		/* Fetch it from the master */
		try
		{
			$response = \IPS\Http\Url::external( $this->settings['url'] )
				->setQueryString( array( 'do' => 'fetchId', 'key' => md5( $this->settings['key'] . $member->email ), 'id' => $member->email, 'idType' => 'email' ) )
				->request()
				->get()
				->decodeJson();

			if ( !empty( $response ) AND !empty( $response['status'] ) AND $response['status'] == 'SUCCESS' AND $response['connect_id'] )
			{
				$member->ipsconnect_id	= $response['connect_id'];
				$member->save();
			}
		}
		/* Have to catch \RuntimeException to catch the BAD_JSON throw from Response.php */
		catch ( \RuntimeException $e )
		{
		}

		return NULL;
	}
	
	/**
	 * Email is in use?
	 * Used when registering or changing an email address to check the new one is available
	 *
	 * @param	string				$email		Email Address
	 * @param	\IPS\Member|NULL	$exclude	Member to exclude
	 * @return	bool|NULL Boolean indicates if email is in use (TRUE means is in use and thus not registerable) or NULL if this handler does not support such an API
	 */
	public function emailIsInUse( $email, \IPS\Member $exclude=NULL )
	{
		if( $exclude !== NULL AND $exclude instanceof \IPS\Member AND $email == $exclude->email )
		{
			return FALSE;
		}

		try
		{
			$response = \IPS\Http\Url::external( $this->settings['url'] )
				->setQueryString( array( 'do' => 'checkEmail', 'key' => $this->settings['key'], 'email' => $email ) )
				->request()
				->get()
				->decodeJson();

			if ( !empty( $response ) AND !empty( $response['status'] ) AND $response['status'] == 'SUCCESS' AND $response['used'] != 0 )
			{
				return TRUE;
			}
		}
		/* Have to catch \RuntimeException to catch the BAD_JSON throw from Response.php */
		catch ( \RuntimeException $e )
		{
		}

		return FALSE;
	}
	
	/**
	 * Username is in use?
	 * Used when registering or changing an username to check the new one is available
	 *
	 * @param	string	$username	Username
	 * @return	bool|NULL			Boolean indicates if username is in use (TRUE means is in use and thus not registerable) or NULL if this handler does not support such an API
	 */
	public function usernameIsInUse( $username )
	{
		if( \IPS\CONNECT_NOSYNC_NAMES === TRUE )
		{
			return FALSE;
		}

		try
		{
			$response = \IPS\Http\Url::external( $this->settings['url'] )
				->setQueryString( array( 'do' => 'checkName', 'key' => $this->settings['key'], 'name' => $username ) )
				->request()
				->get()
				->decodeJson();

			if ( !empty( $response ) AND !empty( $response['status'] ) AND $response['status'] == 'SUCCESS' AND $response['used'] != 0 )
			{
				return TRUE;
			}
		}
		/* Have to catch \RuntimeException to catch the BAD_JSON throw from Response.php */
		catch ( \RuntimeException $e )
		{
		}

		return FALSE;
	}

	/**
	 * Log an account off
	 *
	 * @param	\IPS\Member		$member			The member that was just logged out
	 * @param	\IPS\Http\Url	$redirectUrl	The URL to send the user back to
	 * @return	void
	 * @note	This is NOT called if you force log out all users from the ACP on an individual site
	 */
	public function logoutAccount( \IPS\Member $member, \IPS\Http\Url $redirectUrl )
	{
		if( \IPS\Request::i()->slaveCall )
		{
			return;
		}

		$connectId	= $this->_getConnectId( $member );
		
		if ( $connectId )
		{
			\IPS\Output::i()->redirect( 
				\IPS\Http\Url::external( $this->settings['url'] )->setQueryString( array( 
					'do'		=> 'logout', 
					'key'		=> md5( $this->settings['key'] . $connectId ), 
					'url'		=> \IPS\Settings::i()->base_url . '/applications/core/interface/ipsconnect/ipsconnect.php', 
					'id'		=> $connectId, 
					'returnTo'	=> (string) $redirectUrl, 
				) )
			);
		}
	}
	
	/**
	 * Change Email Address
	 *
	 * @param	\IPS\Member	$member		The member
	 * @param	string		$oldEmail	Old Email Address
	 * @param	string		$newEmail	New Email Address
	 * @return	void
	 */
	public function changeEmail( \IPS\Member $member, $oldEmail, $newEmail )
	{
		if( \IPS\Request::i()->slaveCall )
		{
			return;
		}

		$connectId	= $this->_getConnectId( $member );

		if( $connectId )
		{
			try
			{
				$response = \IPS\Http\Url::external( $this->settings['url'] )
					->setQueryString( array( 'do' => 'changeEmail', 'key' => md5( $this->settings['key'] . $connectId ), 'url' => \IPS\Settings::i()->base_url . '/applications/core/interface/ipsconnect/ipsconnect.php', 'email' => $newEmail, 'id' => $connectId ) )
					->request()
					->get()
					->decodeJson();
			}
			catch( \RuntimeException $e ){}
		}
	}
	
	/**
	 * Change Password
	 *
	 * @param	\IPS\Member	$member			The member
	 * @param	string		$newPassword	New Password
	 * @return	void
	 */
	public function changePassword( \IPS\Member $member, $newPassword )
	{
		if( \IPS\Request::i()->slaveCall )
		{
			return;
		}

		$connectId	= $this->_getConnectId( $member );

		if( $connectId )
		{
			/* Now create a dummy user to get password hash */
			$testUser	= new \IPS\Member;
			$testUser->members_pass_salt	= $testUser->generateSalt();
			$password	= $testUser->encryptedPassword( $newPassword );

			try
			{
				$response = \IPS\Http\Url::external( $this->settings['url'] )
					->setQueryString( array( 'do' => 'changePassword', 'key' => md5( $this->settings['key'] . $connectId ), 'url' => \IPS\Settings::i()->base_url . '/applications/core/interface/ipsconnect/ipsconnect.php', 'pass_hash' => $password, 'pass_salt' => $testUser->members_pass_salt, 'id' => $connectId ) )
					->request()
					->get()
					->decodeJson();
			}
			catch( \RuntimeException $e ){}
		}
	}
	
	/**
	 * Change Username
	 *
	 * @param	\IPS\Member	$member			The member
	 * @param	string		$oldUsername	Old Username
	 * @param	string		$newUsername	New Username
	 * @return	void
	 */
	public function changeUsername( \IPS\Member $member, $oldUsername, $newUsername )
	{
		if( \IPS\CONNECT_NOSYNC_NAMES === TRUE )
		{
			return FALSE;
		}

		if( \IPS\Request::i()->slaveCall )
		{
			return;
		}

		$connectId	= $this->_getConnectId( $member );

		if( $connectId )
		{
			try
			{
				$response = \IPS\Http\Url::external( $this->settings['url'] )
					->setQueryString( array( 'do' => 'changeName', 'key' => md5( $this->settings['key'] . $connectId ), 'url' => \IPS\Settings::i()->base_url . '/applications/core/interface/ipsconnect/ipsconnect.php', 'name' => $newUsername, 'id' => $connectId ) )
					->request()
					->get()
					->decodeJson();
			}
			catch( \RuntimeException $e ){}
		}
	}

	/**
	 * Create an account
	 *
	 * @param	\IPS\Member	$member			The member that was just created
	 * @return	void
	 * @throws	\RuntimeException
	 */
	public function createAccount( \IPS\Member $member )
	{
		if( \IPS\Request::i()->slaveCall )
		{
			return;
		}

		/* If account creation is occurring because the user just successfully logged in, do not try to then create an account on the master install */
		if( $this->skipMasterRegistration === TRUE )
		{
			return;
		}

		$response = \IPS\Http\Url::external( $this->settings['url'] )
			->setQueryString( array( 
				'do'			=> 'register',
				'key'			=> $this->settings['key'],
				'name'			=> $member->name,
				'email'			=> $member->email,
				'pass_hash'		=> $member->members_pass_hash,
				'pass_salt'		=> $member->members_pass_salt,
				'revalidateUrl'	=> ( $member->members_bitoptions['validating'] == TRUE ) ? (string) \IPS\Http\Url::internal( "", 'front' ) : '',
				'url'			=> \IPS\Settings::i()->base_url . '/applications/core/interface/ipsconnect/ipsconnect.php'
			) )
			->request()
			->get()
			->decodeJson();

		if( !empty( $response ) AND !empty( $response['status'] ) AND $response['status'] == 'SUCCESS' AND !empty( $response['connect_id'] ) )
		{
			$member->ipsconnect_id	= $response['connect_id'];
			$member->save();
		}
	}

	/**
	 * Validate account
	 *
	 * @param	\IPS\Member	$member			The member
	 * @return	void
	 * @throws	\RuntimeException
	 */
	public function validateAccount( \IPS\Member $member )
	{
		if( \IPS\Request::i()->slaveCall )
		{
			return;
		}

		$connectId	= $this->_getConnectId( $member );

		if( $connectId )
		{
			$response = \IPS\Http\Url::external( $this->settings['url'] )
				->setQueryString( array( 'do' => 'validate', 'key' => md5( $this->settings['key'] . $connectId ), 'url' => \IPS\Settings::i()->base_url . '/applications/core/interface/ipsconnect/ipsconnect.php', 'id' => $connectId ) )
				->request()
				->get()
				->decodeJson();
		}
	}

	/**
	 * Delete account
	 *
	 * @param	\IPS\Member	$member			The member
	 * @return	void
	 * @throws	\RuntimeException
	 */
	public function deleteAccount( \IPS\Member $member )
	{
		if( \IPS\Request::i()->slaveCall )
		{
			return;
		}

		$connectId	= $this->_getConnectId( $member );

		if( $connectId )
		{
			$response = \IPS\Http\Url::external( $this->settings['url'] )
				->setQueryString( array( 'do' => 'delete', 'key' => md5( $this->settings['key'] . $connectId ), 'url' => \IPS\Settings::i()->base_url . '/applications/core/interface/ipsconnect/ipsconnect.php', 'id' => $connectId ) )
				->request()
				->get()
				->decodeJson();
		}
	}

	/**
	 * Ban or unban account
	 *
	 * @param	\IPS\Member	$member			The member
	 * @param	bool						TRUE means member is being banned, FALSE means they are being unbanned
	 * @return	void
	 * @throws	\RuntimeException
	 */
	public function banAccount( \IPS\Member $member, $ban=TRUE )
	{
		if( \IPS\Request::i()->slaveCall )
		{
			return;
		}

		$connectId	= $this->_getConnectId( $member );

		if( $connectId )
		{
			$response = \IPS\Http\Url::external( $this->settings['url'] )
				->setQueryString( array( 'do' => 'ban', 'key' => md5( $this->settings['key'] . $connectId ), 'url' => \IPS\Settings::i()->base_url . '/applications/core/interface/ipsconnect/ipsconnect.php', 'status' => (int) $ban, 'id' => $connectId ) )
				->request()
				->get()
				->decodeJson();
		}
	}

	/**
	 * Merge two accounts
	 *
	 * @param	\IPS\Member	$member			The member to keep with original data
	 * @param	\IPS\Member	$member2		The member that will be deleted
	 * @return	void
	 * @throws	\RuntimeException
	 */
	public function mergeAccounts( \IPS\Member $member, \IPS\Member $member2 )
	{
		if( \IPS\Request::i()->slaveCall )
		{
			return;
		}

		$connectId	= $this->_getConnectId( $member );
		$connectId2	= $this->_getConnectId( $member2 );

		if( $connectId AND $connectId2 )
		{
			$response = \IPS\Http\Url::external( $this->settings['url'] )
				->setQueryString( array( 'do' => 'merge', 'key' => md5( $this->settings['key'] . $connectId ), 'url' => \IPS\Settings::i()->base_url . '/applications/core/interface/ipsconnect/ipsconnect.php', 'remove' => $connectId2, 'id' => $connectId ) )
				->request()
				->get()
				->decodeJson();
		}
	}
}