Remember me (#4112)

Co-authored-by: Sukhwinder Dhillon <sukhwinder.dhillon@icinga.com>
This commit is contained in:
Eric Lippmann 2021-05-21 15:43:06 +02:00 committed by GitHub
parent 577e47142e
commit 68acf12407
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 1278 additions and 4 deletions

View File

@ -32,7 +32,15 @@ class AccountController extends Controller
'title' => $this->translate('List and configure your own navigation items'),
'label' => $this->translate('Navigation'),
'url' => 'navigation'
));
))
->add(
'devices',
array(
'title' => $this->translate('List of devices you are logged in'),
'label' => $this->translate('My Devices'),
'url' => 'my-devices'
)
);
}
/**

View File

@ -7,6 +7,7 @@ use Icinga\Forms\AcknowledgeApplicationStateMessageForm;
use Icinga\Web\Announcement\AnnouncementCookie;
use Icinga\Web\Announcement\AnnouncementIniRepository;
use Icinga\Web\Controller;
use Icinga\Web\RememberMe;
use Icinga\Web\Session;
use Icinga\Web\Widget;
@ -65,6 +66,8 @@ class ApplicationStateController extends Controller
}
}
}
RememberMe::removeExpired();
}
public function summaryAction()

View File

@ -5,16 +5,23 @@ namespace Icinga\Controllers;
use Icinga\Application\Hook\AuthenticationHook;
use Icinga\Application\Icinga;
use Icinga\Application\Logger;
use Icinga\Common\Database;
use Icinga\Exception\AuthenticationException;
use Icinga\Forms\Authentication\LoginForm;
use Icinga\Web\Controller;
use Icinga\Web\Helper\CookieHelper;
use Icinga\Web\RememberMe;
use Icinga\Web\Url;
use RuntimeException;
/**
* Application wide controller for authentication
*/
class AuthenticationController extends Controller
{
use Database;
/**
* {@inheritdoc}
*/
@ -35,11 +42,33 @@ class AuthenticationController extends Controller
$this->redirectNow(Url::fromPath('setup'));
}
$form = new LoginForm();
if (RememberMe::hasCookie() && $this->hasDb()) {
$authenticated = false;
try {
$rememberMeOld = RememberMe::fromCookie();
$authenticated = $rememberMeOld->authenticate();
if ($authenticated) {
$rememberMe = $rememberMeOld->renew();
$this->getResponse()->setCookie($rememberMe->getCookie());
$rememberMe->persist($rememberMeOld->getAesCrypt()->getIv());
}
} catch (RuntimeException $e) {
Logger::error("Can't authenticate user via remember me cookie: %s", $e->getMessage());
} catch (AuthenticationException $e) {
Logger::error($e);
}
if (! $authenticated) {
$this->getResponse()->setCookie(RememberMe::forget());
}
}
if ($this->Auth()->isAuthenticated()) {
// Call provided AuthenticationHook(s) when login action is called
// but icinga web user is already authenticated
AuthenticationHook::triggerLogin($this->Auth()->getUser());
$this->redirectNow($form->getRedirectUrl());
$this->redirectNow($this->params->get('redirect', $form->getRedirectUrl()));
}
if (! $requiresSetup) {
$cookies = new CookieHelper($this->getRequest());
@ -77,6 +106,16 @@ class AuthenticationController extends Controller
$this->view->layout()->setLayout('external-logout');
$this->getResponse()->setHttpResponseCode(401);
} else {
if (RememberMe::hasCookie() && $this->hasDb()) {
try {
(new RememberMe())->remove(RememberMe::fromCookie()->getAesCrypt()->getIV());
} catch (RuntimeException $e) {
// pass
}
$this->getResponse()->setCookie(RememberMe::forget());
}
$this->redirectToLogin();
}
}

View File

@ -0,0 +1,84 @@
<?php
/* Icinga Web 2 | (c) 2021 Icinga GmbH | GPLv2+ */
namespace Icinga\Controllers;
use Icinga\Common\Database;
use Icinga\Web\Notification;
use Icinga\Web\RememberMe;
use Icinga\Web\RememberMeUserList;
use Icinga\Web\RememberMeUserDevicesList;
use ipl\Web\Compat\CompatController;
use ipl\Web\Url;
/**
* ManageUserDevicesController
*
* you need 'application/sessions' permission to use this controller
*/
class ManageUserDevicesController extends CompatController
{
use Database;
public function init()
{
$this->assertPermission('application/sessions');
}
public function indexAction()
{
$this->getTabs()
->add(
'manage-user-devices',
array(
'title' => $this->translate('List of users who stay logged in'),
'label' => $this->translate('Users'),
'url' => 'manage-user-devices',
'data-base-target' => '_self'
)
)->activate('manage-user-devices');
$usersList = (new RememberMeUserList())
->setUsers(RememberMe::getAllUser())
->setUrl('manage-user-devices/devices');
$this->addContent($usersList);
if (! $this->hasDb()) {
Notification::warning(
$this->translate("Users can't stay logged in without a database configuration backend")
);
}
}
public function devicesAction()
{
$this->getTabs()
->add(
'manage-devices',
array(
'title' => $this->translate('List of devices'),
'label' => $this->translate('Devices'),
'url' => 'manage-user-devices/devices'
)
)->activate('manage-devices');
$name = $this->params->getRequired('name');
$data = (new RememberMeUserDevicesList())
->setDevicesList(RememberMe::getAllByUsername($name))
->setUsername($name)
->setUrl('manage-user-devices/delete');
$this->addContent($data);
}
public function deleteAction()
{
(new RememberMe())->removeSpecific($this->params->getRequired('fingerprint'));
$this->redirectNow(
Url::fromPath('manage-user-devices/devices')
->addParams(['name' => $this->params->getRequired('name')])
);
}
}

View File

@ -0,0 +1,74 @@
<?php
/* Icinga Web 2 | (c) 2021 Icinga GmbH | GPLv2+ */
namespace Icinga\Controllers;
use Icinga\Common\Database;
use Icinga\Web\Notification;
use Icinga\Web\RememberMe;
use Icinga\Web\RememberMeUserDevicesList;
use ipl\Web\Compat\CompatController;
/**
* MyDevicesController
*
* this controller shows you all the devices you are logged in
*/
class MyDevicesController extends CompatController
{
use Database;
public function init()
{
$this->getTabs()
->add(
'account',
array(
'title' => $this->translate('Update your account'),
'label' => $this->translate('My Account'),
'url' => 'account'
)
)
->add(
'navigation',
array(
'title' => $this->translate('List and configure your own navigation items'),
'label' => $this->translate('Navigation'),
'url' => 'navigation'
)
)
->add(
'devices',
array(
'title' => $this->translate('List of devices you are logged in'),
'label' => $this->translate('My Devices'),
'url' => 'my-devices'
)
)->activate('devices');
}
public function indexAction()
{
$name = $this->auth->getUser()->getUsername();
$data = (new RememberMeUserDevicesList())
->setDevicesList(RememberMe::getAllByUsername($name))
->setUsername($name)
->setUrl('my-devices/delete');
$this->addContent($data);
if (! $this->hasDb()) {
Notification::warning(
$this->translate("Users can't stay logged in without a database configuration backend")
);
}
}
public function deleteAction()
{
(new RememberMe())->removeSpecific($this->params->getRequired('fingerprint'));
$this->redirectNow('my-devices');
}
}

View File

@ -145,6 +145,14 @@ class NavigationController extends Controller
'label' => $this->translate('Navigation'),
'url' => 'navigation'
)
)
->add(
'devices',
array(
'title' => $this->translate('List of devices you are logged in'),
'label' => $this->translate('My Devices'),
'url' => 'my-devices'
)
);
$this->setupSortControl(
array(

View File

@ -7,8 +7,10 @@ use Icinga\Application\Config;
use Icinga\Application\Hook\AuthenticationHook;
use Icinga\Authentication\Auth;
use Icinga\Authentication\User\ExternalBackend;
use Icinga\Common\Database;
use Icinga\User;
use Icinga\Web\Form;
use Icinga\Web\RememberMe;
use Icinga\Web\Url;
/**
@ -16,6 +18,8 @@ use Icinga\Web\Url;
*/
class LoginForm extends Form
{
use Database;
const DEFAULT_CLASSES = 'icinga-controls';
/**
@ -60,6 +64,19 @@ class LoginForm extends Form
'class' => isset($formData['username']) ? 'autofocus' : ''
)
);
$this->addElement(
'checkbox',
'rememberme',
[
'label' => $this->translate('Stay logged in'),
]
);
if (! $this->hasDb()) {
$this->getElement('rememberme')
->setAttrib('disabled', true)
->setAttrib('title', "You can't stay logged in without a database configuration backend");
}
$this->addElement(
'hidden',
'redirect',
@ -100,6 +117,12 @@ class LoginForm extends Form
$authenticated = $authChain->authenticate($user, $password);
if ($authenticated) {
$auth->setAuthenticated($user);
if ($this->getElement('rememberme')->isChecked()) {
$rememberMe = RememberMe::fromCredentials($user->getUsername(), $password);
$this->getResponse()->setCookie($rememberMe->getCookie());
$rememberMe->persist();
}
// Call provided AuthenticationHook(s) after successful login
AuthenticationHook::triggerLogin($user);
$this->getResponse()->setRerenderLayout(true);

View File

@ -552,6 +552,9 @@ class RoleForm extends RepositoryForm
],
'user/share/navigation' => [
'description' => t('Allow to share navigation items')
],
'application/sessions' => [
'description' => t('Allow to manage user sessions')
]
];

View File

@ -0,0 +1,11 @@
CREATE TABLE `icingaweb_rememberme`(
id int(10) unsigned NOT NULL AUTO_INCREMENT,
username varchar(254) COLLATE utf8mb4_unicode_ci NOT NULL,
passphrase varchar(256) NOT NULL,
random_iv varchar(24) NOT NULL,
http_user_agent text NOT NULL,
expires_at timestamp NULL DEFAULT NULL,
ctime timestamp NULL DEFAULT NULL,
mtime timestamp NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;

View File

@ -40,3 +40,15 @@ CREATE TABLE `icingaweb_user_preference`(
`mtime` timestamp NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`username`,`section`,`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
CREATE TABLE `icingaweb_rememberme`(
id int(10) unsigned NOT NULL AUTO_INCREMENT,
username varchar(254) COLLATE utf8mb4_unicode_ci NOT NULL,
passphrase varchar(256) NOT NULL,
random_iv varchar(24) NOT NULL,
http_user_agent text NOT NULL,
expires_at timestamp NULL DEFAULT NULL,
ctime timestamp NULL DEFAULT NULL,
mtime timestamp NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;

View File

@ -0,0 +1,16 @@
CREATE TABLE "icingaweb_rememberme" (
"id" serial,
"username" character varying(254) NOT NULL,
"passphrase" character varying(256) NOT NULL,
"random_iv" character varying(24) NOT NULL,
"http_user_agent" text NOT NULL,
"expires_at" timestamp NULL DEFAULT NULL,
"ctime" timestamp NULL DEFAULT NULL,
"mtime" timestamp NULL DEFAULT NULL
);
ALTER TABLE ONLY "icingaweb_rememberme"
ADD CONSTRAINT pk_icingaweb_rememberme
PRIMARY KEY (
"id"
);

View File

@ -100,3 +100,20 @@ CREATE UNIQUE INDEX idx_icingaweb_user_preference
lower((section)::text),
lower((name)::text)
);
CREATE TABLE "icingaweb_rememberme" (
"id" serial,
"username" character varying(254) NOT NULL,
"passphrase" character varying(256) NOT NULL,
"random_iv" character varying(24) NOT NULL,
"http_user_agent" text NOT NULL,
"expires_at" timestamp NULL DEFAULT NULL,
"ctime" timestamp NULL DEFAULT NULL,
"mtime" timestamp NULL DEFAULT NULL
);
ALTER TABLE ONLY "icingaweb_rememberme"
ADD CONSTRAINT pk_icingaweb_rememberme
PRIMARY KEY (
"id"
);

View File

@ -7,23 +7,47 @@ use Icinga\Application\Config as IcingaConfig;
use Icinga\Data\ResourceFactory;
use ipl\Sql\Config as SqlConfig;
use ipl\Sql\Connection;
use LogicException;
use PDO;
/**
* Trait for accessing the Icinga Web database
*/
trait Database
{
/**
* Get a connection to the Icinga Web database
*
* @return Connection
*
* @throws \Icinga\Exception\ConfigurationError
*/
protected function getDb()
{
if (! $this->hasDb()) {
throw new LogicException('Please check if a db instance exists at all');
}
$config = new SqlConfig(ResourceFactory::getResourceConfig(
IcingaConfig::app()->get('global', 'config_resource')
));
$config->options = [
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_OBJ,
PDO::MYSQL_ATTR_INIT_COMMAND => "SET SESSION SQL_MODE='STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE"
. ",ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION'"
];
$conn = new Connection($config);
return new Connection($config);
}
return $conn;
/**
* Check if db exists
*
* @return bool true if a database was found otherwise false
*/
protected function hasDb()
{
return (bool) IcingaConfig::app()->get('global', 'config_resource');
}
}

View File

@ -0,0 +1,220 @@
<?php
/* Icinga Web 2 | (c) 2021 Icinga GmbH | GPLv2+ */
namespace Icinga\Crypt;
use UnexpectedValueException;
use RuntimeException;
/**
* Data encryption and decryption using symmetric algorithm
*
* # Example Usage
*
* ```php
*
* // Encryption
* $encryptedData = new AesCrypt()->encrypt($data); // Accepts a string
*
*
* // Encrypt and encode to Base64
* $encryptedData = (new AesCrypt())->encryptToBase64($data); // Accepts a string
*
*
* // Decryption
* $aesCrypt = (new AesCrypt())
* ->setTag($tag)
* ->setIV($iv)
* ->setKey($key);
*
* $decryptedData = $aesCrypt->decrypt($data);
*
* // Decode from Base64 and decrypt
* $aesCrypt = (new AesCrypt())
* ->setTag($tag)
* ->setIV($iv)
* ->setKey($key);
*
* $decryptedData = $aesCrypt->->decryptFromBase64($data);
* ```
*
*/
class AesCrypt
{
/** @var string The encryption key */
private $key;
/** @var string The initialization vector which is not NULL */
private $iv;
/** @var string The authentication tag which is passed by reference when using AEAD cipher mode */
private $tag;
/** @var string The cipher method */
private $method = 'aes-128-gcm';
public function __construct($random_bytes_len = 128)
{
$len = openssl_cipher_iv_length($this->method);
$this->iv = random_bytes($len);
$this->key = random_bytes($random_bytes_len);
}
/**
* Set the key
*
* @return $this
*/
public function setKey($key)
{
$this->key = $key;
return $this;
}
/**
* Get the key
*
* @return string
*
* @throws RuntimeException If the key is not set
*/
public function getKey()
{
if (empty($this->key)) {
throw new RuntimeException('No key set');
}
return $this->key;
}
/**
* Set the IV
*
* @return $this
*/
public function setIV($iv)
{
$this->iv = $iv;
return $this;
}
/**
* Get the IV
*
* @return string
*
* @throws RuntimeException If the IV is not set
*/
public function getIV()
{
if (empty($this->iv)) {
throw new RuntimeException('No iv set');
}
return $this->iv;
}
/**
* Set the Tag
*
* @return $this
*/
public function setTag($tag)
{
if (strlen($tag) !== 16) {
throw new UnexpectedValueException(sprintf(
'expects tag length to be 16, got instead %s',
strlen($tag)
));
}
$this->tag = $tag;
return $this;
}
/**
* Get the Tag
*
* @return string
*
* @throws RuntimeException If the Tag is not set
*/
public function getTag()
{
if (empty($this->tag)) {
throw new RuntimeException('No tag set');
}
return $this->tag;
}
/**
* Decrypt the given data using the key, iv and tag
*
* @param string $data
*
* @return string
*
* @throws RuntimeException If decryption fails
*/
public function decrypt($data)
{
$decrypt = openssl_decrypt($data, $this->method, $this->getKey(), 0, $this->getIV(), $this->getTag());
if (is_bool($decrypt) && $decrypt === false) {
throw new RuntimeException('Decryption failed');
}
return $decrypt;
}
/**
* Decode from Base64 and decrypt the given data using the key, iv and tag
*
* @param string $data
*
* @return string decrypted data
*
* @throws RuntimeException If decryption fails
*/
public function decryptFromBase64($data)
{
return $this->decrypt(base64_decode($data));
}
/**
* Encrypt the given data using the key, iv and tag
*
* @param string $data
*
* @return string encrypted data
*
* @throws RuntimeException If decryption fails
*/
public function encrypt($data)
{
$encrypt = openssl_encrypt($data, $this->method, $this->getkey(), 0, $this->getIV(), $this->tag);
if (is_bool($encrypt) && $encrypt === false) {
throw new RuntimeException('Encryption failed');
}
return $encrypt;
}
/**
* Encrypt the given string using the the key, iv, tag and encode to Base64
*
* @param string $data
*
* @return string encrypted and encoded to Base64 data
*
* @throws RuntimeException If encryption fails
*/
public function encryptToBase64($data)
{
return base64_encode($this->encrypt($data));
}
}

View File

@ -262,4 +262,38 @@ class Cookie
$this->value = $value;
return $this;
}
/**
* Create invalidation cookie
*
* This method clones the current cookie and sets its value to null and expire time to 1.
* That way, the cookie removes itself when it has been sent to and processed by the client.
*
* We're cloning the current cookie in order to meet the [RFC6265 spec](https://tools.ietf.org/search/rfc6265)
* regarding the `Path` and `Domain` attribute:
*
* > Finally, to remove a cookie, the server returns a Set-Cookie header with an expiration date in the past.
* > The server will be successful in removing the cookie only if the Path and the Domain attribute in the
* > Set-Cookie header match the values used when the cookie was created.
*
* Note that the cookie has to be sent to the client.
*
* # Example Usage
*
* ```php
* $response->setCookie(
* $cookie->forgetMe()
* );
* ```
*
* @return static
*/
public function forgetMe()
{
$forgetMe = clone $this;
return $forgetMe
->setValue(null)
->setExpire(1);
}
}

View File

@ -62,6 +62,14 @@ class Menu extends Navigation
'label' => t('Announcements'),
'url' => 'announcements',
'priority' => 720
],
'sessions' => [
'icon' => 'host',
'description' => t('List of users who stay logged in'),
'label' => t('User Sessions'),
'permission' => 'application/sessions',
'url' => 'manage-user-devices',
'priority' => 730
]
]
]);

View File

@ -0,0 +1,335 @@
<?php
/* Icinga Web 2 | (c) 2021 Icinga GmbH | GPLv2+ */
namespace Icinga\Web;
use Icinga\Application\Config;
use Icinga\Authentication\Auth;
use Icinga\Crypt\AesCrypt;
use Icinga\Common\Database;
use Icinga\User;
use ipl\Sql\Expression;
use ipl\Sql\Select;
use RuntimeException;
/**
* Remember me component
*
* Retains credentials for 30 days by default in order to stay signed in even after the session is closed.
*/
class RememberMe
{
use Database;
/** @var string Cookie name */
const COOKIE = 'icingaweb2-remember-me';
/** @var string Database table name */
const TABLE = 'icingaweb_rememberme';
/** @var string Encrypted password of the user */
protected $encryptedPassword;
/** @var string */
protected $username;
/** @var AesCrypt Instance for encrypting/decrypting the credentials */
protected $aesCrypt;
/** @var int Timestamp when the remember me cookie expires */
protected $expiresAt;
/**
* Get whether the remember cookie is set
*
* @return bool
*/
public static function hasCookie()
{
return isset($_COOKIE[static::COOKIE]);
}
/**
* Unset the remember me cookie from PHP's `$_COOKIE` superglobal and return the invalidation cookie
*
* @return Cookie Cookie which has to be sent to client in oder to remove the remember me cookie
*/
public static function forget()
{
unset($_COOKIE[static::COOKIE]);
return (new Cookie(static::COOKIE))
->setHttpOnly(true)
->forgetMe();
}
/**
* Create the remember me component from the remember me cookie
*
* @return static
*/
public static function fromCookie()
{
$data = explode('|', $_COOKIE[static::COOKIE]);
$iv = base64_decode(array_pop($data));
$tag = base64_decode(array_pop($data));
$select = (new Select())
->from(static::TABLE)
->columns('*')
->where(['random_iv = ?' => bin2hex($iv)]);
$rememberMe = new static();
$rs = $rememberMe->getDb()->select($select)->fetch();
if (! $rs) {
throw new RuntimeException(sprintf(
"No database entry found for IV '%s'",
bin2hex($iv)
));
}
$rememberMe->aesCrypt = (new AesCrypt())
->setKey(hex2bin($rs->passphrase))
->setTag($tag)
->setIV($iv);
$rememberMe->username = $rs->username;
$rememberMe->encryptedPassword = $data[0];
return $rememberMe;
}
/**
* Create the remember me component from the given username and password
*
* @param string $username
* @param string $password
*
* @return static
*/
public static function fromCredentials($username, $password)
{
$aesCrypt = new AesCrypt();
$rememberMe = new static();
$rememberMe->encryptedPassword = $aesCrypt->encryptToBase64($password);
$rememberMe->username = $username;
$rememberMe->aesCrypt = $aesCrypt;
return $rememberMe;
}
/**
* Remove expired remember me information from the database
*/
public static function removeExpired()
{
$rememberMe = new static();
if (! $rememberMe->hasDb()) {
return;
}
$rememberMe->getDb()->delete(static::TABLE, [
'expires_at < NOW()'
]);
}
/**
* Get the remember me cookie
*
* @return Cookie
*/
public function getCookie()
{
return (new Cookie(static::COOKIE))
->setExpire($this->getExpiresAt())
->setHttpOnly(true)
->setValue(implode('|', [
$this->encryptedPassword,
base64_encode($this->aesCrypt->getTag()),
base64_encode($this->aesCrypt->getIV()),
]));
}
/**
* Get the timestamp when the cookie expires
*
* Defaults to now plus 30 days, if not set via {@link setExpiresAt()}.
*
* @return int
*/
public function getExpiresAt()
{
if ($this->expiresAt === null) {
$this->expiresAt = time() + 60 * 60 * 24 * 30;
}
return $this->expiresAt;
}
/**
* Set the timestamp when the cookie expires
*
* @param int $expiresAt
*
* @return $this
*/
public function setExpiresAt($expiresAt)
{
$this->expiresAt = $expiresAt;
return $this;
}
/**
* Authenticate via the remember me cookie
*
* @return bool
*
* @throws \Icinga\Exception\AuthenticationException
*/
public function authenticate()
{
$password = $this->aesCrypt->decryptFromBase64($this->encryptedPassword);
$auth = Auth::getInstance();
$authChain = $auth->getAuthChain();
$authChain->setSkipExternalBackends(true);
$user = new User($this->username);
if (! $user->hasDomain()) {
$user->setDomain(Config::app()->get('authentication', 'default_domain'));
}
$authenticated = $authChain->authenticate($user, $password);
if ($authenticated) {
$auth->setAuthenticated($user);
}
return $authenticated;
}
/**
* Persist the remember me information into the database
*
* Any previous stored information is automatically removed.
*
* @param string|null $iv
*
* @return $this
*/
public function persist($iv = null)
{
if ($iv) {
$this->remove($iv);
}
$this->getDb()->insert(static::TABLE, [
'username' => $this->username,
'passphrase' => bin2hex($this->aesCrypt->getKey()),
'random_iv' => bin2hex($this->aesCrypt->getIV()),
'http_user_agent' => (new UserAgent)->getAgent(),
'expires_at' => date('Y-m-d H:i:s', $this->getExpiresAt()),
'ctime' => new Expression('NOW()'),
'mtime' => new Expression('NOW()')
]);
return $this;
}
/**
* Remove remember me information from the database
*
* @param string $iv
*
* @return $this
*/
public function remove($iv)
{
$this->getDb()->delete(static::TABLE, [
'random_iv = ?' => bin2hex($iv)
]);
return $this;
}
/**
* Create renewed remember me cookie
*
* @return static New remember me cookie which has to be sent to the client
*/
public function renew()
{
return static::fromCredentials(
$this->username,
$this->aesCrypt->decryptFromBase64($this->encryptedPassword)
);
}
/**
* Remove specific remember me information from the database
*
* @param string $username
*
* @param $iv
*
* @return $this
*/
public function removeSpecific($iv)
{
$this->getDb()->delete(static::TABLE, [
'random_iv = ?' => $iv
]);
return $this;
}
/**
* Get all users using rememberme cookie
*
* @return array
*/
public static function getAllUser()
{
$rememberMe = new static();
if (! $rememberMe->hasDb()) {
return [];
}
$select = (new Select())
->from(static::TABLE)
->columns('username')
->groupBy('username');
return $rememberMe->getDb()->select($select)->fetchAll();
}
/**
* Get all rememberme cookies of the given user
*
* @param $username
*
* @return array
*/
public static function getAllByUsername($username)
{
$rememberMe = new static();
if (! $rememberMe->hasDb()) {
return [];
}
$select = (new Select())
->from(static::TABLE)
->columns(['http_user_agent', 'random_iv'])
->where(['username = ?' => $username]);
return $rememberMe->getDb()->select($select)->fetchAll();
}
/**
* Get the encrypton/decryption instance
*
* @return AesCrypt
*/
public function getAesCrypt()
{
return $this->aesCrypt;
}
}

View File

@ -0,0 +1,144 @@
<?php
/* Icinga Web 2 | (c) 2021 Icinga GmbH | GPLv2+ */
namespace Icinga\Web;
use ipl\Html\BaseHtmlElement;
use ipl\Html\Html;
use ipl\Web\Url;
use ipl\Web\Widget\Icon;
use ipl\Web\Widget\Link;
class RememberMeUserDevicesList extends BaseHtmlElement
{
protected $tag = 'table';
protected $defaultAttributes = [
'class' => 'common-table',
'data-base-target' => '_self'
];
/**
* @var array
*/
protected $devicesList;
/**
* @var string
*/
protected $username;
/**
* @var string
*/
protected $url;
/**
* @return string
*/
public function getUrl()
{
return $this->url;
}
/**
* @param string $url
*
* @return $this
*/
public function setUrl($url)
{
$this->url = $url;
return $this;
}
/**
* @return string
*/
public function getUsername()
{
return $this->username;
}
/**
* @param string $username
*
* @return $this
*/
public function setUsername($username)
{
$this->username = $username;
return $this;
}
/**
* @return array List of devices. Each device contains user agent and fingerprint string
*/
public function getDevicesList()
{
return $this->devicesList;
}
/**
* @param $devicesList
*
* @return $this
*/
public function setDevicesList($devicesList)
{
$this->devicesList = $devicesList;
return $this;
}
protected function assemble()
{
$thead = Html::tag('thead');
$theadRow = Html::tag('tr')
->add(Html::tag(
'th',
sprintf(t('List of devices and browsers %s is currently logged in:'), $this->getUsername())
));
$thead->add($theadRow);
$head = Html::tag('tr')
->add(Html::tag('th', t('OS')))
->add(Html::tag('th', t('Browser')))
->add(Html::tag('th', t('Fingerprint')));
$thead->add($head);
$tbody = Html::tag('tbody');
if (empty($this->getDevicesList())) {
$tbody->add(Html::tag('td', t('No device found')));
} else {
foreach ($this->getDevicesList() as $device) {
$agent = new UserAgent($device);
$element = Html::tag('tr')
->add(Html::tag('td', $agent->getOs()))
->add(Html::tag('td', $agent->getBrowser()))
->add(Html::tag('td', $device->random_iv));
$link = (new Link(
new Icon('trash'),
Url::fromPath($this->getUrl())
->addParams(
[
'name' => $this->getUsername(),
'fingerprint' => $device->random_iv,
]
)
));
$element->add(Html::tag('td', $link));
$tbody->add($element);
}
}
$this->add($thead);
$this->add($tbody);
}
}

View File

@ -0,0 +1,106 @@
<?php
/* Icinga Web 2 | (c) 2021 Icinga GmbH | GPLv2+ */
namespace Icinga\Web;
use ipl\Html\BaseHtmlElement;
use ipl\Html\Html;
use ipl\Web\Url;
use ipl\Web\Widget\Link;
/**
* Class RememberMeUserList
*
* @package Icinga\Web
*/
class RememberMeUserList extends BaseHtmlElement
{
protected $tag = 'table';
protected $defaultAttributes = [
'class' => 'common-table table-row-selectable',
'data-base-target' => '_next',
];
/**
* @var array
*/
protected $users;
/**
* @var string
*/
protected $url;
/**
* @return string
*/
public function getUrl()
{
return $this->url;
}
/**
* @param string $url
*
* @return $this
*/
public function setUrl($url)
{
$this->url = $url;
return $this;
}
/**
* @return array
*/
public function getUsers()
{
return $this->users;
}
/**
* @param array $users
*
* @return $this
*/
public function setUsers($users)
{
$this->users = $users;
return $this;
}
protected function assemble()
{
$thead = Html::tag('thead');
$theadRow = Html::tag('tr')
->add(Html::tag(
'th',
t('List of users who stay logged in')
));
$thead->add($theadRow);
$tbody = Html::tag('tbody');
if (empty($this->getUsers())) {
$tbody->add(Html::tag('td', t('No user found')));
} else {
foreach ($this->getUsers() as $user) {
$element = Html::tag('tr');
$link = new Link(
$user->username,
Url::fromPath($this->getUrl())->addParams(['name' => $user->username]),
['title' => sprintf(t('Device list of %s'), $user->username)]
);
$element->add(Html::tag('td', $link));
$tbody->add($element);
}
}
$this->add($thead);
$this->add($tbody);
}
}

