Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Add support for authenticated iframe (with JWT) #326

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thorws an error in my setup, better:
if (isset($formData['grafana_jwtEnable']) && $formData['grafana_jwtEnable']) {
?
except this it works perfectly!

$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