Single Sign-On Authentication Mechanism (#7601)

* Allow the URL a user is sent to after logging out to be customised
This is required for any authentication system that has a magic URL for logging out (e.g. /Shibboleth.sso/Logout).

* Allow auth plugins to return a username

This is a bit cleaner than the current auth flow, which special cases e.g. http authentication

* Add some tests, defaults and documentation

* Add single sign-on authentication mechanism

* Make HTTPAuth use the authExternal/getExternalUsername methods

* Add to acknowledgements

* Add reset method to Auth
This commit is contained in:
Adam Bishop
2017-11-29 02:40:17 +00:00
committed by Tony Murray
parent 3720f0e776
commit 1c6b7a967f
11 changed files with 1094 additions and 5 deletions

View File

@@ -25,6 +25,7 @@ class Auth
'http-auth' => 'LibreNMS\Authentication\HttpAuthAuthorizer',
'ad-authorization' => 'LibreNMS\Authentication\ADAuthorizationAuthorizer',
'ldap-authorization' => 'LibreNMS\Authentication\LdapAuthorizationAuthorizer',
'sso' => 'LibreNMS\Authentication\SSOAuthorizer',
);
$auth_mechanism = Config::get('auth_mechanism');
@@ -36,4 +37,15 @@ class Auth
}
return static::$_instance;
}
/**
* Destroy the existing instance and get a new one - required for tests.
*
* @return Authorizer
*/
public static function reset()
{
static::$_instance = null;
return static::get();
}
}

View File

