Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SAML User support and test fixes #95

Merged
merged 18 commits into from
Jul 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading