Skip to content

Commit

Permalink
Add support for authenticated iframe (with JWT)
Browse files Browse the repository at this point in the history
The image renderer from Grafana is very slow and cpu heavy. Iframe is
not an option for most cases because it needs anonymous access to
Grafana. This commit adds JWT support to secure the Grafana access
when using iframe.
When a graph is loaded in Icinga web interface, the signed JWT token is
sent to Grafana in the request, if JWT is validated graph is displayed,
if anything goes wrong with the token validation, Grafana will refuse the
access.

The library Firebase PHP-JWT is used to create the token. For now, the
library is included in the vendor directory.

The JWT token uses RSA keys, these keys are generated automatically in
/etc when the user saves the configuration with jwt enabled.
  • Loading branch information
epinter committed Sep 30, 2023
1 parent 614023a commit 5f5cff8
Show file tree
Hide file tree
Showing 31 changed files with 3,551 additions and 1 deletion.
51 changes: 50 additions & 1 deletion application/forms/Config/GeneralConfigForm.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Icinga\Module\Grafana\Forms\Config;

use Icinga\Module\Grafana\Helpers\Timeranges;
use Icinga\Module\Grafana\Helpers\JwtToken;
use Icinga\Forms\ConfigForm;

class GeneralConfigForm extends ConfigForm
Expand Down Expand Up @@ -335,6 +336,54 @@ public function createElements(array $formData)
)
);
}
if (isset($formData['grafana_accessmode']) && ( $formData['grafana_accessmode'] === 'iframe' )) {
$this->addElement(
'checkbox',
'grafana_jwtEnable',
array(
'label' => $this->translate('Enable JWT'),
'value' => false,
'description' => $this->translate('Enable JWT. Grafana host will receive the JWT token to authorize the user.'),
'class' => 'autosubmit',
)
);
if ($formData['grafana_jwtEnable']) {
$this->addElement(
'number',
'grafana_jwtExpires',
array(
'label' => $this->translate('JWT Expiration'),
'placeholder' => 30,
'description' => $this->translate('JWT Token expiration in seconds. A very short time is recommended. Default 30 seconds.'),
'required' => false,
'class' => 'autosubmit',
)
);
$this->addElement(
'text',
'grafana_jwtIssuer',
array(
'placeholder' => 'https://localhost',
'label' => $this->translate('JWT Issuer'),
'description' => $this->translate('The issuer of the token (e.g. url of this system). Can be used as a validation when other systems receive the token. Default is empty, no issuer.'),
'required' => false,
'class' => 'autosubmit',
)
);
$this->addElement(
'text',
'grafana_jwtUser',
array(
'placeholder' => 'username',
'label' => $this->translate('JWT Subject (login)'),
'description' => $this->translate('The username or email to be used as login. Leave empty to use IcingaWeb username.'),
'required' => false,
'class' => 'autosubmit',
)
);
}
}

$this->addElement(
'checkbox',
'grafana_debug',
Expand All @@ -345,4 +394,4 @@ public function createElements(array $formData)
)
);
}
}
}
70 changes: 70 additions & 0 deletions doc/08-config-jwt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# JWT Configuration

JWT is used to send a signed token to Grafana, so the graphs only loads if the JWT token is validated by Grafana. If the token is expired or not validated, Grafana will redirect the iframe to the login page.

### Icinga configuration
In the Icinga configuration:

1. Change "Grafana access" to Iframe and Enable JWT

2. Choose an expiration, issuer and user.
- A low expiration is recommended, specially because the token is being sent in the url.
- Set an issuer, for a better validation. Must be set the same on both sides. The default is empty, no issuer.
- Set and existing Grafana username so the graphs open using that user.

3. When you save the configuration, the RSA keys will be created at /etc/icingaweb2/modules/grafana/ (jwt.key.priv and jwt.key.pub).
- For now, other directories are not supported, the filenames are hard coded in the file library/Grafana/Helpers/JwtToken.php.
- If any kind of errors happens while creating the keys (e.g. permission denied), you will have to create the keys and copy them to the directory /etc/icingaweb2/modules/grafana/, use the commands below.