@@ -33,6 +33,7 @@ abstract class AuthorizerBase implements Authorizer
{
protected static $HAS_AUTH_USERMANAGEMENT = 0;
protected static $CAN_UPDATE_USER = 0;
protected static $AUTH_IS_EXTERNAL = 0;
/**
* Log out the user, unset cookies, destroy the session
@@ -244,4 +245,14 @@ abstract class AuthorizerBase implements Authorizer
//not supported by default
return 0;
}
public function authIsExternal()
{
return static::$AUTH_IS_EXTERNAL;
}
public function getExternalUsername()
{
return null;
}
}

View File

@@ -10,6 +10,7 @@ class HttpAuthAuthorizer extends AuthorizerBase
{
protected static $HAS_AUTH_USERMANAGEMENT = 1;
protected static $CAN_UPDATE_USER = 1;
protected static $AUTH_IS_EXTERNAL = 1;
public function authenticate($username, $password)
{
@@ -103,4 +104,13 @@ class HttpAuthAuthorizer extends AuthorizerBase
{
dbUpdate(array('realname' => $realname, 'level' => $level, 'can_modify_passwd' => $can_modify_passwd, 'email' => $email), 'users', '`user_id` = ?', array($user_id));
}
public function getExternalUsername()
{
if (isset($_SERVER['REMOTE_USER'])) {
return clean($_SERVER['REMOTE_USER']);
} elseif (isset($_SERVER['PHP_AUTH_USER'])) {
return clean($_SERVER['PHP_AUTH_USER']);
}
}
}

View File

@@ -0,0 +1,297 @@
<?php
/**
* SSOAuthorizer.php
*
* -Description-
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program 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 General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
* @package LibreNMS\Authentication
* @link https://librenms.org
* @copyright 2017 Adam Bishop
* @author Adam Bishop <adam@omega.org.uk>
*/
namespace LibreNMS\Authentication;
use LibreNMS\Config;
use LibreNMS\Util\IP;
use LibreNMS\Exceptions\AuthenticationException;
use LibreNMS\Exceptions\InvalidIpException;
/**
* Some functionality in this mechanism is inspired by confluence_http_authenticator (@chauth) and graylog-plugin-auth-sso (@Graylog)
*
*/
class SSOAuthorizer extends AuthorizerBase
{
protected static $HAS_AUTH_USERMANAGEMENT = 1;
protected static $CAN_UPDATE_USER = 1;
protected static $AUTH_IS_EXTERNAL = 1;
public function authenticate($username, $password)
{
if (empty($username)) {
throw new AuthenticationException('$config[\'sso\'][\'user_attr\'] was not found or was empty');
}
// Build the user's details from attributes
$email = $this->authSSOGetAttr(Config::get('sso.email_attr'));
$realname = $this->authSSOGetAttr(Config::get('sso.realname_attr'));
$description = $this->authSSOGetAttr(Config::get('sso.descr_attr'));
$can_modify_passwd = 0;
$level = $this->authSSOCalculateLevel();
// User has already been approved by the authenicator so if automatic user create/update is enabled, do it
if (Config::get('sso.create_users') && !$this->userExists($username)) {
$this->addUser($username, $password, $level, $email, $realname, $can_modify_passwd, $description ? $description : 'SSO User');
} elseif (Config::get('sso.update_users') && $this->userExists($username)) {
$this->updateUser($this->getUserid($username), $realname, $level, $can_modify_passwd, $email);
}
return true;
}
public function addUser($username, $password, $level = 1, $email = '', $realname = '', $can_modify_passwd = 0, $description = 'SSO User')
{
// Check to see if user is already added in the database
if (!$this->userExists($username)) {
$userid = dbInsert(array('username' => $username, 'password' => null, 'realname' => $realname, 'email' => $email, 'descr' => $description, 'level' => $level, 'can_modify_passwd' => $can_modify_passwd), 'users');
if ($userid == false) {
return false;
} else {
foreach (dbFetchRows('select notifications.* from notifications where not exists( select 1 from notifications_attribs where notifications.notifications_id = notifications_attribs.notifications_id and notifications_attribs.user_id = ?) order by notifications.notifications_id desc', array($userid)) as $notif) {
dbInsert(array('notifications_id'=>$notif['notifications_id'],'user_id'=>$userid,'key'=>'read','value'=>1), 'notifications_attribs');
}
}
return $userid;
} else {
return false;
}
}
public function userExists($username, $throw_exception = false)
{
// A local user is always created, so we query this, as in the event of an admin choosing to manually administer user creation we should return false.
$user = dbFetchCell('SELECT COUNT(*) FROM users WHERE username = ?', array($this->getExternalUsername()), true);
if ($throw_exception) {
// Hopefully the caller knows what they're doing
throw new AuthenticationException('User not found and invocation requested an exception be thrown');
}
return $user;
}
public function getUserLevel($username)
{
// The user level should always be persisted to the database (and may be managed there) so we again query the database.
return dbFetchCell('SELECT `level` FROM `users` WHERE `username` = ?', array($this->getExternalUsername()), true);
}
public function getUserid($username)
{
// User ID is obviously unique to LibreNMS, this must be resolved via the database
return dbFetchCell('SELECT `user_id` FROM `users` WHERE `username` = ?', array($this->getExternalUsername()), true);
}
public function deleteUser($userid)
{
// Clean up the entries persisted to the database
dbDelete('bill_perms', '`user_id` = ?', array($userid));
dbDelete('devices_perms', '`user_id` = ?', array($userid));
dbDelete('ports_perms', '`user_id` = ?', array($userid));
dbDelete('users_prefs', '`user_id` = ?', array($userid));
dbDelete('users', '`user_id` = ?', array($userid));
return dbDelete('users', '`user_id` = ?', array($userid));
}
public function getUserlist()
{
return dbFetchRows('SELECT * FROM `users` ORDER BY `username`');
}
public function getUser($user_id)
{
return dbFetchRow('SELECT * FROM `users` WHERE `user_id` = ?', array($user_id), true);
}
public function updateUser($user_id, $realname, $level, $can_modify_passwd, $email)
{
// This could, in the worse case, occur on every single pageload, so try and do a bit of optimisation
$user = $this->getUser($user_id);
if ($realname !== $user['realname'] || $level !== $user['level'] || $can_modify_passwd !== $user['can_modify_passwd'] || $email !== $user['email']) {
dbUpdate(array('realname' => $realname, 'level' => $level, 'can_modify_passwd' => $can_modify_passwd, 'email' => $email), 'users', '`user_id` = ?', array($user_id));
}
}
public function getExternalUsername()
{
return $this->authSSOGetAttr(Config::get('sso.user_attr'), '');
}
/**
* Return an attribute from the configured attribute store.
* Returns null if the attribute cannot be found
*
* @param string $attr The name of the attribute to find
* @return string|null
*/
public function authSSOGetAttr($attr, $prefix = 'HTTP_')
{
// Check attribute originates from a trusted proxy - we check it on every attribute just in case this gets called after initial login
if ($this->authSSOProxyTrusted()) {
// Short circuit everything if the attribute is non-existant or null
if (empty($attr)) {
return null;
}
$header_key = $prefix . str_replace('-', '_', strtoupper($attr));
if (Config::get('sso.mode') === 'header' && array_key_exists($header_key, $_SERVER)) {
return $_SERVER[$header_key];
} elseif (Config::get('sso.mode') === 'env' && array_key_exists($attr, $_SERVER)) {
return $_SERVER[$attr];
} else {
return null;
}
} else {
throw new AuthenticationException('$config[\'sso\'][\'trusted_proxies\'] is set, but this connection did not originate from trusted source: ' . $_SERVER['REMOTE_ADDR']);
}
}
/**
* Checks to see if the connection originated from a trusted source address stored in the configuration.
* Returns false if the connection is untrusted, true if the connection is trusted, and true if the trusted sources are not defined.
*
* @return bool
*/
public function authSSOProxyTrusted()
{
// We assume IP is used - if anyone is using a non-ip transport, support will need to be added
if (Config::get('sso.trusted_proxies')) {
try {
// Where did the HTTP connection originate from?
if (isset($_SERVER['REMOTE_ADDR'])) {
// Do not replace this with a call to authSSOGetAttr
$source = IP::parse($_SERVER['REMOTE_ADDR']);
} else {
return false;
}
foreach (Config::get('sso.trusted_proxies') as $value) {
$proxy = IP::parse($value);
if ($source->innetwork((string) $proxy)) {
// Proxy matches trusted subnet
return true;
}
}
// No match, proxy is untrusted
return false;
} catch (InvalidIpException $e) {
// Webserver is talking nonsense (or, IPv10 has been deployed, or maybe something weird like a domain socket is in use?)
return false;
}
}
// Not enabled, trust everything
return true;
}
/**
* Calculate the privilege level to assign to a user based on the configuration and attributes supplied by the external authenticator.
* Returns an integer if the permission is found, or raises an AuthenticationException if the configuration is not valid.
*
* @return integer
*/
public function authSSOCalculateLevel()
{
if (Config::get('sso.group_strategy') === 'attribute') {
if (Config::get('sso.level_attr')) {
if (is_numeric($this->authSSOGetAttr(Config::get('sso.level_attr')))) {
return (int) $this->authSSOGetAttr(Config::get('sso.level_attr'));
} else {
throw new AuthenticationException('group assignment by attribute requested, but httpd is not setting the attribute to a number');
}
} else {
throw new AuthenticationException('group assignment by attribute requested, but $config[\'sso\'][\'level_attr\'] not set');
}
} elseif (Config::get('sso.group_strategy') === 'map') {
if (Config::get('sso.group_level_map') && is_array(Config::get('sso.group_level_map')) && Config::get('sso.group_delimiter') && Config::get('sso.group_attr')) {
return (int) $this->authSSOParseGroups();
} else {
throw new AuthenticationException('group assignment by level map requested, but $config[\'sso\'][\'group_level_map\'], $config[\'sso\'][\'group_attr\'], or $config[\'sso\'][\'group_delimiter\'] are not set');
}
} elseif (Config::get('sso.group_strategy') === 'static') {
if (Config::get('sso.static_level')) {
return (int) Config::get('sso.static_level');
} else {
throw new AuthenticationException('group assignment by static level was requested, but $config[\'sso\'][\'group_level_map\'] was not set');
}
}
throw new AuthenticationException('$config[\'sso\'][\'group_strategy\'] is not set to one of attribute, map or static - configuration is unsafe');
}
/**
* Map a user to a permission level based on a table mapping, 0 if no matching group is found.
*
* @return integer
*/
public function authSSOParseGroups()
{
// Parse a delimited group list
$groups = explode(Config::get('sso.group_delimiter'), $this->authSSOGetAttr(Config::get('sso.group_attr')));
$valid_groups = array();
// Only consider groups that match the filter expression - this is an optimisation for sites with thousands of groups
if (Config::get('sso.group_filter')) {
foreach ($groups as $group) {
if (preg_match(Config::get('sso.group_filter'), $group)) {
array_push($valid_groups, $group);
}
}
$groups = $valid_groups;
}
$level = 0;
$config_map = Config::get('sso.group_level_map');
// Find the highest level the user is entitled to
foreach ($groups as $value) {
if (isset($config_map[$value])) {
$map = $config_map[$value];
if (is_integer($map) && $level < $map) {
$level = $map;
}
}
}
return $level;
}
}

View File

@@ -167,4 +167,20 @@ interface Authorizer
* @return bool
*/
public function sessionAuthenticated();
/**
* Indicates if the authentication happens within the LibreNMS process, or external to it.
* If the former, LibreNMS provides a login form, and the user must supply the username. If the latter, the authenticator supplies it via getExternalUsername() without user interaction.
* This is an important distinction, because at the point this is called if the authentication happens out of process, the user is already authenticated and LibreNMS must not display a login form - even if something fails.
*
* @return bool
*/
public function authIsExternal();
/**
* The username provided by an external authenticator.
*
* @return string|null
*/
public function getExternalUsername();
}

