Files
Cloud-CMS/lib/Middleware/SAMLAuthentication.php

489 lines
20 KiB
PHP
Raw Permalink Normal View History

2025-12-02 10:32:59 -05:00
<?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'])
);
}
}