4. The private key (jwt.key.priv), should kept safe, Grafana server only needs the public key. If you have multiple IcingaWeb servers, copy the keys to the other servers.

```
openssl genrsa -out /etc/icingaweb2/modules/grafana/jwt.key.priv 2048
openssl rsa -in /etc/icingaweb2/modules/grafana/jwt.key.priv -pubout -outform PEM -out /etc/icingaweb2/modules/grafana/jwt.key.pub
```

### Grafana

The configuration options for Grafana JWT Auth can be found at the website: [https://grafana.com/docs/grafana/latest/setup-grafana/configure-security/configure-authentication/jwt/](https://grafana.com/docs/grafana/latest/setup-grafana/configure-security/configure-authentication/jwt/).

Basic grafana.ini:

```
[auth.jwt]
# By default, auth.jwt is disabled.
enabled = true
# HTTP header to look into to get a JWT token.
header_name = X-JWT-Assertion
# Specify a claim to use as a username to sign in.
username_claim = sub
# Specify a claim to use as an email to sign in.
email_claim = sub
# enable JWT authentication in the URL
url_login = true
# PEM-encoded key file in PKIX, PKCS #1, PKCS #8 or SEC 1 format.
key_file = /etc/grafana/icinga.pem
# This can be seen as a required "subset" of a JWT Claims Set.
# expect_claims = {"iss": "https://icinga.yourdomain"}
# role_attribute_path = contains(roles[*], 'admin') && 'Admin' || contains(roles[*], 'editor') && 'Editor' || 'Viewer'
# To skip the assignment of roles and permissions upon login via JWT and handle them via other mechanisms like the user interface, we can skip the organization role synchronization with the following configuration.
skip_org_role_sync = true
```

1. Read the docs, and configure your grafana.ini

2. Copy the PUBLIC key from Icinga (/etc/icingaweb2/modules/grafana/jwt.key.pub) to the path configured in "key_file".

3. Enable url_login, header_name and username_claim/email_claim these options are required.

4. Enable allow_embedding in the security section.

5. Restart grafana
64 changes: 64 additions & 0 deletions library/Grafana/Helpers/JwtToken.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

namespace Icinga\Module\Grafana\Helpers;

use Firebase\JWT\JWT;
use Firebase\JWT\Key;

class JwtToken {
const RSA_KEY_BITS = 2048;
const JWT_PRIVATEKEY_FILE = '/etc/icingaweb2/modules/grafana/jwt.key.priv';
const JWT_PUBLICKEY_FILE = '/etc/icingaweb2/modules/grafana/jwt.key.pub';


/**
* Create JWT Token
*/
public static function create(string $sub, int $exp = 0, string $iss = null, array $claims = null) : string {
$privateKeyFile = JwtToken::JWT_PRIVATEKEY_FILE;

$privateKey = openssl_pkey_get_private(
file_get_contents($privateKeyFile),
);

$payload = [
'sub' => $sub,
'iat' => time(),
'nbf' => time(),
];

if(isset($claims)) {
$payload = array_merge($payload, $claims);
}

if (!empty($iss)) {
$payload['iss'] = $iss;
}
if ($exp > 0) {
$payload['exp'] = $exp;
}

return JWT::encode($payload, $privateKey, 'RS256');
}

/**
* Generate Private and Public RSA Keys
*/
public static function generateRsaKeys()
{
if(!file_exists(JwtToken::JWT_PRIVATEKEY_FILE)) {
$config = array(
"private_key_bits" => JwtToken::RSA_KEY_BITS,
"private_key_type" => OPENSSL_KEYTYPE_RSA,
);

$res = openssl_pkey_new($config);
openssl_pkey_export($res, $privKey);
$pubKey = openssl_pkey_get_details($res);
$pubKey = $pubKey["key"];

file_put_contents(JwtToken::JWT_PRIVATEKEY_FILE, $privKey);
file_put_contents(JwtToken::JWT_PUBLICKEY_FILE, $pubKey);
}
}
}
24 changes: 24 additions & 0 deletions library/Grafana/ProvidedHook/Icingadb/GeneralConfigFormHook.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

namespace Icinga\Module\Grafana\ProvidedHook\Icingadb;

use Icinga\Application\Hook\ConfigFormEventsHook;
use Icinga\Module\Grafana\Forms\Config\GeneralConfigForm;
use Icinga\Web\Form;
use Icinga\Module\Grafana\Helpers\JwtToken;

class GeneralConfigFormHook extends ConfigFormEventsHook
{

public function appliesTo(Form $form)
{
return $form instanceof GeneralConfigForm;
}

public function onSuccess(Form $form)
{
if($form->getElement('grafana_jwtEnable')->getValue()) {
JwtToken::generateRsaKeys();
}
}
}
15 changes: 15 additions & 0 deletions library/Grafana/ProvidedHook/Icingadb/IcingaDbGrapher.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
use ipl\Stdlib\Filter;
use ipl\Web\Url;
use ipl\Web\Widget\Link;
use Icinga\Module\Grafana\Helpers\JwtToken;

trait IcingaDbGrapher
{
Expand Down Expand Up @@ -68,6 +69,10 @@ trait IcingaDbGrapher
protected $grafanaVersion = "0";
protected $defaultdashboarduid;
protected $object;
protected $jwtIssuer = "https://localhost";
protected $jwtEnable = false;
protected $jwtUser;
protected $jwtExpires = 30;

protected function init()
{
Expand Down Expand Up @@ -177,6 +182,11 @@ protected function init()
$this->auth = "";
}
}

$this->jwtIssuer = $this->config->get('jwtIssuer');
$this->jwtEnable = $this->config->get('jwtEnable', $this->jwtEnable);
$this->jwtExpires = $this->config->get('jwtExpires', $this->jwtExpires);
$this->jwtUser = $this->config->get('jwtUser', $this->permission->getUser()->getUsername());
}

