grafana免密登陆嵌入后台(三种方式)

6,086 阅读4分钟

以下账号跟域名脱敏处理。curl不通正常。

需求

在后台嵌入grafana的监控链接。假设我们后台域名是houtai.yanshinian.com ,grafana是 grafana.ysn.com。

三种方式

匿名登录、api key 免登陆、模拟登录种cookie,其中匿名登录不安全,那么使用api key免登陆但是在proxy header头中,强制加了Authorization 头之后,似乎又成了另一种匿名登录了。那么模拟登录种cookie似乎显的更安全了。

匿名登录

网上就属这个资料最多了。简单来说,配置下面的参数。启动服务。由于我用的是k8s部署,配置文件在挂载了一块存储卷,重启不会丢失,所以就直接重启pod了。

[auth.anonymous]
# enable anonymous access
enabled = true
org_name = 你想要的匿名登录的组织名,后台同样需要创建一个对应的组织

api key免登陆

1.首先要先创建一个api key,下面是我展示的实例

curl -X POST -H "Content-Type: application/json" -d '{"name":"test", "role": "Admin"}'  'http://admin:admin@grafana.ysn.com/grafana/api/auth/keys'
{"id":1,"name":"test","key":"eyJrIjoiaTM4dEdrSTEwVDY0bzM2N0JYNmpidWVhd2F1ECMgciLCJuIjoidGVzdCIsImlkIjoxfQ=="}

具体的文档在官网可以看到。这里要注意的是。创建前要先选组织防止创建到了其他组织下。然后role选择 viewer,因为是匿名免登陆,当然不能给写权限。

当我们创建了一个apikey ,把这 apikey 添加到网关,比如下面的示例配置。为什么用域名grafana-api-key.ysn.com 而不是 grafana.ysn.com 呢?因为你写死了auth头,就相当于限定了一个用户了。所以需要单独用一个域名。

server { 
      listen 80;
      listen 443 ssl;
      server_name grafana-api-key.ysn.com;
      access_log /home/nginx/logs/grafana-api-key.ysn.com_access.log main;
      include /home/openresty/nginx/conf/nconf/ysnssl.conf;
      location / { 
          set $upstream grafana.ysn.com; # 这里是复用下upstream
          proxy_pass http://$upstream;
          proxy_set_header Host $host;
          proxy_set_header X-Real-IP $remote_addr;
          proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
          proxy_set_header Connection "";
          proxy_set_header Authorization "Bearer eyJrIjoiaTM4dEdrSTEwVDYVCzM2N0JYNmpidWVhd2F1MW92cEMiLCJuIjoidGVzdCIsImlkIjoxfQ=="
          proxy_redirect off;
          client_max_body_size 500m;
          client_body_buffer_size 128k;
          proxy_ignore_client_abort on;
          proxy_connect_timeout 60;
          proxy_send_timeout 60;
          proxy_read_timeout 60;
          proxy_buffer_size 128k;
          proxy_buffers 32 32k;
          proxy_busy_buffers_size 128k;
          proxy_temp_file_write_size 128k;
          proxy_next_upstream off;
          proxy_http_version 1.1;
          add_header Xes-App $upstream_http_server;
      } 
} 

重点是这行 proxy_set_header Authorization "Bearer eyJrIjoiaTM4dEdrSTEwVDYVCzM2N0JYNmpidWVhd2F1MW92cEMiLCJuIjoidGVzdCIsImlkIjoxfQ==";

种Cookie方式

首先先找到login 接口,我不知道官方文档有没有指明,我是这么找的。

image.png

上面是故意写错了账号,为了截图方便,下面复制一个成功登录的curl请求(Chrome可以复制出来curl)

