Skip to content

Commit 41018e3

Browse files
jbonorepintermartialblog
authored
Add support for authenticated iframe (with JWT) (#51)
* Add support for authenticated iframe (with JWT) 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 JWT token uses RSA keys, these keys are generated automatically in /etc when the user saves the configuration with jwt enabled. Co-authored-by: Jorge Boncompte <[email protected]> Co-authored-by: Emerson Pinter <[email protected]> Co-authored-by: Markus Opolka <[email protected]>
1 parent d9b0633 commit 41018e3

File tree

6 files changed

+267
-0
lines changed

6 files changed

+267
-0
lines changed

application/forms/Config/GeneralConfigForm.php

+48
Original file line numberDiff line numberDiff line change
@@ -331,5 +331,53 @@ public function createElements(array $formData)
331331
]
332332
);
333333
}
334+
335+
if (isset($formData['grafana_accessmode']) && ( $formData['grafana_accessmode'] === 'iframe' )) {
336+
$this->addElement(
337+
'checkbox',
338+
'grafana_jwtEnable',
339+
array(
340+
'label' => $this->translate('Enable JWT'),
341+
'value' => false,
342+
'description' => $this->translate('Enable JWT. Grafana host will receive the JWT token to authorize the user.'),
343+
'class' => 'autosubmit',
344+
)
345+
);
346+
if (isset($formData['grafana_jwtEnable'])) {
347+
$this->addElement(
348+
'number',
349+
'grafana_jwtExpires',
350+
array(
351+
'label' => $this->translate('JWT Expiration'),
352+
'placeholder' => 30,
353+
'description' => $this->translate('JWT Token expiration in seconds. A very short time is recommended. Default 30 seconds.'),
354+
'required' => false,
355+
'class' => 'autosubmit',
356+
)
357+
);
358+
$this->addElement(
359+
'text',
360+
'grafana_jwtIssuer',
361+
array(
362+
'placeholder' => 'https://localhost',
363+
'label' => $this->translate('JWT Issuer'),
364+
'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.'),
365+
'required' => false,
366+
'class' => 'autosubmit',
367+
)
368+
);
369+
$this->addElement(
370+
'text',
371+
'grafana_jwtUser',
372+
array(
373+
'placeholder' => 'username',
374+
'label' => $this->translate('JWT Subject (login)'),
375+
'description' => $this->translate('The username or email to be used as login. Leave empty to use the Icinga Web username.'),
376+
'required' => false,
377+
'class' => 'autosubmit',
378+
)
379+
);
380+
}
381+
}
334382
}
335383
}

doc/08-config-jwt.md

+70
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# JWT Configuration
2+
3+
JWT is used to send a signed token to Grafana, so the graphs only load if the JWT token is validated by Grafana. If the token is expired or not valid, Grafana will redirect the iframe to the login page.
4+
5+
### Icinga configuration
6+
In the Icinga Web configuration:
7+
8+
1. Change "Grafana access" to "iFrame" and "Enable JWT"
9+
10+
2. Choose an expiration, issuer and user.
11+
- A short expiration is recommended, because the token is being sent in the URL.
12+
- Set an issuer, for a better validation. Must be set the same on both sides. The default is empty, no issuer.
13+
- Use an existing Grafana username so the graphs are accessed using that user.
14+
15+
3. When you save the configuration, the RSA keys will be created at /etc/icingaweb2/modules/grafana/ (jwt.key.priv and jwt.key.pub).
16+
- For now, other directories are not supported, the filenames are hard coded in the file library/Grafana/Helpers/JwtToken.php.
17+
- 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.
18+
19+
4. The private key (jwt.key.priv), should kept safe, Grafana server only needs the public key. If you have multiple Icinga Web servers, copy the keys to the other servers.
20+
21+
```
22+
openssl genrsa -out /etc/icingaweb2/modules/grafana/jwt.key.priv 2048
23+
24+
openssl rsa -in /etc/icingaweb2/modules/grafana/jwt.key.priv -pubout -outform PEM -out /etc/icingaweb2/modules/grafana/jwt.key.pub
25+
```
26+
27+
### Grafana
28+
29+
The configuration options for Grafana JWT Auth can be found here: [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/).
30+
31+
Example `grafana.ini`:
32+
33+
```
34+
[auth.jwt]
35+
# By default, auth.jwt is disabled.
36+
enabled = true
37+
38+
# HTTP header to look into to get a JWT token.
39+
header_name = X-JWT-Assertion
40+
41+
# Specify a claim to use as a username to sign in.
42+
username_claim = sub
43+
44+
# Specify a claim to use as an email to sign in.
45+
email_claim = sub
46+
47+
# enable JWT authentication in the URL
48+
url_login = true
49+
50+
# PEM-encoded key file in PKIX, PKCS #1, PKCS #8 or SEC 1 format.
51+
key_file = /etc/grafana/icinga.pem
52+
53+
# This can be seen as a required "subset" of a JWT Claims Set.
54+
# expect_claims = {"iss": "https://icinga.yourdomain"}
55+
56+
# role_attribute_path = contains(roles[*], 'admin') && 'Admin' || contains(roles[*], 'editor') && 'Editor' || 'Viewer'
57+
58+
# 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.
59+
skip_org_role_sync = true
60+
```
61+
62+
1. Read the docs, and configure your grafana.ini
63+
64+
2. Copy the **public key** from Icinga (/etc/icingaweb2/modules/grafana/jwt.key.pub) to the path configured in "key_file".
65+
66+
3. Enable url_login, header_name and username_claim/email_claim these options are required.
67+
68+
4. Enable allow_embedding in the security section.
69+
70+
5. Restart Grafana

