Files
iTop/datamodels/2.x/authent-cas/src/CASLoginExtension.php
Eric Espie d9819d9c2a N°4784 - CAS authentication issue with iTop 3.0
* Fix regression when setting cas as first login mode
2022-02-28 11:55:45 +01:00

531 lines
15 KiB
PHP

<?php
/**
* @copyright Copyright (C) 2010-2021 Combodo SARL
* @license https://www.combodo.com/documentation/combodo-software-license.html
*
*/
namespace Combodo\iTop\Cas;
use AbstractLoginFSMExtension;
use CMDBObject;
use Combodo\iTop\Application\Helper\Session;
use DBObjectSearch;
use DBObjectSet;
use Dict;
use iLoginUIExtension;
use iLogoutExtension;
use LoginBlockExtension;
use LoginTwigContext;
use LoginWebPage;
use MetaModel;
use phpCAS;
use User;
use UserExternal;
use utils;
/**
* Class CASLoginExtension
*/
class CASLoginExtension extends AbstractLoginFSMExtension implements iLogoutExtension, iLoginUIExtension
{
const LOGIN_MODE = 'cas';
/**
* Return the list of supported login modes for this plugin
*
* @return array of supported login modes
*/
public function ListSupportedLoginModes()
{
return array(static::LOGIN_MODE);
}
protected function OnStart(&$iErrorCode)
{
Session::Unset('phpCAS');
return LoginWebPage::LOGIN_FSM_CONTINUE;
}
protected function OnReadCredentials(&$iErrorCode)
{
if (empty(Session::Get('login_mode')) || Session::Get('login_mode') == static::LOGIN_MODE)
{
static::InitCASClient();
if (phpCAS::isAuthenticated())
{
Session::Set('login_mode', static::LOGIN_MODE);
Session::Set('auth_user', phpCAS::getUser());
Session::Unset('login_will_redirect');
}
else
{
if (!Session::IsSet('login_will_redirect'))
{
Session::Set('login_will_redirect', true);
}
else
{
Session::Unset('login_will_redirect');
$iErrorCode = LoginWebPage::EXIT_CODE_MISSINGLOGIN;
return LoginWebPage::LOGIN_FSM_ERROR;
}
Session::Set('login_mode', static::LOGIN_MODE);
phpCAS::forceAuthentication(); // Redirect to CAS and exit
}
}
return LoginWebPage::LOGIN_FSM_CONTINUE;
}
protected function OnCheckCredentials(&$iErrorCode)
{
if (Session::Get('login_mode') == static::LOGIN_MODE)
{
if (!Session::IsSet('auth_user'))
{
$iErrorCode = LoginWebPage::EXIT_CODE_WRONGCREDENTIALS;
return LoginWebPage::LOGIN_FSM_ERROR;
}
if (Config::Get('cas_user_synchro' ))
{
self::DoUserProvisioning(Session::Get('auth_user'));
}
}
return LoginWebPage::LOGIN_FSM_CONTINUE;
}
protected function OnCredentialsOK(&$iErrorCode)
{
if (Session::Get('login_mode') == static::LOGIN_MODE)
{
$sAuthUser = Session::Get('auth_user');
if (!LoginWebPage::CheckUser($sAuthUser))
{
$iErrorCode = LoginWebPage::EXIT_CODE_NOTAUTHORIZED;
return LoginWebPage::LOGIN_FSM_ERROR;
}
LoginWebPage::OnLoginSuccess($sAuthUser, 'external', Session::Get('login_mode'));
}
return LoginWebPage::LOGIN_FSM_CONTINUE;
}
protected function OnError(&$iErrorCode)
{
if (Session::Get('login_mode') == static::LOGIN_MODE)
{
Session::Unset('phpCAS');
if ($iErrorCode != LoginWebPage::EXIT_CODE_MISSINGLOGIN)
{
$oLoginWebPage = new LoginWebPage();
$oLoginWebPage->DisplayLogoutPage(false, Dict::S('CAS:Error:UserNotAllowed'));
exit();
}
}
return LoginWebPage::LOGIN_FSM_CONTINUE;
}
protected function OnConnected(&$iErrorCode)
{
if (Session::Get('login_mode') == static::LOGIN_MODE)
{
Session::Set('can_logoff', true);
return LoginWebPage::CheckLoggedUser($iErrorCode);
}
return LoginWebPage::LOGIN_FSM_CONTINUE;
}
/**
* Execute all actions to log out properly
*/
public function LogoutAction()
{
$sCASLogoutUrl = Config::Get('cas_logout_redirect_service');
if (empty($sCASLogoutUrl))
{
$sCASLogoutUrl = utils::GetAbsoluteUrlAppRoot().'pages/UI.php';
}
static::InitCASClient();
phpCAS::logoutWithRedirectService($sCASLogoutUrl); // Redirects to the CAS logout page
}
private static function InitCASClient()
{
$bCASDebug = Config::Get('cas_debug');
if ($bCASDebug)
{
phpCAS::setDebug(APPROOT.'log/cas.log');
}
// Initialize phpCAS
$sCASVersion = Config::Get('cas_version');
$sCASHost = Config::Get('cas_host');
$iCASPort = Config::Get('cas_port');
$sCASContext = Config::Get('cas_context');
phpCAS::client($sCASVersion, $sCASHost, $iCASPort, $sCASContext, false /* session already started */);
$sCASCACertPath = Config::Get('cas_server_ca_cert_path');
if (empty($sCASCACertPath))
{
// If no certificate authority is provided, do not attempt to validate
// the server's certificate
// THIS SETTING IS NOT RECOMMENDED FOR PRODUCTION.
// VALIDATING THE CAS SERVER IS CRUCIAL TO THE SECURITY OF THE CAS PROTOCOL!
phpCAS::setNoCasServerValidation();
}
else
{
phpCAS::setCasServerCACert($sCASCACertPath);
}
}
private function DoUserProvisioning($sLogin)
{
$bCASUserSynchro = Config::Get('cas_user_synchro');
if (!$bCASUserSynchro)
{
return;
}
CMDBObject::SetTrackInfo('CAS/LDAP Synchro');
$oUser = LoginWebPage::FindUser($sLogin, false);
if ($oUser)
{
if ($oUser->Get('status') == 'enabled')
{
CASUserProvisioning::UpdateUser($oUser);
}
return;
}
CASUserProvisioning::CreateUser($sLogin, '', 'external');
}
/**
* @return LoginTwigContext
*/
public function GetTwigContext()
{
$oLoginContext = new LoginTwigContext();
$oLoginContext->SetLoaderPath(APPROOT.'env-'.utils::GetCurrentEnvironment().'/authent-cas/view');
$aData = array(
'sLoginMode' => static::LOGIN_MODE,
'sLabel' => Dict::S('CAS:Login:SignIn'),
'sTooltip' => Dict::S('CAS:Login:SignInTooltip'),
);
$oLoginContext->AddBlockExtension('login_sso_buttons', new LoginBlockExtension('cas_sso_button.html.twig', $aData));
return $oLoginContext;
}
}
/**
* Automatic creation & update of CAS users
*
*/
class CASUserProvisioning
{
/**
* Called when no user is found in iTop for the corresponding 'name'. This method
* can create/synchronize the User in iTop with an external source (such as AD/LDAP) on the fly
*
* @return bool true if the user is a valid one, false otherwise
* @throws \ArchivedObjectException
* @throws \CoreCannotSaveObjectException
* @throws \CoreException
* @throws \CoreUnexpectedValue
* @throws \CoreWarning
* @throws \MissingQueryArgument
* @throws \MySQLException
* @throws \MySQLHasGoneAwayException
* @throws \OQLException
*/
public static function CreateUser()
{
$bOk = true;
$sCASMemberships = Config::Get('cas_memberof');
$bFound = false;
if (!empty($sCASMemberships))
{
$sCASMemberOfName = Config::Get('cas_memberof_attribute_name', 'memberOf');
if (phpCAS::hasAttribute($sCASMemberOfName))
{
// A list of groups is specified, the user must a be member of (at least) one of them to pass
$aCASMemberships = array();
$aTmp = explode(';', $sCASMemberships);
setlocale(LC_ALL, "en_US.utf8"); // !!! WARNING: this is needed to have the iconv //TRANSLIT working fine below !!!
foreach($aTmp as $sGroupName)
{
$aCASMemberships[] = trim(iconv('UTF-8', 'ASCII//TRANSLIT', $sGroupName)); // Just in case remove accents and spaces...
}
$aMemberOf = phpCAS::getAttribute($sCASMemberOfName);
if (!is_array($aMemberOf)) $aMemberOf = array($aMemberOf); // Just one entry, turn it into an array
$aFilteredGroupNames = array();
foreach($aMemberOf as $sGroupName)
{
phpCAS::log("Info: user if a member of the group: ".$sGroupName);
$sGroupName = trim(iconv('UTF-8', 'ASCII//TRANSLIT', $sGroupName)); // Remove accents and spaces as well
$aFilteredGroupNames[] = $sGroupName;
$bIsMember = false;
foreach($aCASMemberships as $sCASPattern)
{
if (self::IsPattern($sCASPattern))
{
if (preg_match($sCASPattern, $sGroupName))
{
$bIsMember = true;
break;
}
}
else if ($sCASPattern == $sGroupName)
{
$bIsMember = true;
break;
}
}
if ($bIsMember)
{
// If needed create a new user for this email/profile
$bOk = self::CreateCASUser(phpCAS::getUser(), $aMemberOf);
if($bOk)
{
$bFound = true;
}
else
{
phpCAS::log("User ".phpCAS::getUser()." cannot be created in iTop. Logging off...");
}
break;
}
}
if($bOk && !$bFound)
{
phpCAS::log("User ".phpCAS::getUser().", none of his/her groups (".implode('; ', $aFilteredGroupNames).") match any of the required groups: ".implode('; ', $aCASMemberships));
}
}
else
{
// Too bad, the user is not part of any of the group => not allowed
phpCAS::log("No '$sCASMemberOfName' attribute found for user ".phpCAS::getUser().". Are you using the SAML protocol (S1) ?");
}
}
else
{
// No membership: no way to create the user that should exist prior to authentication
phpCAS::log("User ".phpCAS::getUser().": missing user account in iTop (or iTop badly configured, Cf setting cas_memberof)");
$bFound = false;
}
if (!$bFound)
{
// The user is not part of the allowed groups, => log out
$sCASLogoutUrl = Config::Get('cas_logout_redirect_service');
if (empty($sCASLogoutUrl))
{
$sCASLogoutUrl = utils::GetAbsoluteUrlAppRoot().'pages/UI.php';
}
phpCAS::logoutWithRedirectService($sCASLogoutUrl); // Redirects to the CAS logout page
// Will never return !
}
return $bFound;
}
/**
* Called after the user has been authenticated and found in iTop. This method can
* Update the user's definition (profiles...) on the fly to keep it in sync with an external source
*
* @param User $oUser The user to update/synchronize
*
* @return void
* @throws \CoreException
* @throws \CoreUnexpectedValue
* @throws \MySQLException
*/
public static function UpdateUser(User $oUser)
{
$bCASUpdateProfiles = Config::Get('cas_update_profiles');
$sCASMemberOfName = Config::Get('cas_memberof_attribute_name', 'memberOf');
if ($bCASUpdateProfiles && (phpCAS::hasAttribute($sCASMemberOfName)))
{
$aMemberOf = phpCAS::getAttribute($sCASMemberOfName);
if (!is_array($aMemberOf)) $aMemberOf = array($aMemberOf); // Just one entry, turn it into an array
self::SetProfilesFromCAS($oUser, $aMemberOf);
}
// No groups defined in CAS or not CAS at all: do nothing...
}
/**
* Helper method to create a CAS based user
*
* @param string $sLogin
* @param array $aGroups
*
* @return bool true on success, false otherwise
* @throws \CoreException
* @throws \CoreUnexpectedValue
* @throws \MissingQueryArgument
* @throws \MySQLException
* @throws \MySQLHasGoneAwayException
*/
protected static function CreateCASUser($sLogin, $aGroups)
{
if (!MetaModel::IsValidClass('URP_Profiles'))
{
phpCAS::log("URP_Profiles is not a valid class. Automatic creation of Users is not supported in this context, sorry.");
return false;
}
$oUser = MetaModel::GetObjectByName('UserExternal', $sLogin, false);
if ($oUser == null)
{
// Create the user, link it to a contact
if (phpCAS::hasAttribute('mail'))
{
$sEmail = phpCAS::getAttribute('mail');
}
else
{
$sEmail = $sLogin;
}
phpCAS::log("Info: the user '$sLogin' does not exist. A new UserExternal will be created.");
$oSearch = new DBObjectSearch('Person');
$oSearch->AddCondition('email', $sEmail);
$oSet = new DBObjectSet($oSearch);
switch($oSet->Count())
{
case 0:
phpCAS::log("Error: found no contact with the email: '$sEmail'. Cannot create the user in iTop.");
return false;
case 1:
$oContact = $oSet->Fetch();
$iContactId = $oContact->GetKey();
phpCAS::log("Info: Found 1 contact '".$oContact->GetName()."' (id=$iContactId) corresponding to the email '$sEmail'.");
break;
default:
phpCAS::log("Error: ".$oSet->Count()." contacts have the same email: '$sEmail'. Cannot create a user for this email.");
return false;
}
$oUser = new UserExternal();
$oUser->Set('login', $sLogin);
$oUser->Set('contactid', $iContactId);
$oUser->Set('language', MetaModel::GetConfig()->GetDefaultLanguage());
}
else
{
phpCAS::log("Info: the user '$sLogin' already exists (id=".$oUser->GetKey().").");
}
// Now synchronize the profiles
return self::SetProfilesFromCAS($oUser, $aGroups);
}
/**
* @param User $oUser
* @param array $aGroups
*
* @return bool
* @throws \CoreException
* @throws \CoreUnexpectedValue
* @throws \MySQLException
*/
protected static function SetProfilesFromCAS($oUser, $aGroups)
{
if (!MetaModel::IsValidClass('URP_Profiles'))
{
phpCAS::log("URP_Profiles is not a valid class. Automatic creation of Users is not supported in this context, sorry.");
return false;
}
// read all the existing profiles
$oProfilesSearch = new DBObjectSearch('URP_Profiles');
$oProfilesSet = new DBObjectSet($oProfilesSearch);
$aAllProfiles = array();
while($oProfile = $oProfilesSet->Fetch())
{
$aAllProfiles[strtolower($oProfile->GetName())] = $oProfile->GetKey();
}
// Translate the CAS/LDAP group names into iTop profile names
$aProfiles = array();
$sPattern = Config::Get('cas_profile_pattern');
foreach($aGroups as $sGroupName)
{
if (preg_match($sPattern, $sGroupName, $aMatches))
{
if (array_key_exists(strtolower($aMatches[1]), $aAllProfiles))
{
$aProfiles[] = $aAllProfiles[strtolower($aMatches[1])];
phpCAS::log("Info: Adding the profile '{$aMatches[1]}' from CAS.");
}
else
{
phpCAS::log("Warning: {$aMatches[1]} is not a valid iTop profile (extracted from group name: '$sGroupName'). Ignored.");
}
}
else
{
phpCAS::log("Info: The CAS group '$sGroupName' does not seem to match an iTop pattern. Ignored.");
}
}
if (count($aProfiles) == 0)
{
phpCAS::log("Info: The user '".$oUser->GetName()."' has no profiles retrieved from CAS. Default profile(s) will be used.");
// Second attempt: check if there is/are valid default profile(s)
$sCASDefaultProfiles = Config::Get('cas_default_profiles');
$aCASDefaultProfiles = explode(';', $sCASDefaultProfiles);
foreach($aCASDefaultProfiles as $sDefaultProfileName)
{
if (array_key_exists(strtolower($sDefaultProfileName), $aAllProfiles))
{
$aProfiles[] = $aAllProfiles[strtolower($sDefaultProfileName)];
phpCAS::log("Info: Adding the default profile '".$aAllProfiles[strtolower($sDefaultProfileName)]."' from CAS.");
}
else
{
phpCAS::log("Warning: the default profile {$sDefaultProfileName} is not a valid iTop profile. Ignored.");
}
}
if (count($aProfiles) == 0)
{
phpCAS::log("Error: The user '".$oUser->GetName()."' has no profiles in iTop, and therefore cannot be created.");
return false;
}
}
// Now synchronize the profiles
LoginWebPage::SynchronizeProfiles($oUser, $aProfiles, 'CAS/LDAP Synchro');
phpCAS::log("Info: the user '".$oUser->GetName()."' (id=".$oUser->GetKey().") now has the following profiles: '".implode("', '", $aProfiles)."'.");
if ($oUser->IsModified())
{
$oUser->DBWrite();
}
return true;
}
/**
* Helper function to check if the supplied string is a literal string or a regular expression pattern
* @param string $sCASPattern
* @return bool True if it's a regular expression pattern, false otherwise
*/
protected static function IsPattern($sCASPattern)
{
if ((substr($sCASPattern, 0, 1) == '/') && (substr($sCASPattern, -1) == '/'))
{
// the string is enclosed by slashes, let's assume it's a pattern
return true;
}
else
{
return false;
}
}
}