View File

@ -0,0 +1,86 @@
<?php
/* Icinga Web 2 | (c) 2021 Icinga GmbH | GPLv2+ */
namespace Icinga\Web;
/**
* Class UserAgent
*
* This class helps to get user agent information like OS type and browser name
*
* @package Icinga\Web
*/
class UserAgent
{
/**
* $_SERVER['HTTP_USER_AGENT'] output string
*
* @var string|null
*/
private $agent;
public function __construct($agent = null)
{
$this->agent = isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : null;
if ($agent) {
$this->agent = $agent->http_user_agent;
}
}
/**
* Return $_SERVER['HTTP_USER_AGENT'] output string of given or current device
*
* @return string
*/
public function getAgent()
{
return $this->agent;
}
/**
* Get Browser name
*
* @return string Browser name or unknown if not found
*/
public function getBrowser()
{
// key => regex value
$browsers = [
"Internet Explorer" => "/MSIE(.*)/i",
"Seamonkey" => "/Seamonkey(.*)/i",
"MS Edge" => "/Edg(.*)/i",
"Opera" => "/Opera(.*)/i",
"Opera Browser" => "/OPR(.*)/i",
"Chromium" => "/Chromium(.*)/i",
"Firefox" => "/Firefox(.*)/i",
"Google Chrome" => "/Chrome(.*)/i",
"Safari" => "/Safari(.*)/i"
];
//TODO find a way to return also the version of the browser
foreach ($browsers as $browser => $regex) {
if (preg_match($regex, $this->agent)) {
return $browser;
}
}
return 'unknown';
}
/**
* Get Operating system information
*
* @return string os information
*/
public function getOs()
{
// get string before the first appearance of ')'
$device = strstr($this->agent, ')', true);
if (! $device) {
return 'unknown';
}
// return string after the first appearance of '('
return substr($device, strpos($device, '(') + 1);
}
}

View File

@ -103,6 +103,10 @@
}
}
input[type="submit"]:focus {
outline: 3px solid fade(@icinga-blue, 50%);
}
.form-controls {
margin-bottom: 2em;
margin-top: 2em;
@ -134,6 +138,21 @@
font-weight: bold;
}
}
.control-group:nth-child(3) {
text-align: left;
margin-top: 1.25em;
> .control-label-group {
display: inline-block;
width: auto;
}
> .toggle-switch {
float: left;
margin-right: 1em;
}
}
}
#social {