curl 'https://grafana.ysn.com/grafana/login' \
  -H 'authority: grafana.ysn.com' \
  -H 'accept: application/json, text/plain, */*' \
  -H 'accept-language: zh-CN,zh;q=0.9' \
  -H 'cache-control: no-cache' \
  -H 'content-type: application/json' \
  -H 'origin: https://grafana.yanshinian.com' \
  -H 'pragma: no-cache' \
  -H 'referer: https://grafana.ysn.comm/grafana/login' \
  -H 'sec-ch-ua: "Chromium";v="104", " Not A;Brand";v="99", "Google Chrome";v="104"' \
  -H 'sec-ch-ua-mobile: ?0' \
  -H 'sec-ch-ua-platform: "macOS"' \
  -H 'sec-fetch-dest: empty' \
  -H 'sec-fetch-mode: cors' \
  -H 'sec-fetch-site: same-origin' \
  -H 'user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.0.0 Safari/537.36' \
  --data-raw '{"user":"3333","password":"3333"}' \
  --compressed

登录成功后的接口我们可以看到cookie信息 grafana_session=1bc3f73468da3633f2c18685094810ce; Path=/grafana; Max-Age=2592000; HttpOnly; SameSite=Lax。当然 curl 加上 -vvv 执行下也可以在命令行看到。

image.png

这里要注意下,种cookie 就必须是同域名或者同属一个根域名。所以需要把grafana的地址。再解析一个域名。于是我又弄了一个域名叫做 grafana.yanshinian.com 要跟 houtai.yanshinian.com 保证是一个根域名。 php代码示例(hyperf框架)

模拟登录

public function login()
{
    if (empty(env('GRAFANA_HOST'))) {
        return [];
    }
    $key = 'ysn:grafana_session_config';
    $redis = ApplicationContext::getContainer()->get(Redis::class);
    $grafanaSessionConfigResult = $redis->get($key);
    if (!empty($grafanaSessionConfigResult)) {
        return json_decode($grafanaSessionConfigResult, true);
    }

    $loginApi = 'http://'.env('GRAFANA_HOST').'/grafana/login';
    $client = new Client([
        'handler' => HandlerStack::create(new CoroutineHandler()),
        'timeout' => 3,
        'swoole' => [
            'timeout' => 3,
            'socket_buffer_size' => 1024 * 1024 * 2,
        ],
    ]);
    $headers = [
        'content-type' => 'application/json',
    ];
    $user = env('GRAFANA_USER');
    $password = env('GRAFANA_PASSWORD');

    $response = $client->post($loginApi, [
        'headers' => $headers,
        'body' => json_encode([
            'user' => $user,
            'password' => $password,
        ]),
    ]);
    if (200 === $response->getStatusCode()) {
        $cookies = $response->getHeader('set-cookie');
        $grafanaSessionConfig = [];
        foreach ($cookies as $cookie) {
            if (0 === strpos($cookie, 'grafana_session=')) {
                $cookieSegments = explode(';', $cookie);
                foreach ($cookieSegments as $segment) {
                    $segment = trim($segment);
                    $kv = explode('=', $segment);
                    if ('grafana_session' === $kv[0]) {
                        $grafanaSessionConfig['name'] = 'grafana_session';
                        $grafanaSessionConfig['value'] = $kv[1];
                    } elseif ('Max-Age' === $kv[0]) {
                        $grafanaSessionConfig['ttl'] = $kv[1];
                    } elseif ('Path' === $kv[0]) {
                        $grafanaSessionConfig['path'] = $kv[1];
                    }
                }
                break;
            }
        }
        $redis->set($key, json_encode($grafanaSessionConfig), 10);
        return $grafanaSessionConfig;
    }

    return [];
}

下发给浏览器

$cookie = new Cookie(
    $grafanaCookieConfig['name'],
    $grafanaCookieConfig['value'],
    time() + $grafanaCookieConfig['ttl'],
    $grafanaCookieConfig['path'],
    '.yanshinian.com',
);
$response = Context::get(ResponseInterface::class);
$response = $response->withCookie($cookie);
Context::set(ResponseInterface::class, $response);

上面代码注意下cookie的作用范围是.yanshinian.com 而不是 grafana.yanshinian.com那是因为不是同一个域名,域名范围设置成 grafana.yanshinian.com就种不成功。

进一步提升体验

我们发下嵌入的页面有一个按钮是可以点的。

image.png

当我点了它之后会蹦出来

image.png 当我摁了ESC又会出来,侧边栏。

