Initial Upload
This commit is contained in:
488
lib/Middleware/SAMLAuthentication.php
Normal file
488
lib/Middleware/SAMLAuthentication.php
Normal file
@@ -0,0 +1,488 @@
|
||||
<?php
|
||||
/*
|
||||
* Copyright (C) 2024 Xibo Signage Ltd
|
||||
*
|
||||
* Xibo - Digital Signage - https://xibosignage.com
|
||||
*
|
||||
* This file is part of Xibo.
|
||||
*
|
||||
* Xibo 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
|
||||
* any later version.
|
||||
*
|
||||
* Xibo 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 Xibo. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
|
||||
namespace Xibo\Middleware;
|
||||
|
||||
use OneLogin\Saml2\Auth;
|
||||
use OneLogin\Saml2\Error;
|
||||
use OneLogin\Saml2\Settings;
|
||||
use OneLogin\Saml2\Utils;
|
||||
use Slim\Http\Response as Response;
|
||||
use Slim\Http\ServerRequest as Request;
|
||||
use Xibo\Helper\ApplicationState;
|
||||
use Xibo\Helper\LogoutTrait;
|
||||
use Xibo\Helper\Random;
|
||||
use Xibo\Support\Exception\AccessDeniedException;
|
||||
use Xibo\Support\Exception\ConfigurationException;
|
||||
use Xibo\Support\Exception\NotFoundException;
|
||||
|
||||
/**
|
||||
* Class SAMLAuthentication
|
||||
* @package Xibo\Middleware
|
||||
*
|
||||
* Provide SAML authentication to Xibo configured via settings.php.
|
||||
*/
|
||||
class SAMLAuthentication extends AuthenticationBase
|
||||
{
|
||||
use LogoutTrait;
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
public function addRoutes()
|
||||
{
|
||||
$app = $this->app;
|
||||
$app->getContainer()->set('logoutRoute', 'saml.logout');
|
||||
|
||||
// Route providing SAML metadata
|
||||
$app->get('/saml/metadata', function (Request $request, Response $response) {
|
||||
$settings = new Settings($this->getConfig()->samlSettings, true);
|
||||
$metadata = $settings->getSPMetadata();
|
||||
$errors = $settings->validateMetadata($metadata);
|
||||
if (empty($errors)) {
|
||||
return $response
|
||||
->withHeader('Content-Type', 'text/xml')
|
||||
->write($metadata);
|
||||
} else {
|
||||
throw new ConfigurationException(
|
||||
'Invalid SP metadata: ' . implode(', ', $errors),
|
||||
Error::METADATA_SP_INVALID
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// SAML Login
|
||||
$app->get('/saml/login', function (Request $request, Response $response) {
|
||||
// Initiate SAML SSO
|
||||
$auth = new Auth($this->getConfig()->samlSettings);
|
||||
return $auth->login();
|
||||
});
|
||||
|
||||
// SAML Logout
|
||||
$app->get('/saml/logout', function (Request $request, Response $response) {
|
||||
return $this->samlLogout($request, $response);
|
||||
})->setName('saml.logout');
|
||||
|
||||
// SAML Assertion Consumer Endpoint
|
||||
$app->post('/saml/acs', function (Request $request, Response $response) {
|
||||
// Log some interesting things
|
||||
$this->getLog()->debug('Arrived at the ACS route with own URL: ' . Utils::getSelfRoutedURLNoQuery());
|
||||
|
||||
// Pull out the SAML settings
|
||||
$samlSettings = $this->getConfig()->samlSettings;
|
||||
$auth = new Auth($samlSettings);
|
||||
$auth->processResponse();
|
||||
|
||||
// Check for errors
|
||||
$errors = $auth->getErrors();
|
||||
|
||||
if (!empty($errors)) {
|
||||
$this->getLog()->error('Single Sign on Failed: ' . implode(', ', $errors)
|
||||
. '. Last Reason: ' . $auth->getLastErrorReason());
|
||||
|
||||
throw new AccessDeniedException(__('Your authentication provider could not log you in.'));
|
||||
} else {
|
||||
// Pull out the SAML attributes
|
||||
$samlAttrs = $auth->getAttributes();
|
||||
|
||||
$this->getLog()->debug('SAML attributes: ' . json_encode($samlAttrs));
|
||||
|
||||
// How should we look up the user?
|
||||
$identityField = (isset($samlSettings['workflow']['field_to_identify']))
|
||||
? $samlSettings['workflow']['field_to_identify']
|
||||
: 'UserName';
|
||||
|
||||
if ($identityField !== 'nameId' && empty($samlAttrs)) {
|
||||
// We will need some attributes
|
||||
throw new AccessDeniedException(__('No attributes retrieved from the IdP'));
|
||||
}
|
||||
|
||||
// If appropriate convert the SAML Attributes into userData mapped against the workflow mappings.
|
||||
$userData = [];
|
||||
if (isset($samlSettings['workflow']) && isset($samlSettings['workflow']['mapping'])) {
|
||||
foreach ($samlSettings['workflow']['mapping'] as $key => $value) {
|
||||
if (!empty($value) && isset($samlAttrs[$value])) {
|
||||
$userData[$key] = $samlAttrs[$value];
|
||||
}
|
||||
}
|
||||
|
||||
// If we can't map anything, then we better throw an error
|
||||
if (empty($userData)) {
|
||||
throw new AccessDeniedException(__('No attributes could be mapped'));
|
||||
}
|
||||
}
|
||||
|
||||
// If we're using the nameId as the identity, then we should populate our userData with that value
|
||||
if ($identityField === 'nameId') {
|
||||
$userData[$identityField] = $auth->getNameId();
|
||||
} else {
|
||||
// Check to ensure that our identity has been populated from attributes successfully
|
||||
if (!isset($userData[$identityField]) || empty($userData[$identityField])) {
|
||||
throw new AccessDeniedException(sprintf(__('%s not retrieved from the IdP and required since is the field to identify the user'), $identityField));
|
||||
}
|
||||
}
|
||||
|
||||
// Are we going to try and match our Xibo groups to our Idp groups?
|
||||
$isMatchGroupFromIdp = ($samlSettings['workflow']['matchGroups']['enabled'] ?? false) === true
|
||||
&& ($samlSettings['workflow']['matchGroups']['attribute'] ?? null) !== null;
|
||||
|
||||
// Try and get the user record.
|
||||
$user = null;
|
||||
|
||||
try {
|
||||
switch ($identityField) {
|
||||
case 'nameId':
|
||||
$user = $this->getUserFactory()->getByName($userData[$identityField]);
|
||||
break;
|
||||
|
||||
case 'UserID':
|
||||
$user = $this->getUserFactory()->getById($userData[$identityField][0]);
|
||||
break;
|
||||
|
||||
case 'UserName':
|
||||
$user = $this->getUserFactory()->getByName($userData[$identityField][0]);
|
||||
break;
|
||||
|
||||
case 'email':
|
||||
$user = $this->getUserFactory()->getByEmail($userData[$identityField][0]);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new AccessDeniedException(__('Invalid field_to_identify value. Review settings.'));
|
||||
}
|
||||
} catch (NotFoundException $e) {
|
||||
// User does not exist - this is valid as we might create them JIT.
|
||||
}
|
||||
|
||||
if (!isset($user)) {
|
||||
if (!isset($samlSettings['workflow']['jit']) || $samlSettings['workflow']['jit'] == false) {
|
||||
throw new AccessDeniedException(__('User logged at the IdP but the account does not exist in the CMS and Just-In-Time provisioning is disabled'));
|
||||
} else {
|
||||
// Provision the user
|
||||
$user = $this->getEmptyUser();
|
||||
$user->homeFolderId = 1;
|
||||
|
||||
if (isset($userData["UserName"])) {
|
||||
$user->userName = $userData["UserName"][0];
|
||||
}
|
||||
|
||||
if (isset($userData["email"])) {
|
||||
$user->email = $userData["email"][0];
|
||||
}
|
||||
|
||||
if (isset($userData["usertypeid"])) {
|
||||
$user->userTypeId = $userData["usertypeid"][0];
|
||||
} else {
|
||||
$user->userTypeId = 3;
|
||||
}
|
||||
|
||||
// Xibo requires a password, generate a random one (it won't ever be used by SAML)
|
||||
$password = Random::generateString(20);
|
||||
$user->setNewPassword($password);
|
||||
|
||||
// Home page
|
||||
if (isset($samlSettings['workflow']['homePage'])) {
|
||||
try {
|
||||
$user->homePageId = $this->getUserGroupFactory()->getHomepageByName(
|
||||
$samlSettings['workflow']['homePage']
|
||||
)->homepage;
|
||||
} catch (NotFoundException $exception) {
|
||||
$this->getLog()->info(
|
||||
sprintf(
|
||||
'Provided homepage %s, does not exist,
|
||||
setting the icondashboard.view as homepage',
|
||||
$samlSettings['workflow']['homePage']
|
||||
)
|
||||
);
|
||||
$user->homePageId = 'icondashboard.view';
|
||||
}
|
||||
} else {
|
||||
$user->homePageId = 'icondashboard.view';
|
||||
}
|
||||
|
||||
// Library Quota
|
||||
if (isset($samlSettings['workflow']['libraryQuota'])) {
|
||||
$user->libraryQuota = $samlSettings['workflow']['libraryQuota'];
|
||||
} else {
|
||||
$user->libraryQuota = 0;
|
||||
}
|
||||
|
||||
// Match references
|
||||
if (isset($samlSettings['workflow']['ref1']) && isset($userData['ref1'])) {
|
||||
$user->ref1 = $userData['ref1'];
|
||||
}
|
||||
|
||||
if (isset($samlSettings['workflow']['ref2']) && isset($userData['ref2'])) {
|
||||
$user->ref2 = $userData['ref2'];
|
||||
}
|
||||
|
||||
if (isset($samlSettings['workflow']['ref3']) && isset($userData['ref3'])) {
|
||||
$user->ref3 = $userData['ref3'];
|
||||
}
|
||||
|
||||
if (isset($samlSettings['workflow']['ref4']) && isset($userData['ref4'])) {
|
||||
$user->ref4 = $userData['ref4'];
|
||||
}
|
||||
|
||||
if (isset($samlSettings['workflow']['ref5']) && isset($userData['ref5'])) {
|
||||
$user->ref5 = $userData['ref5'];
|
||||
}
|
||||
|
||||
// Save the user
|
||||
$user->save();
|
||||
|
||||
// Assign the initial group
|
||||
if (isset($samlSettings['workflow']['group']) && !$isMatchGroupFromIdp) {
|
||||
$group = $this->getUserGroupFactory()->getByName($samlSettings['workflow']['group']);
|
||||
} else {
|
||||
$group = $this->getUserGroupFactory()->getByName('Users');
|
||||
}
|
||||
|
||||
$group->assignUser($user);
|
||||
$group->save(['validate' => false]);
|
||||
$this->getLog()->setIpAddress($request->getAttribute('ip_address'));
|
||||
|
||||
// Audit Log
|
||||
$this->getLog()->audit('User', $user->userId, 'User created with SAML workflow', [
|
||||
'UserName' => $user->userName,
|
||||
'UserAgent' => $request->getHeader('User-Agent')
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($user) && $user->userId > 0) {
|
||||
// Load User
|
||||
$user = $this->getUser(
|
||||
$user->userId,
|
||||
$request->getAttribute('ip_address'),
|
||||
$this->getSession()->get('sessionHistoryId')
|
||||
);
|
||||
|
||||
// Overwrite our stored user with this new object.
|
||||
$this->setUserForRequest($user);
|
||||
|
||||
// Switch Session ID's
|
||||
$this->getSession()->setIsExpired(0);
|
||||
$this->getSession()->regenerateSessionId();
|
||||
$this->getSession()->setUser($user->userId);
|
||||
|
||||
$user->touch();
|
||||
|
||||
// Audit Log
|
||||
$this->getLog()->audit('User', $user->userId, 'Login Granted via SAML', [
|
||||
'UserAgent' => $request->getHeader('User-Agent')
|
||||
]);
|
||||
}
|
||||
|
||||
// Match groups from IdP?
|
||||
if ($isMatchGroupFromIdp) {
|
||||
$this->getLog()->debug('group matching enabled');
|
||||
|
||||
// Match groups is enabled, and we have an attribute to get groups from.
|
||||
$idpGroups = [];
|
||||
$extractionRegEx = $samlSettings['workflow']['matchGroups']['extractionRegEx'] ?? null;
|
||||
|
||||
// Get groups.
|
||||
foreach ($samlAttrs[$samlSettings['workflow']['matchGroups']['attribute']] as $groupAttr) {
|
||||
// Regex?
|
||||
if (!empty($extractionRegEx)) {
|
||||
$matches = [];
|
||||
preg_match_all($extractionRegEx, $groupAttr, $matches);
|
||||
|
||||
if (count($matches[1]) > 0) {
|
||||
$groupAttr = $matches[1][0];
|
||||
}
|
||||
}
|
||||
|
||||
$this->getLog()->debug('checking for group ' . $groupAttr);
|
||||
|
||||
// Does this group exist?
|
||||
try {
|
||||
$idpGroups[$groupAttr] = $this->getUserGroupFactory()->getByName($groupAttr);
|
||||
} catch (NotFoundException) {
|
||||
$this->getLog()->debug('group ' . $groupAttr . ' does not exist');
|
||||
}
|
||||
}
|
||||
|
||||
// Go through the users groups
|
||||
$usersGroups = [];
|
||||
foreach ($user->groups as $userGroup) {
|
||||
$usersGroups[$userGroup->group] = $userGroup;
|
||||
}
|
||||
|
||||
foreach ($user->groups as $userGroup) {
|
||||
// Does this group exist in the Idp? If not, remove.
|
||||
if (!array_key_exists($userGroup->group, $idpGroups)) {
|
||||
// Group exists in Xibo, does not exist in the response, so remove.
|
||||
$userGroup->unassignUser($user);
|
||||
$userGroup->save(['validate' => false]);
|
||||
|
||||
$this->getLog()->debug($userGroup->group
|
||||
. ' not matched to any IdP groups linked, removing');
|
||||
|
||||
unset($usersGroups[$userGroup->group]);
|
||||
} else {
|
||||
// Matched, so remove from idpGroups
|
||||
unset($idpGroups[$userGroup->group]);
|
||||
|
||||
$this->getLog()->debug($userGroup->group . ' already linked.');
|
||||
}
|
||||
}
|
||||
|
||||
// Go through remaining groups and assign the user to them.
|
||||
foreach ($idpGroups as $idpGroup) {
|
||||
$this->getLog()->debug($idpGroup->group . ' already linked.');
|
||||
|
||||
$idpGroup->assignUser($user);
|
||||
$idpGroup->save(['validate' => false]);
|
||||
}
|
||||
|
||||
// Does this user still not have any groups?
|
||||
if (count($usersGroups) <= 0) {
|
||||
$group = $this->getUserGroupFactory()->getByName($samlSettings['workflow']['group'] ?? 'Users');
|
||||
$group->assignUser($user);
|
||||
$group->save(['validate' => false]);
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect back to the originally-requested url, if provided
|
||||
// it is not clear why basename is used here, it seems to be something to do with a logout loop
|
||||
$params = $request->getParams();
|
||||
$relayState = $params['RelayState'] ?? null;
|
||||
$redirect = empty($relayState) || basename($relayState) === 'login'
|
||||
? $this->getRouteParser()->urlFor('home')
|
||||
: $relayState;
|
||||
|
||||
$this->getLog()->debug('redirecting to ' . $redirect);
|
||||
|
||||
return $response->withRedirect($redirect);
|
||||
}
|
||||
});
|
||||
|
||||
// Single Logout Service
|
||||
$app->map(['GET', 'POST'], '/saml/sls', function (Request $request, Response $response) use ($app) {
|
||||
// Make request to IDP
|
||||
$auth = new Auth($app->getContainer()->get('configService')->samlSettings);
|
||||
try {
|
||||
$auth->processSLO(false, null, false, function () use ($request) {
|
||||
// Audit that the IDP has completed this request.
|
||||
$this->getLog()->setIpAddress($request->getAttribute('ip_address'));
|
||||
$this->getLog()->setSessionHistoryId($this->getSession()->get('sessionHistoryId'));
|
||||
$this->getLog()->audit('User', 0, 'Idp SLO completed', [
|
||||
'UserAgent' => $request->getHeader('User-Agent')
|
||||
]);
|
||||
});
|
||||
} catch (\Exception $e) {
|
||||
// Ignored - get with getErrors()
|
||||
}
|
||||
|
||||
$errors = $auth->getErrors();
|
||||
|
||||
if (empty($errors)) {
|
||||
return $response->withRedirect($this->getRouteParser()->urlFor('home'));
|
||||
} else {
|
||||
throw new AccessDeniedException('SLO failed. ' . implode(', ', $errors));
|
||||
}
|
||||
});
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \Slim\Http\ServerRequest $request
|
||||
* @param \Slim\Http\Response $response
|
||||
* @return \Psr\Http\Message\ResponseInterface|\Slim\Http\Response
|
||||
* @throws \OneLogin\Saml2\Error
|
||||
*/
|
||||
public function samlLogout(Request $request, Response $response)
|
||||
{
|
||||
$samlSettings = $this->getConfig()->samlSettings;
|
||||
|
||||
if (isset($samlSettings['workflow'])
|
||||
&& isset($samlSettings['workflow']['slo'])
|
||||
&& $samlSettings['workflow']['slo'] == true
|
||||
) {
|
||||
// Complete our own logout flow
|
||||
$this->completeLogoutFlow(
|
||||
$this->getUser(
|
||||
$_SESSION['userid'],
|
||||
$request->getAttribute('ip_address'),
|
||||
$_SESSION['sessionHistoryId']
|
||||
),
|
||||
$this->getSession(),
|
||||
$this->getLog(),
|
||||
$request
|
||||
);
|
||||
|
||||
// Initiate SAML SLO
|
||||
$auth = new Auth($samlSettings);
|
||||
return $response->withRedirect($auth->logout());
|
||||
} else {
|
||||
return $response->withRedirect($this->getRouteParser()->urlFor('logout'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Request $request
|
||||
* @return Response
|
||||
* @throws \OneLogin\Saml2\Error
|
||||
*/
|
||||
public function redirectToLogin(\Psr\Http\Message\ServerRequestInterface $request)
|
||||
{
|
||||
if ($this->isAjax($request)) {
|
||||
return $this->createResponse($request)->withJson(ApplicationState::asRequiresLogin());
|
||||
} else {
|
||||
// Initiate SAML SSO
|
||||
$auth = new Auth($this->getConfig()->samlSettings);
|
||||
return $this->createResponse($request)->withRedirect($auth->login());
|
||||
}
|
||||
}
|
||||
|
||||
/** @inheritDoc */
|
||||
public function getPublicRoutes(\Psr\Http\Message\ServerRequestInterface $request)
|
||||
{
|
||||
return array_merge($request->getAttribute('publicRoutes', []), [
|
||||
'/saml/metadata',
|
||||
'/saml/login',
|
||||
'/saml/acs',
|
||||
'/saml/logout',
|
||||
'/saml/sls'
|
||||
]);
|
||||
}
|
||||
|
||||
/** @inheritDoc */
|
||||
public function shouldRedirectPublicRoute($route)
|
||||
{
|
||||
return ($this->getSession()->isExpired()
|
||||
&& ($route == '/login/ping' || $route == 'clock'))
|
||||
|| $route == '/login';
|
||||
}
|
||||
|
||||
/** @inheritDoc */
|
||||
public function addToRequest(\Psr\Http\Message\ServerRequestInterface $request)
|
||||
{
|
||||
return $request->withAttribute(
|
||||
'excludedCsrfRoutes',
|
||||
array_merge($request->getAttribute('excludedCsrfRoutes', []), ['/saml/acs', '/saml/sls'])
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user