From 1c6b7a967f4eeeae8215936d3a5a887a94263cce Mon Sep 17 00:00:00 2001 From: Adam Bishop Date: Wed, 29 Nov 2017 02:40:17 +0000 Subject: [PATCH] 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 --- LibreNMS/Authentication/Auth.php | 12 + LibreNMS/Authentication/AuthorizerBase.php | 11 + .../Authentication/HttpAuthAuthorizer.php | 10 + LibreNMS/Authentication/SSOAuthorizer.php | 297 +++++++++++ .../Interfaces/Authentication/Authorizer.php | 16 + doc/Extensions/Authentication.md | 147 +++++ doc/General/Acknowledgement.md | 2 + html/includes/authenticate.inc.php | 9 +- includes/defaults.inc.php | 5 + tests/AuthHTTPTest.php | 89 ++++ tests/AuthSSOTest.php | 501 ++++++++++++++++++ 11 files changed, 1094 insertions(+), 5 deletions(-) create mode 100644 LibreNMS/Authentication/SSOAuthorizer.php create mode 100644 tests/AuthHTTPTest.php create mode 100644 tests/AuthSSOTest.php diff --git a/LibreNMS/Authentication/Auth.php b/LibreNMS/Authentication/Auth.php index 9400d46410..c807aa56a8 100644 --- a/LibreNMS/Authentication/Auth.php +++ b/LibreNMS/Authentication/Auth.php @@ -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(); + } } diff --git a/LibreNMS/Authentication/AuthorizerBase.php b/LibreNMS/Authentication/AuthorizerBase.php index 761f4cc08f..e72bdaf51f 100644 --- a/LibreNMS/Authentication/AuthorizerBase.php +++ b/LibreNMS/Authentication/AuthorizerBase.php @@ -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; + } } diff --git a/LibreNMS/Authentication/HttpAuthAuthorizer.php b/LibreNMS/Authentication/HttpAuthAuthorizer.php index 78c082cef6..5051cac2d9 100644 --- a/LibreNMS/Authentication/HttpAuthAuthorizer.php +++ b/LibreNMS/Authentication/HttpAuthAuthorizer.php @@ -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']); + } + } } diff --git a/LibreNMS/Authentication/SSOAuthorizer.php b/LibreNMS/Authentication/SSOAuthorizer.php new file mode 100644 index 0000000000..0f8e0e5506 --- /dev/null +++ b/LibreNMS/Authentication/SSOAuthorizer.php @@ -0,0 +1,297 @@ +. + * + * @package LibreNMS\Authentication + * @link https://librenms.org + * @copyright 2017 Adam Bishop + * @author Adam Bishop + */ + +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; + } +} diff --git a/LibreNMS/Interfaces/Authentication/Authorizer.php b/LibreNMS/Interfaces/Authentication/Authorizer.php index 1ac4317ae3..7c2738de07 100644 --- a/LibreNMS/Interfaces/Authentication/Authorizer.php +++ b/LibreNMS/Interfaces/Authentication/Authorizer.php @@ -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(); } diff --git a/doc/Extensions/Authentication.md b/doc/Extensions/Authentication.md index d14fbfc3cc..8ae6ac9943 100644 --- a/doc/Extensions/Authentication.md +++ b/doc/Extensions/Authentication.md @@ -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]; +``` diff --git a/doc/General/Acknowledgement.md b/doc/General/Acknowledgement.md index 892f36ddae..16759a26d9 100644 --- a/doc/General/Acknowledgement.md +++ b/doc/General/Acknowledgement.md @@ -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 diff --git a/html/includes/authenticate.inc.php b/html/includes/authenticate.inc.php index a957c9781d..ff25ef9fd6 100644 --- a/html/includes/authenticate.inc.php +++ b/html/includes/authenticate.inc.php @@ -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 diff --git a/includes/defaults.inc.php b/includes/defaults.inc.php index 3644426e8a..e284b0f33e 100644 --- a/includes/defaults.inc.php +++ b/includes/defaults.inc.php @@ -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; diff --git a/tests/AuthHTTPTest.php b/tests/AuthHTTPTest.php new file mode 100644 index 0000000000..75a1f36ea1 --- /dev/null +++ b/tests/AuthHTTPTest.php @@ -0,0 +1,89 @@ +. + * + * @package LibreNMS + * @link https://librenms.org + * @copyright 2017 Adam Bishop + * @author Adam Bishop + */ + +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']); + } +} diff --git a/tests/AuthSSOTest.php b/tests/AuthSSOTest.php new file mode 100644 index 0000000000..ecb1969bcb --- /dev/null +++ b/tests/AuthSSOTest.php @@ -0,0 +1,501 @@ +. + * + * @package LibreNMS + * @link https://librenms.org + * @copyright 2017 Adam Bishop + * @author Adam Bishop + */ + +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(); + } +}