Skip to content

Commit

Permalink
Merge pull request #95 from TAMULib/development
Browse files Browse the repository at this point in the history
SAML User support and test fixes
  • Loading branch information
jsavell committed Jul 11, 2024
2 parents ba9b81a + c87e5e1 commit 098c7ae
Show file tree
Hide file tree
Showing 11 changed files with 309 additions and 20 deletions.
9 changes: 5 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,17 @@ jobs:
steps:
- uses: shivammathur/setup-php@v2
with:
php-version: 7.4
php-version: 8.1
- uses: actions/checkout@v3
- uses: php-actions/composer@v5
- uses: php-actions/composer@v6
- name: Run PHPStan Static Analysis
uses: php-actions/phpstan@master
uses: php-actions/phpstan@v3
with:
php_version: 8.1
memory_limit: 512M
path: src/Pipit/
configuration: phpstan.dist
level: 9
level: 8
- name: Run Codeception Tests
run: php vendor/bin/codecept run

3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
],
"require": {
"php": ">=7.4",
"psr/log": "^1.1"
"psr/log": "^1.1",
"onelogin/php-saml": "^4.2"
},
"autoload": {
"psr-4": {
Expand Down
2 changes: 1 addition & 1 deletion src/Pipit/Classes/Data/ResultsPage.php
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ public static function getNewResultsPage(int $page, int $resultsPerPage) {
return $resultsPage;
}

public function jsonSerialize() {
public function jsonSerialize():mixed {
return get_object_vars($this);
}
}
174 changes: 174 additions & 0 deletions src/Pipit/Classes/Data/UserSAML.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
<?php
namespace Pipit\Classes\Data;

use Pipit\Classes\Exceptions\ConfigurationException;
use OneLogin\Saml2\Auth;

class UserSAML extends UserDB {
use \Pipit\Traits\FileConfiguration;

/** @var \Pipit\Interfaces\DataRepository $usersRepo A DataRepository representing the app's Users (assumes existence of 'username' and 'issaml' fields) */
private $usersRepo;

/** @var mixed[] $settings An array of settings for onelogin configuration */
private $settings;

private const CONFIG_FILE = "user.saml";
private const DEFAULT_USERNAME_MAPPING = "netid";

/**
* Instantiates a new UserSAML by negotiating the login process with a configured SAML Server
* @param mixed[] $inputData The input data from the request
* @param \Pipit\Interfaces\DataRepository $usersRepo A DataRepository representing the app's Users (assumes existence of 'username' and 'issaml' fields)
*/
public function __construct($inputData, $usersRepo) {

$this->loadSettings();

parent::__construct();

$appConfig = $this->getAppConfiguration();
$redirectUrl = (array_key_exists('redirect', $this->settings) && is_string($this->settings['redirect'])) ?
$this->settings['redirect']:$appConfig['PATH_HTTP'];

if (!empty($inputData['SAMLResponse'])) {
$this->usersRepo = $usersRepo;

if (is_string($inputData['SAMLResponse']) && $this->processLogIn()) {
header("Location:".$redirectUrl);
}
} elseif (!$this->isLoggedIn() && !isset($inputData['action'])) {
$this->initiateLogIn();
}
}

/**
* Verifies that all required settings have some value
* @return boolean
*/
protected function checkSettings() {
return (is_array($this->settings)
&& !empty($this->settings['sp']['entityId'])
&& !empty($this->settings['sp']['assertionConsumerService']['url'])
&& !empty($this->settings['sp']['singleLogoutService']['url'])
&& !empty($this->settings['idp']['entityId'])
&& !empty($this->settings['idp']['singleSignOnService']['url'])
&& !empty($this->settings['idp']['singleLogoutService']['url'])
&& !empty($this->settings['idp']['singleLogoutService']['responseUrl'])
&& !empty($this->settings['idp']['x509cert']));
}

/**
* Loads the settings from Pipit configuration and merges them with the defaults from onelogin
* @return void
*/
protected function loadSettings() {
$configurationFileName = self::CONFIG_FILE;
$config = null;
if ($this->configurationFileExists($configurationFileName)) {
$config = $this->getConfigurationFromFileName($configurationFileName);
} else {
throw new ConfigurationException("SAML config file does not exist");
}
$settings = [];
$defaultSettingsFile = __DIR__."/../../../../../../onelogin/php-saml/settings_example.php";
if (is_file($defaultSettingsFile)) {
require($defaultSettingsFile);
} else {
throw new ConfigurationException("Default Settings file is missing. Have you run composer install?");
}
$this->settings = array_replace($settings, $config);

if (!$this->checkSettings()) {
throw new ConfigurationException("Invalid SAML settings. Please check ".$configurationFileName);
}
}

/**
* Processes the SAML response and uses it to login a user
* @return boolean Returns true on successful login, false on everything else
*/
public function processLogIn() {
$auth = new Auth($this->settings);

if (isset($_SESSION) && isset($_SESSION['AuthNRequestID'])) {
$requestId = $_SESSION['AuthNRequestID'];
} else {
$requestId = null;
}

$auth->processResponse($requestId);
unset($_SESSION['AuthNRequestID']);

$errors = $auth->getErrors();

if (!empty($errors)) {
throw new \RuntimeException("SAML error: ".implode(', ',$errors));
}

if (!$auth->isAuthenticated()) {
throw new \RuntimeException("SAML error: Not authenticated");
}

$userNameField = (is_array($this->settings['claims']) && array_key_exists('username', $this->settings['claims'])) ? $this->settings['claims']['username'] : self::DEFAULT_USERNAME_MAPPING;

if (!array_key_exists($userNameField, $auth->getAttributes())) {
throw new \RuntimeException("SAML error: {$userNameField} claim not present in SAML response");
}

$samlUserName = $auth->getAttributes()[$userNameField][0];
return $this->processUser($samlUserName);
}

/**
* Uses the provided username to find/create a matching local user and initiate the session
* @param string $userName
* @return boolean Returns true on success, false for anything else
*/
protected function processUser($userName) {
$tusers = $this->usersRepo;
//find an existing, active user or create a new one
$user = $tusers->searchAdvanced(array("username"=>$userName));
if ($user && is_array($user)) {
if ($user[0]['inactive'] == 0) {
$userId = $user[0]['id'];
}
} elseif (!empty($userName)) {
$userId = $tusers->add(array("username"=>$userName,"issaml"=>1));
}
if (!empty($userId)) {
session_regenerate_id(true);
session_start();
$this->setSessionUserId($userId);
$this->buildProfile();
return true;
}
return false;
}

/**
* Triggers the SAML login request
* @return void
*/
public function initiatelogIn() {
$auth = new Auth($this->settings);
$auth->login();
}

/**
* Terminates the local session and Initiates the SAML logout process
*/
public function logOut() {
parent::logOut();
$auth = new Auth($this->settings);
$auth->logout();
}

/**
* Overrides the inherited UserDB login mechanism to guarantee no action/success
*/
public function logIn($username,$password) {
return false;
}
}
?>
52 changes: 42 additions & 10 deletions src/Pipit/Classes/Site/CoreSite.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,29 +37,61 @@ public function __construct(&$siteConfig) {
protected function setUser() {
$config = $this->getSiteConfig();
$userClass = null;
$isCustomUserClass = array_key_exists('USER_CLASS', $config) && is_string($config['USER_CLASS']) && is_string($config['NAMESPACE_APP']);
$useSaml = is_bool($config['USESAML']) && $config['USESAML'];
$useCas = is_bool($config['USECAS']) && $config['USECAS'];

if (is_array($config)) {
if (array_key_exists('USER_CLASS', $config) && is_string($config['USER_CLASS']) && is_string($config['NAMESPACE_APP'])) {
if ($isCustomUserClass) {
$className = "{$config['NAMESPACE_APP']}Classes\\Data\\{$config['USER_CLASS']}";
if (class_exists($className)) {
$userClass = new $className();
if (!($userClass instanceof \Pipit\Interfaces\User)) {
$userClass = null;
$customUserClassImplements = class_implements($className);
if (!in_array('Pipit\Interfaces\User', $customUserClassImplements)) { //!($userClass instanceof \Pipit\Interfaces\User)) {
throw new ConfigurationException("Configured User class does not implement: Pipit\Interfaces\User");
}
if (!$useSaml && !$useCas) {
$userClass = new $className();
}
} else {
throw new ConfigurationException("Configured User class not found: {$className}");
}
}
if ($userClass && is_bool($config['USECAS']) && $config['USECAS']) {
if ($useSaml) {
if ($config['SAML_USER_REPO']) {
$userRepo = $this->getDataRepository($config['SAML_USER_REPO']);
if ($userRepo instanceof \Pipit\Interfaces\DataRepository) {
if ($isCustomUserClass) {
if (in_array("Pipit\Classes\Data\UserSAML", $customUserClassImplements)) {
$userClass = new $className($this->getSanitizedInputData(),$userRepo);
unset($userRepo);
} else {
$userClass = null;
throw new ConfigurationException("Configured UserSAML classes must extend Pipit\Classes\Data\UserSAML");
}
} else {
$userClass = new Data\UserSAML($this->getSanitizedInputData(),$userRepo);
}
} else {
throw new ConfigurationException("UserSAML requires a Pipit\Interfaces\DataRepository");
}
} else {
throw new ConfigurationException("UserSAML requires SAML_USER_REPO to be defined with a Pipit\Interfaces\DataRepository");
}
}
if ($useCas) {
if ($config['CAS_USER_REPO']) {
$userRepo = $this->getDataRepository($config['CAS_USER_REPO']);
if ($userRepo instanceof \Pipit\Interfaces\DataRepository) {
if ($userClass instanceof \Pipit\Classes\Data\UserCAS) {
$userClass = new Data\UserCAS($this->getSanitizedInputData(),$userRepo);
unset($userRepo);
if ($isCustomUserClass) {
if (in_array("Pipit\Classes\Data\UserCAS", $customUserClassImplements)) {
$userClass = new $userClass($this->getSanitizedInputData(),$userRepo);
unset($userRepo);
} else {
$userClass = null;
throw new ConfigurationException("Configured UserCAS classes must extend Pipit\Classes\Data\UserCAS");
}
} else {
$userClass = null;
throw new ConfigurationException("Configured UserCAS classes must extend Pipit\Classes\Data\UserCAS");
$userClass = new Data\UserCAS($this->getSanitizedInputData(),$userRepo);
}
} else {
throw new ConfigurationException("UserCAS requires a Pipit\Interfaces\DataRepository");
Expand Down
4 changes: 2 additions & 2 deletions src/Pipit/Utilities/LDAPConnector.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ class LDAPConnector {
private $user;
/** @var string $password The password to connect with */
private $password;
/** @var resource|false $handle The LDAP Connection */
/** @var \LDAP\Connection|false $handle The LDAP Connection */
private $handle;

private const CONFIG_FILE = "ldap.connector";
Expand Down Expand Up @@ -79,7 +79,7 @@ static private function configIsValid($url, $port, $user, $password) {

/**
* Returns the value of the property with the given name
* @return resource|false
* @return \LDAP\Connection|false
*/
public function getConnection() {
if ($this->handle) {
Expand Down
27 changes: 27 additions & 0 deletions tests/CoreSiteTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,33 @@ public function testDefaultCasUser()
$this->assertEquals('Pipit\Classes\Data\UserCAS',get_class($coreSite->getGlobalUser()));
}

public function testConfiguredSamlUser()
{
$this->config['SAML_USER_REPO'] = 'TestDataRepository';
$coreSite = $this->getCoreSiteInstance('TestUserSAML');
$this->assertEquals('TestFiles\Classes\Data\TestUserSAML',get_class($coreSite->getGlobalUser()));
}

public function testConfiguredSamlUserWithNoRepository()
{
$this->config['USESAML'] = true;
$this->config['SAML_USER_REPO'] = null;

$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage("UserSAML requires SAML_USER_REPO to be defined with a Pipit\Interfaces\DataRepository");

$coreSite = $this->getCoreSiteInstance('TestUserSAML');
}

public function testDefaultSamlUser()
{
$this->config['USE_SAML'] = true;
//We will fail to connect to a non-existent database, but we're only testing that the right class is set for the globalUser
$this->expectException(\PDOException::class);
$coreSite = $this->getDefaultCoreSiteInstance();
$this->assertEquals('Pipit\Classes\Data\UserSAML',get_class($coreSite->getGlobalUser()));
}

public function testDefaultUser()
{
//We will fail to connect to a non-existent database, but we're only testing that the right class is set for the globalUser
Expand Down
30 changes: 30 additions & 0 deletions tests/TestFiles/Classes/Data/TestUserSAML.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php
namespace TestFiles\Classes\Data;
use Pipit\Interfaces as Interfaces;

class TestUserSAML implements Interfaces\User {
public function logOut() {
return true;
}

public function logIn($username,$password) {
return true;
}

public function isLoggedIn() {
return true;
}

public function getProfileValue($field) {
return null;
}

public function getProfile() {
return [];
}

public function isAdmin() {
return false;
}
}
?>
22 changes: 22 additions & 0 deletions tests/TestFiles/Config/user.saml.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
[claims]
;Optionally override the default 'netid' claim mapping
;username =

[sp]
;Identifier of the SP entity (must be a URI)
entityId = PATH_HTTP
;Where the <AuthnResponse> message MUST be returned
assertionConsumerService[url] = PATH_HTTP
;Where the <Response> from the IdP will be returned
singleLogoutService[url] = PATH_HTTP

[idp]
entityId =
singleSignOnService[url] =
singleLogoutService[url] =
singleLogoutService[responseUrl] = PATH_HTTP
x509cert =

;The default post-login redirect behavior is to redirect to the application's home page
;This can be overridden by defining a custom redirect path
;redirect = PATH_HTTP
Loading

0 comments on commit 098c7ae

Please sign in to comment.