library/Grafana/Helpers/JwtToken.php

+108
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
<?php
2+
3+
namespace Icinga\Module\Grafana\Helpers;
4+
5+
use OpenSSLAsymmetricKey;
6+
use InvalidArgumentException;
7+
use RuntimeException;
8+
9+
class JwtToken
10+
{
11+
const RSA_KEY_BITS = 2048;
12+
const JWT_PRIVATEKEY_FILE = '/etc/icingaweb2/modules/grafana/jwt.key.priv';
13+
const JWT_PUBLICKEY_FILE = '/etc/icingaweb2/modules/grafana/jwt.key.pub';
14+
15+
/**
16+
* Create JWT Token
17+
*/
18+
public static function create(string $sub, int $exp = 0, string $iss = null, array $claims = null): string
19+
{
20+
$privateKeyFile = JwtToken::JWT_PRIVATEKEY_FILE;
21+
22+
$privateKey = openssl_pkey_get_private(
23+
file_get_contents($privateKeyFile),
24+
);
25+
26+
$payload = [
27+
'sub' => $sub,
28+
'iat' => time(),
29+
'nbf' => time(),
30+
];
31+
32+
if (isset($claims)) {
33+
$payload = array_merge($payload, $claims);
34+
}
35+
36+
if (!empty($iss)) {
37+
$payload['iss'] = $iss;
38+
}
39+
40+
return JwtToken::encode($payload, $privateKey, 'RS256', $exp);
41+
}
42+
43+
/**
44+
* Generate Private and Public RSA Keys
45+
*/
46+
public static function generateRsaKeys()
47+
{
48+
$ret = file_exists(JwtToken::JWT_PRIVATEKEY_FILE);
49+
if ($ret) {
50+
return;
51+
}
52+
53+
$config = array(
54+
"private_key_bits" => JwtToken::RSA_KEY_BITS,
55+
"private_key_type" => OPENSSL_KEYTYPE_RSA,
56+
);
57+
58+
$res = openssl_pkey_new($config);
59+
openssl_pkey_export($res, $privKey);
60+
$pubKey = openssl_pkey_get_details($res);
61+
$pubKey = $pubKey["key"];
62+
63+
file_put_contents(JwtToken::JWT_PRIVATEKEY_FILE, $privKey);
64+
file_put_contents(JwtToken::JWT_PUBLICKEY_FILE, $pubKey);
65+
}
66+
67+
private static function encode(array $payload, OpenSSLAsymmetricKey $privateKey, string $algorithm = 'RS256', int $expiration = 3600): string
68+
{
69+
// Verify that the algorithm is compatible with asymmetric keys
70+
if ($algorithm !== 'RS256' && $algorithm !== 'RS512') {
71+
throw new InvalidArgumentException("Unsupported algorithm for assymmetric keys: $algorithm");
72+
}
73+
74+
// Define the JWT header
75+
$header = json_encode([
76+
'alg' => $algorithm,
77+
'typ' => 'JWT'
78+
]);
79+
80+
// Add expiration time to the payload
81+
if ($expiration > 0) {
82+
$payload['exp'] = time() + $expiration;
83+
}
84+
85+
// Encode header and payload to base64 URL
86+
$base64Header = JwtToken::base64UrlEncode($header);
87+
$base64Payload = JwtToken::base64UrlEncode(json_encode($payload));
88+
89+
// Create the signature
90+
$dataToSign = "$base64Header.$base64Payload";
91+
$signature = '';
92+
$success = openssl_sign($dataToSign, $signature, $privateKey, OPENSSL_ALGO_SHA256);
93+
if (!$success) {
94+
throw new RuntimeException("Failed to sign the JWT with the private key.");
95+
}
96+
97+
// Encode signature to base64 URL
98+
$base64Signature = JwtToken::base64UrlEncode($signature);
99+
100+
// Return the complete token
101+
return "$base64Header.$base64Payload.$base64Signature";
102+
}
103+
104+
private static function base64UrlEncode(string $data): string
105+
{
106+
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
107+
}
108+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
namespace Icinga\Module\Grafana\ProvidedHook\Icingadb;
4+
5+
use Icinga\Application\Hook\ConfigFormEventsHook;
6+
use Icinga\Module\Grafana\Forms\Config\GeneralConfigForm;
7+
use Icinga\Web\Form;
8+
use Icinga\Module\Grafana\Helpers\JwtToken;
9+
10+
class GeneralConfigFormHook extends ConfigFormEventsHook
11+
{
12+
13+
public function appliesTo(Form $form)
14+
{
15+
return $form instanceof GeneralConfigForm;
16+
}
17+
18+
public function onSuccess(Form $form)
19+
{
20+
if ($form->getElement('grafana_jwtEnable')->getValue()) {
21+
JwtToken::generateRsaKeys();
22+
}
23+
}
24+
}

library/Grafana/ProvidedHook/Icingadb/IcingaDbGrapher.php

+15
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
use ipl\Web\Url;
2727
use ipl\Web\Widget\Icon;
2828
use ipl\Web\Widget\Link;
29+
use Icinga\Module\Grafana\Helpers\JwtToken;
2930

3031
/**
3132
* IcingaDbGrapher contains methods for retrieving and rendering the data from Grafana
@@ -81,6 +82,10 @@ trait IcingaDbGrapher
8182
protected $orgId;
8283
protected $customVars;
8384
protected $pngUrl;
85+
protected $jwtIssuer = "https://localhost";
86+
protected $jwtEnable = false;
87+
protected $jwtUser;
88+
protected $jwtExpires = 30;
8489

8590
protected function init()
8691
{
@@ -163,6 +168,11 @@ protected function init()
163168
$this->auth = "";
164169
}
165170
}
171+
172+
$this->jwtIssuer = $this->config->get('jwtIssuer');
173+
$this->jwtEnable = $this->config->get('jwtEnable', $this->jwtEnable);
174+
$this->jwtExpires = $this->config->get('jwtExpires', $this->jwtExpires);
175+
$this->jwtUser = $this->config->get('jwtUser', $this->permission->getUser()->getUsername());
166176
}
167177

168178
public function has(Model $object): bool
@@ -300,6 +310,11 @@ private function getMyPreviewHtml($serviceName, $hostName, HtmlDocument $preview
300310
urlencode($this->timerangeto)
301311
);
302312

313+
if ($this->jwtEnable) {
314+
$authToken = JwtToken::create($this->jwtUser, $this->jwtExpires, !empty($this->jwtIssuer) ? $this->jwtIssuer:null, [ 'roles' => [ 'Viewer' ] ]);
315+
$iFramesrc .= sprintf("&auth_token=%s", urlencode($authToken));
316+
}
317+
303318
$iframeHtml = Html::tag(
304319
'iframe',
305320
[

run.php

+2
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
<?php
22

33
use Icinga\Module\Grafana\ProvidedHook\Icingadb\IcingadbSupport;
4+
use Icinga\Module\Grafana\ProvidedHook\Icingadb\GeneralConfigFormHook;
45

56
$this->provideHook('icingadb/HostActions');
67
$this->provideHook('icingadb/IcingadbSupport');
78
$this->provideHook('icingadb/HostDetailExtension');
89
$this->provideHook('icingadb/ServiceDetailExtension');
10+
$this->provideHook('ConfigFormEvents', GeneralConfigFormHook::class);

0 commit comments

Comments
 (0)