View File

@@ -16,6 +16,8 @@ Here we will provide configuration details for these modules.
- HTTP Auth: [http-auth](#http-authentication), [ad_authorization](#http-authentication-ad-authorization), [ldap_authorization](#http-authentication-ldap-authorization)
- Single Sign-on: [sso](#single-sign-on)
### Enable authentication module
To enable a particular authentication module you need to set this up in config.php.
@@ -267,3 +269,148 @@ $config['auth_ldap_cache_ttl'] = 300;
$config['allow_unauth_graphs_cidr'] = array(127.0.0.1/32');
$config['allow_unauth_graphs'] = true;
```
# Single Sign-on
The single sign-on mechanism is used to integrate with third party authentication providers that are managed outside of LibreNMS - such as ADFS, Shibboleth, EZProxy, BeyondCorp, and others.
A large number of these methods use [SAML](https://en.wikipedia.org/wiki/Security_Assertion_Markup_Language) - the module has been written assuming the use of SAML, and therefore these instructions contain some SAML terminology, but it should be possible to use any software that works in a similar way.
In order to make use of the single sign-on module, you need to have an Identity Provider up and running, and know how to configure your Relying Party to pass attributes to LibreNMS via header injection or environment variables. Setting these up is outside of the scope of this documentation.
As this module deals with authentication, it is extremely careful about validating the configuration - if it finds that certain values in the configuration are not set, it will reject access rather than try and guess.
## Basic Configuration
To get up and running, all you need to do is configure the following values:
```php
$config['auth_mechanism'] = "sso";
$config['sso']['mode'] = "env";
$config['sso']['group_strategy'] = "static";
$config['sso']['static_level'] = 10;
```
This, along with the defaults, sets up a basic Single Sign-on setup that:
* Reads values from environment variables
* Automatically creates users when they're first seen
* Authomatically updates users with new values
* Gives everyone privilege level 10
This happens to mimic the behaviour of [http-auth](#http-auth), so if this is the kind of setup you want, you're probably better of just going and using that mechanism.
## Security
If there is a proxy involved (e.g. EZProxy, Azure AD Application Proxy, NGINX, mod_proxy) it's ___essential___ that you have some means in place to prevent headers being injected between the proxy and the end user, and also prevent end users from contacting LibreNMS directly.
This should also apply to user connections to the proxy itself - the proxy ___must not___ be allowed to blindly pass through HTTP headers. ___mod_security___ should be considered a minimum, with a full [WAF](https://en.wikipedia.org/wiki/Web_application_firewall) being strongly recommended. This advice applies to the IDP too.
The mechanism includes very basic protection, in the form of an IP whitelist with should contain the source addresses of your proxies:
```php
$config['sso']['trusted_proxies'] = ['127.0.0.1/8', '::1/128', '192.0.2.0', '2001:DB8::'];
```
This configuration item should contain an array with a list of IP addresses or CIDR prefixes that are allowed to connect to LibreNMS and supply environment variables or headers.
## Advanced Configuration Options
### User Attribute
If for some reason your relying party doesn't store the username in ___REMOTE_USER___, you can override this choice.
```php
$config['sso']['user_attr'] = 'HTTP_UID';
```
Note that the user lookup is a little special - normally headers are prefixed with ___HTTP\____, however this is not the case for remote user - it's a special case. If you're using something different you need to figure out of the ___HTTP\____ prefix is required or not yourself.
### Automatic User Create/Update
These are enabled by default:
```php
$config['sso']['create_users'] = true;
$config['sso']['update_users'] = true;
```
If these are not enabled, user logins will be (somewhat silently) rejected unless an administrator has created the account in advance. Note that in the case of SAML federations, unless release of the users true identity has been negotiated with the IDP, the username (probably ePTID) is not likely to be predicable.
### Personalisation
If the attributes are being populated, you can instruct the mechanism to add additional information to the user's database entry:
```php
$config['sso']['email_attr'] = "mail";
$config['sso']['realname_attr'] = "displayName";
$config['sso']['descr_attr'] = "unscoped-affiliation
```
### Group Strategies
#### Static
As used above, ___static___ gives every single user the same privilege level. If you're working with a small team, or don't need access control, this is probably suitable.
#### Attribute
```php
$config['sso']['group_strategy'] = "attribute";
$config['sso']['level_attr'] = "entitlement";
```
If your Relying Party is capable of calculating the necessary privilege level, you can configure the module to read the privilege number straight from an attribute. ___sso_level_attr___ should contain the name of the attribute that the Relying Party exposes to LibreNMS - as long as ___sso_mode___ is correctly set, the mechanism should find the value.
### Group Map
This is the most flexible (and complex) way of assigning privileges.
```php
$config['sso']['group_strategy'] = "map";
$config['sso']['group_attr'] = "member";
$config['sso']['group_level_map'] = ['librenms-admins' => 10, 'librenms-readers' => 1, 'librenms-billingcontacts' => 5];
$config['sso']['group_delimiter'] = ';';
```
The mechanism expects to find a delimited list of groups within the attribute that ___sso_group_attr___ points to. This should be an associative array of group name keys, with privilege levels as values.
The mechanism will scan the list and find the ___highest___ privilege level that the user is entitled to, and assign that value to the user.
This format may be specific to Shibboleth; other relying party software may need changes to the mechanism (e.g. ___mod_auth_mellon___ may create pseudo arrays).
There is an optional value for sites with large numbers of groups:
```php
$config['sso']['group_filter'] = "/librenms-(.*)/i";
```
This filter causes the mechanism to only consider groups matching a regular expression.
### Logout Behaviour
LibreNMS has no capability to log out a user authenticated via Single Sign-On - that responsability falls to the Relying Party.
If your Relying Party has a magic URL that needs to be called to end a session, you can configure LibreNMS to direct the user to it:
```php
$config['post_logout_action'] = '/Shibboleth.sso/Logout';
```
This option functions independantly of the Single Sign-on mechanism.
## Complete Configuration
This configuration works on my deployment with a Shibboleth relying party, injecting environment variables, with the IDP supplying a list of groups.
```php
$config['auth_mechanism'] = 'sso';
$config['auth_logout_handler'] = '/Shibboleth.sso/Logout';
$config['sso']['mode'] = 'env';
$config['sso']['create_users'] = true;
$config['sso']['update_users'] = true;
$config['sso']['realname_attr'] = 'displayName';
$config['sso']['email_attr'] = 'mail';
$config['sso']['group_strategy'] = 'map';
$config['sso']['group_attr'] = 'member';
$config['sso']['group_filter'] = '/(librenms-.*)/i';
$config['sso']['group_delimiter'] = ';';
$config['sso']['group_level_map'] = ['librenms-demo' => 11, 'librenms-globaladmin' => 10, 'librenms-globalread' => 5, 'librenms-lowpriv'=> 1];
```

View File

@@ -36,6 +36,8 @@ We list below what we make use of including the license compliance.
- [PHPMailer](https://github.com/PHPMailer/PHPMailer): LGPL v2.1
- [pbin](https://github.com/glensc/pbin): GPLv2 (or later - see script header)
- [CorsSlim](https://github.com/palanik/CorsSlim): MIT
- [Confluence HTTP Authenticator](https://github.com/chauth/confluence_http_authenticator)
- [Graylog SSO Authentication Plugin](https://github.com/Graylog2/graylog-plugin-auth-sso)
## 3rd Party GPLv3 Non-compliant

View File

@@ -2,6 +2,7 @@
use LibreNMS\Authentication\Auth;
use LibreNMS\Authentication\TwoFactor;
use LibreNMS\Config;
use LibreNMS\Exceptions\AuthenticationException;
ini_set('session.use_only_cookies', 1);
@@ -30,7 +31,7 @@ session_start();
$authorizer = Auth::get();
if ($vars['page'] == 'logout' && $authorizer->sessionAuthenticated()) {
$authorizer->logOutUser();
header('Location: ' . $config['base_url']);
header('Location: ' . Config::get('post_logout_action', Config::get('base_url')));
exit;
}
@@ -57,10 +58,8 @@ try {
if (isset($_REQUEST['username']) && isset($_REQUEST['password'])) {
$username = clean($_REQUEST['username']);
$password = $_REQUEST['password'];
} elseif (isset($_SERVER['REMOTE_USER'])) {
$username = clean($_SERVER['REMOTE_USER']);
} elseif (isset($_SERVER['PHP_AUTH_USER']) && $config['auth_mechanism'] === 'http-auth') {
$username = clean($_SERVER['PHP_AUTH_USER']);
} elseif ($authorizer->authIsExternal()) {
$username = $authorizer->getExternalUsername();
}
// form authentication

View File

@@ -637,6 +637,11 @@ $config['auth_ldap_cache_ttl'] = 300;
$config['auth_ad_user_filter'] = "(objectclass=user)";
$config['auth_ad_group_filter'] = "(objectclass=group)";
// Single sign-on defaults
$config['sso']['create_users'] = true;
$config['sso']['update_users'] = true;
$config['sso']['user_attr'] = 'REMOTE_USER';
// Sensors
$config['allow_entity_sensor']['amperes'] = 1;
$config['allow_entity_sensor']['celsius'] = 1;

89
tests/AuthHTTPTest.php Normal file
View File

@@ -0,0 +1,89 @@
<?php
/**
* AuthHTTP.php
*
* -Description-
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program 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 General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
* @package LibreNMS
* @link https://librenms.org
* @copyright 2017 Adam Bishop
* @author Adam Bishop <adam@omega.org.uk>
*/
namespace LibreNMS\Tests;
use LibreNMS\Authentication\Auth;
// Note that as this test set depends on mres(), it is a DBTestCase even though the database is unused
class AuthHTTPTest extends DBTestCase
{
// Document the modules current behaviour, so that changes trigger test failures
public function testCapabilityFunctions()
{
global $config;
$config['auth_mechanism'] = 'http-auth';
$a = Auth::reset();
$this->assertFalse($a->reauthenticate(null, null));
$this->assertTrue($a->canUpdatePasswords() === 0);
$this->assertTrue($a->changePassword(null, null) === 0);
$this->assertTrue($a->canManageUsers() === 1);
$this->assertTrue($a->canUpdateUsers() === 1);
$this->assertTrue($a->authIsExternal() === 1);
}
public function testOldBehaviourAgainstCurrent()
{
global $config;
$old_username = null;
$new_username = null;
$config['auth_mechanism'] = 'http-auth';
$users = array('steve', ' steve', 'steve ', ' steve ', ' steve ', '', 'CAT');
$vars = array('REMOTE_USER', 'PHP_AUTH_USER');
$a = Auth::reset();
foreach ($vars as $v) {
foreach ($users as $u) {
$_SERVER[$v] = $u;
// Old Behaviour
if (isset($_SERVER['REMOTE_USER'])) {
$old_username = clean($_SERVER['REMOTE_USER']);
} elseif (isset($_SERVER['PHP_AUTH_USER']) && $config['auth_mechanism'] === 'http-auth') {
$old_username = clean($_SERVER['PHP_AUTH_USER']);
}
// Current Behaviour
if ($a->authIsExternal()) {
$new_username = $a->getExternalUsername();
}
$this->assertFalse($old_username === null);
$this->assertFalse($new_username === null);
$this->assertTrue($old_username === $new_username);
}
unset($_SERVER[$v]);
}
unset($config['auth_mechanism']);
}
}

501
tests/AuthSSOTest.php Normal file
View File

@@ -0,0 +1,501 @@
<?php
/**
* AuthSSO.php
*
* -Description-
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program 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 General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
* @package LibreNMS
* @link https://librenms.org
* @copyright 2017 Adam Bishop
* @author Adam Bishop <adam@omega.org.uk>
*/
namespace LibreNMS\Tests;
use LibreNMS\Authentication\Auth;
class AuthSSOTest extends DBTestCase
{
private $last_user = null;
private $original_auth_mech = null;
public function setUp()
{
parent::setUp();
global $config;
$this->original_auth_mech = $config['auth_mechanism'];
$config['auth_mechanism'] = 'sso';
}
// Set up an SSO config for tests
public function basicConfig()
{
global $config;
$config['sso']['mode'] = 'env';
$config['sso']['create_users'] = true;
$config['sso']['update_users'] = true;
$config['sso']['trusted_proxies'] = array('127.0.0.1', '::1');
$config['sso']['user_attr'] = 'REMOTE_USER';
$config['sso']['realname_attr'] = 'displayName';
$config['sso']['email_attr'] = 'mail';
$config['sso']['descr_attr'] = null;
$config['sso']['level_attr'] = null;
$config['sso']['group_strategy'] = 'static';
$config['sso']['group_attr'] = 'member';
$config['sso']['group_filter'] = '/(.*)/i';
$config['sso']['group_delimiter'] = ';';
$config['sso']['group_level_map'] = null;
$config['sso']['static_level'] = -1;
}
// Set up $_SERVER in env mode
public function basicEnvironmentEnv()
{
global $config;
unset($_SERVER);
$config['sso']['mode'] = 'env';
$_SERVER['REMOTE_ADDR'] = '::1';
$_SERVER['REMOTE_USER'] = 'test';
$_SERVER['mail'] = 'test@example.org';
$_SERVER['displayName'] = bin2hex(openssl_random_pseudo_bytes(16));
}
// Set up $_SERVER in header mode
public function basicEnvironmentHeader()
{
global $config;
unset($_SERVER);
$config['sso']['mode'] = 'header';
$_SERVER['REMOTE_ADDR'] = '::1';
$_SERVER['REMOTE_USER'] = bin2hex(openssl_random_pseudo_bytes(16));
$_SERVER['HTTP_MAIL'] = 'test@example.org';
$_SERVER['HTTP_DISPLAYNAME'] = 'Test User';
}
public function makeBreakUser()
{
$this->breakUser();
$u = bin2hex(openssl_random_pseudo_bytes(16));
$this->last_user = $u;
$_SERVER['REMOTE_USER'] = $u;
return $u;
}
public function breakUser()
{
$a = Auth::reset();
if ($this->last_user !== null) {
$r = $a->deleteUser($a->getUserid($this->last_user));
$this->last_user = null;
return $r;
}
return true;
}
// Excercise general auth flow
public function testValidAuthNoCreateUpdate()
{
global $config;
$this->basicConfig();
$a = Auth::reset();
$config['sso']['create_users'] = false;
$config['sso']['update_users'] = false;
// Create a random username and store it with the defaults
$this->basicEnvironmentEnv();
$user = $this->makeBreakUser();
$this->assertTrue($a->authenticate($user, null));
// Retrieve it and validate
$dbuser = $a->getUser($a->getUserid($user));
$this->assertFalse($a->authSSOGetAttr($config['sso']['realname_attr']) === $dbuser['realname']);
$this->assertFalse($dbuser['level'] === "0");
$this->assertFalse($a->authSSOGetAttr($config['sso']['email_attr']) === $dbuser['email']);
}
// Excercise general auth flow with creation enabled
public function testValidAuthCreateOnly()
{
global $config;
$this->basicConfig();
$a = Auth::reset();
$config['sso']['create_users'] = true;
$config['sso']['update_users'] = false;
// Create a random username and store it with the defaults
$this->basicEnvironmentEnv();
$user = $this->makeBreakUser();
$this->assertTrue($a->authenticate($user, null));
// Retrieve it and validate
$dbuser = $a->getUser($a->getUserid($user));
$this->assertTrue($a->authSSOGetAttr($config['sso']['realname_attr']) === $dbuser['realname']);
$this->assertTrue($dbuser['level'] === "-1");
$this->assertTrue($a->authSSOGetAttr($config['sso']['email_attr']) === $dbuser['email']);
// Change a few things and reauth
$_SERVER['mail'] = 'test@example.net';
$_SERVER['displayName'] = 'Testier User';
$config['sso']['static_level'] = 10;
$this->assertTrue($a->authenticate($user, null));
// Retrieve it and validate the update was not persisted
$dbuser = $a->getUser($a->getUserid($user));
$this->assertFalse($a->authSSOGetAttr($config['sso']['realname_attr']) === $dbuser['realname']);
$this->assertFalse($dbuser['level'] === "10");
$this->assertFalse($a->authSSOGetAttr($config['sso']['email_attr']) === $dbuser['email']);
}
// Excercise general auth flow with updates enabled
public function testValidAuthUpdate()
{
global $config;
$this->basicConfig();
$a = Auth::reset();
// Create a random username and store it with the defaults
$this->basicEnvironmentEnv();
$user = $this->makeBreakUser();
$this->assertTrue($a->authenticate($user, null));
// Change a few things and reauth
$_SERVER['mail'] = 'test@example.net';
$_SERVER['displayName'] = 'Testier User';
$config['sso']['static_level'] = 10;
$this->assertTrue($a->authenticate($user, null));
// Retrieve it and validate the update persisted
$dbuser = $a->getUser($a->getUserid($user));
$this->assertTrue($a->authSSOGetAttr($config['sso']['realname_attr']) === $dbuser['realname']);
$this->assertTrue($dbuser['level'] === "10");
$this->assertTrue($a->authSSOGetAttr($config['sso']['email_attr']) === $dbuser['email']);
}
// Check some invalid authentication modes
public function testBadAuth()
{
global $config;
$this->basicConfig();
$a = Auth::reset();
$this->basicEnvironmentEnv();
unset($_SERVER);
$this->setExpectedException('LibreNMS\Exceptions\AuthenticationException');
$a->authenticate(null, null);
$this->basicEnvironmentHeader();
unset($_SERVER);
$this->setExpectedException('LibreNMS\Exceptions\AuthenticationException');
$a->authenticate(null, null);
}
// Test some missing attributes
public function testNoAttribute()
{
global $config;
$this->basicConfig();
$a = Auth::reset();
$this->basicEnvironmentEnv();
unset($_SERVER['displayName']);
unset($_SERVER['mail']);
$this->assertTrue($a->authenticate($this->makeBreakUser(), null));
$this->basicEnvironmentHeader();
unset($_SERVER['HTTP_DISPLAYNAME']);
unset($_SERVER['HTTP_MAIL']);
$this->assertTrue($a->authenticate($this->makeBreakUser(), null));
}
// Document the modules current behaviour, so that changes trigger test failures
public function testCapabilityFunctions()
{
$a = Auth::reset();
$this->assertFalse($a->reauthenticate(null, null));
$this->assertTrue($a->canUpdatePasswords() === 0);
$this->assertTrue($a->changePassword(null, null) === 0);
$this->assertTrue($a->canManageUsers() === 1);
$this->assertTrue($a->canUpdateUsers() === 1);
$this->assertTrue($a->authIsExternal() === 1);
}
/* Everything from here comprises of targeted tests to excercise single methods */
public function testGetExternalUserName()
{
global $config;
$this->basicConfig();
$a = Auth::reset();
$this->basicEnvironmentEnv();
$this->assertInternalType('string', $a->getExternalUsername());
// Missing
unset($_SERVER['REMOTE_USER']);
$this->assertNull($a->getExternalUsername());
$this->basicEnvironmentEnv();
// Missing pointer to attribute
unset($config['sso']['user_attr']);
$this->assertNull($a->getExternalUsername());
$this->basicEnvironmentEnv();
// Non-existant attribute
$config['sso']['user_attr'] = 'foobar';
$this->assertNull($a->getExternalUsername());
$this->basicEnvironmentEnv();
// null pointer to attribute
$config['sso']['user_attr'] = null;
$this->assertNull($a->getExternalUsername());
$this->basicEnvironmentEnv();
// null attribute
$config['sso']['user_attr'] = 'REMOTE_USER';
$_SERVER['REMOTE_USER'] = null;
$this->assertNull($a->getExternalUsername());
}
public function testGetAttr()
{
global $config;
$a = Auth::reset();
$_SERVER['HTTP_VALID_ATTR'] = 'string';
$_SERVER['alsoVALID-ATTR'] = 'otherstring';
$config['sso']['mode'] = 'env';
$this->assertNull($a->authSSOGetAttr('foobar'));
$this->assertNull($a->authSSOGetAttr(null));
$this->assertNull($a->authSSOGetAttr(1));
$this->assertInternalType('string', $a->authSSOGetAttr('alsoVALID-ATTR'));
$this->assertInternalType('string', $a->authSSOGetAttr('HTTP_VALID_ATTR'));
$config['sso']['mode'] = 'header';
$this->assertNull($a->authSSOGetAttr('foobar'));
$this->assertNull($a->authSSOGetAttr(null));
$this->assertNull($a->authSSOGetAttr(1));
$this->assertNull($a->authSSOGetAttr('alsoVALID-ATTR'));
$this->assertInternalType('string', $a->authSSOGetAttr('VALID-ATTR'));
}
public function testTrustedProxies()
{
global $config;
$a = Auth::reset();
$config['sso']['trusted_proxies'] = array('127.0.0.1', '::1', '2001:630:50::/48', '8.8.8.0/25');
// v4 valid CIDR
$_SERVER['REMOTE_ADDR'] = '8.8.8.8';
$this->assertTrue($a->authSSOProxyTrusted());
// v4 valid single
$_SERVER['REMOTE_ADDR'] = '127.0.0.1';
$this->assertTrue($a->authSSOProxyTrusted());
// v4 invalid CIDR
$_SERVER['REMOTE_ADDR'] = '9.8.8.8';
$this->assertFalse($a->authSSOProxyTrusted());
// v6 valid CIDR
$_SERVER['REMOTE_ADDR'] = '2001:630:50:baad:beef:feed:face:cafe';
$this->assertTrue($a->authSSOProxyTrusted());
// v6 valid single
$_SERVER['REMOTE_ADDR'] = '::1';
$this->assertTrue($a->authSSOProxyTrusted());
// v6 invalid CIDR
$_SERVER['REMOTE_ADDR'] = '2600::';
$this->assertFalse($a->authSSOProxyTrusted());
// Not an IP
$_SERVER['REMOTE_ADDR'] = 16;
$this->assertFalse($a->authSSOProxyTrusted());
//null
$_SERVER['REMOTE_ADDR'] = null;
$this->assertFalse($a->authSSOProxyTrusted());
// Invalid String
$_SERVER['REMOTE_ADDR'] = 'Not an IP address at all, but maybe PHP will end up type juggling somehow';
$this->assertFalse($a->authSSOProxyTrusted());
// Not a list
$config['sso']['trusted_proxies'] = '8.8.8.0/25';
$_SERVER['REMOTE_ADDR'] = '8.8.8.8';
$this->assertFalse($a->authSSOProxyTrusted());
// Unset
unset($_SERVER['REMOTE_ADDR']);
$this->assertFalse($a->authSSOProxyTrusted());
}
public function testLevelCaulculationFromAttr()
{
global $config;
$a = Auth::reset();
$config['sso']['mode'] = 'env';
$config['sso']['group_strategy'] = 'attribute';
//Integer
$config['sso']['level_attr'] = 'level';
$_SERVER['level'] = 9;
$this->assertTrue($a->authSSOCalculateLevel() === 9);
//String
$config['sso']['level_attr'] = 'level';
$_SERVER['level'] = "9";
$this->assertTrue($a->authSSOCalculateLevel() === 9);
//Invalid String
$config['sso']['level_attr'] = 'level';
$_SERVER['level'] = 'foobar';
$this->setExpectedException('LibreNMS\Exceptions\AuthenticationException');
$a->authSSOCalculateLevel();
//null
$config['sso']['level_attr'] = 'level';
$_SERVER['level'] = null;
$this->setExpectedException('LibreNMS\Exceptions\AuthenticationException');
$a->authSSOCalculateLevel();
//Unset pointer
unset($config['sso']['level_attr']);
$_SERVER['level'] = "9";
$this->setExpectedException('LibreNMS\Exceptions\AuthenticationException');
$a->authSSOCalculateLevel();
//Unset attr
$config['sso']['level_attr'] = 'level';
unset($_SERVER['level']);
$this->setExpectedException('LibreNMS\Exceptions\AuthenticationException');
$a->authSSOCalculateLevel();
}
public function testGroupParsing()
{
global $config;
$this->basicConfig();
$a = Auth::reset();
$this->basicEnvironmentEnv();
$config['sso']['group_strategy'] = 'map';
$config['sso']['group_delimiter'] = ';';
$config['sso']['group_attr'] = 'member';
$config['sso']['group_level_map'] = array('librenms-admins' => 10, 'librenms-readers' => 1, 'librenms-billingcontacts' => 5);
$_SERVER['member'] = "librenms-admins;librenms-readers;librenms-billingcontacts;unrelatedgroup;confluence-admins";
// Valid options
$this->assertTrue($a->authSSOParseGroups() === 10);
// No match
$_SERVER['member'] = "confluence-admins";
$this->assertTrue($a->authSSOParseGroups() === 0);
// Delimiter only
$_SERVER['member'] = ";;;;";
$this->assertTrue($a->authSSOParseGroups() === 0);
// Empty
$_SERVER['member'] = "";
$this->assertTrue($a->authSSOParseGroups() === 0);
// Null
$_SERVER['member'] = null;
$this->assertTrue($a->authSSOParseGroups() === 0);
// Unset
unset($_SERVER['member']);
$this->assertTrue($a->authSSOParseGroups() === 0);
$_SERVER['member'] = "librenms-admins;librenms-readers;librenms-billingcontacts;unrelatedgroup;confluence-admins";
// Empty
$config['sso']['group_level_map'] = array();
$this->assertTrue($a->authSSOParseGroups() === 0);
// Not associative
$config['sso']['group_level_map'] = array('foo', 'bar', 'librenms-admins');
$this->assertTrue($a->authSSOParseGroups() === 0);
// Null
$config['sso']['group_level_map'] = null;
$this->assertTrue($a->authSSOParseGroups() === 0);
// Unset
unset($config['sso']['group_level_map']);
$this->assertTrue($a->authSSOParseGroups() === 0);
// No delimiter
unset($config['sso']['group_delimiter']);
$this->assertTrue($a->authSSOParseGroups() === 0);
// Test group filtering by regex
$config['sso']['group_filter'] = "/confluence-(.*)/i";
$config['sso']['group_delimiter'] = ';';
$config['sso']['group_level_map'] = array('librenms-admins' => 10, 'librenms-readers' => 1, 'librenms-billingcontacts' => 5, 'confluence-admins' => 7);
$this->assertTrue($a->authSSOParseGroups() === 7);
// Test group filtering by empty regex
$config['sso']['group_filter'] = "";
$this->assertTrue($a->authSSOParseGroups() === 10);
// Test group filtering by null regex
$config['sso']['group_filter'] = null;
$this->assertTrue($a->authSSOParseGroups() === 10);
}
public function tearDown()
{
parent::tearDown();
global $config;
$config['auth_mechanism'] = $this->original_auth_mech;
unset($config['sso']);
$this->breakUser();
}
}