image.png 那么我们怎么去掉这个按钮呢?我们能想到的是 修改grafana的html页面,理论上当然可行,比如是不是可以用sed 命令匹配替换成成空字符串? 使用JavaScript代码操作iframe中的grafana页面,理论上也可行,但是会有跨域问题。 于是选择了用JavaScript操作iframe的方式。既然会有跨域问题。也就是houtai.yanshinian.com,无法操作grafana.yanshinian.com 的页面。那么我把grafana域名搞成houtai.yanshinian.com/grafana不就可以了?于是嵌入的页面的链接变成了houtai.yanshinian.com/grafana/xxxxxxxx。然后上面cookie的作用域改成 'houtai.yanshinian.com',,这样做是缩小范围,避免线上跟测试环境cookie冲突。因为houtai-test.yanshinian.com/grafana 跟 houtai.yanshinian.com/grafana 都是.yanshinian.com cookie会冲突。

下面是删除Cycle view mode按钮代码。

     var iframe = document.getElementById('iframe');
     var iwindow = iframe.contentWindow;
     console.log(iwindow);
     var idoc = iwindow.document;
     console.log(idoc);
     var btns = idoc.getElementsByClassName('toolbar-button');
     for (var i = 0; i < btns.length; i++) {
         var btn = btns[i];

         var label = btn.getAttributeNode('aria-label');
         console.log(label.value);
         if (label.value === 'Cycle view mode') {
             console.log(btn);
             console.log('3333333')
             btn.remove();
         }
     }

但是,我想在iframe加载后删除。而且,我由于我用表单给iframe链接设置参数,就会经常加载链接,那么使用onload事件?onload 函数中执行 btn.getAttributeNode('aria-label') 是有个问题的。浏览器控制台可以打印出btns内容。但是实际上btns.length为0。这是因为页面没有完全渲染,所以就无法拿到dom。那么怎么办?目前只有一个出路了。那就是用定时器,也只能这样了。

另外还想禁止用户点击下面的标题。

image.png

下面是代码。

    function panelHeaderClickDefault (callback = function () {}) {
      var iframe = document.getElementById('iframe')
      var iwindow = iframe.contentWindow
      var idoc = iwindow.document
      var divs = idoc.getElementsByClassName('panel-header')
      if (divs.length > 0) {
        for (var i = 0; i < divs.length; i++) {
          divs[i].onclick = function (event) { // 通过这样的绑定覆盖之前的点击事件,并阻止冒泡
            event.stopPropagation()
          }
        }
        callback()
      }
    }
    // 清除 按钮
    function clearCycleViewModeBtn (callBack = function () {}) {
      var iframe = document.getElementById('iframe')
      var iwindow = iframe.contentWindow
      var idoc = iwindow.document
      var btns = idoc.getElementsByClassName('toolbar-button')
      for (var i = 0; i < btns.length; i++) {
        var btn = btns[i]
        var label = btn.getAttributeNode('aria-label')
        if (label.value === 'Cycle view mode') {
          btn.remove()
          // 如果删除成功 执行回调,比如清除定时器
          callBack()
        }
      }
    }
    document.getElementById('iframe').onload = function () {
      clearCycleViewModeBtn()
      var timer = null
      timer = setInterval(clearCycleViewModeBtn, 1000, function () {
        clearInterval(timer)
      })
      panelHeaderClickDefault()
      var timer2 = null
      timer2 = setInterval(panelHeaderClickDefault, 1000, function () {
        clearInterval(timer2)
      })
    }

禁止ESC,下面代码摘自网上。

document.onkeydown = killesc;

function killesc()
{
    if (window.event.keyCode == 27) {
        console.log('esc 禁用');
        window.event.keyCode = 0;
        window.event.returnValue = false;
    }

可能你会说,查看元素拿到iframe的链接,不就又可以各种操作了?是的,那样就没办法了。

实际上我并没有阻止esc键(尝试了好个博客的代码),不知道是代码问题还是?如果你知道怎么禁止的话。可以说下。

但是有个思路是。还是捕获onkeydown事件,通过隐藏导航条(也就是拿到dom,display none)的方式实现同样的目的,这样做就是费点点代码。

参考资料

image.png