public function has(Model $object): bool
Expand Down Expand Up @@ -320,6 +330,11 @@ private function getMyPreviewHtml($serviceName, $hostName, HtmlDocument $preview
urlencode($this->timerangeto)
);

if($this->jwtEnable) {
$authToken = JwtToken::create($this->jwtUser, time()+$this->jwtExpires, !empty($this->jwtIssuer)?$this->jwtIssuer:null, [ 'roles' => [ 'Viewer' ] ]);
$iFramesrc .= sprintf("&auth_token=%s", urlencode($authToken));
}

$iframeHtml = Html::tag(
'iframe',
[
Expand Down
4 changes: 4 additions & 0 deletions run.php
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
<?php

use Icinga\Module\Grafana\ProvidedHook\Icingadb\IcingadbSupport;
use Icinga\Module\Grafana\ProvidedHook\Icingadb\GeneralConfigFormHook;

$this->provideHook('icingadb/HostActions');
$this->provideHook('icingadb/IcingadbSupport');
$this->provideHook('icingadb/HostDetailExtension');
$this->provideHook('icingadb/ServiceDetailExtension');
$this->provideHook('ConfigFormEvents', GeneralConfigFormHook::class);

require_once __DIR__ . '/vendor/autoload.php';
25 changes: 25 additions & 0 deletions vendor/autoload.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

// autoload.php @generated by Composer

if (PHP_VERSION_ID < 50600) {
if (!headers_sent()) {
header('HTTP/1.1 500 Internal Server Error');
}
$err = 'Composer 2.3.0 dropped support for autoloading on PHP <5.6 and you are running '.PHP_VERSION.', please upgrade PHP or use Composer 2.2 LTS via "composer self-update --2.2". Aborting.'.PHP_EOL;
if (!ini_get('display_errors')) {
if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
fwrite(STDERR, $err);
} elseif (!headers_sent()) {
echo $err;
}
}
trigger_error(
$err,
E_USER_ERROR
);
}

require_once __DIR__ . '/composer/autoload_real.php';

return ComposerAutoloaderInit795e51b6d89d2c6da53b5c5f25b52645::getLoader();
5 changes: 5 additions & 0 deletions vendor/composer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"require": {
"firebase/php-jwt": "^6.8"
}
}
Loading

0 comments on commit 5f5cff8

Please sign in to comment.