没有单点登录和统一身份认证?前端免登录方案实践

3,094 阅读6分钟

去年参与了公司某平台项目,两个千万级的项目,实话实说。要求开发一个门户页面把公司现有的应用系统以卡片的形式集成进来,点击卡片能免登录跳转(这里划重点,核心诉求)到各系统。

场景一(明文)

这里我以后端常用的公共组件 nacos 来举例,把它想象成一个需要免登录跳转的应用。 截屏2023-02-06 15.43.05.png 不要嫌弃页面长得丑,商业秘密没办法,做了精简但不影响理解。

点击微服务管理平台卡片会跳转到 nacos 的控制台。

截屏2023-02-06 15.48.25.png

嗯...那不是应该有一个用户统一认证中心吗,还需要额外做免登录处理?理想是美好的,现实很残酷。

几千万的项目还是要有点东西的,因此就出现了公司各业务线的系统被集成到门户上面的状况,并且因为各种原因,用户这块是没办法打通的。

整个背景就是这样了,下面再谈具体怎么解决问题。

这里,我们还是以前面举例的 nacos 来说,先看下 nacos 控制台用户密码登录的流程。

在输入用户名和密码后,点击登录会调用接口 http://127.0.0.1:8848/nacos/v1/auth/users/login, 调用成功后会返回 token 信息并将其保存到 localStorage 中。

{
  "accessToken": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJuYWNvcyIsImV4cCI6MTYwNTYyOTE2Nn0.2TogGhhr11_vLEjqKko1HJHUJEmsPuCxkur-CfNojDo",
  "tokenTtl": 18000,
  "globalAdmin": true
}

后续操作页面调用接口时会带上 accessToken 进行用户鉴权,比如获取配置列表http://127.0.0.1:8848/nacos/v1/cs/configs?dataId=&group=&appName=&config_tags=&pageNo=1&pageSize=10&tenant=&search=accurate&accessToken=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJuYWNvcyIsImV4cCI6MTY3NTY4ODY4M30.gbBFsXIaMYI-N4fE3yf7kKctGs7tlgdyVAx9LzMwXd4&username=nacos

如果我们想在门户页面直接跳到 nacos 控制台是肯定绕不开用户鉴权的,这里可以通过一个过渡页面来模拟用户登录的过程。

过渡页面的脚本如下:

const http = new XMLHttpRequest();
// nacos 登录接口
const url = "http://127.0.0.1:8848/nacos/v1/auth/users/login";
const formData = new window.FormData();
formData.append("username", "nacos");
formData.append("password", "nacos");
http.open("POST", url, true);
http.onreadystatechange = function () {
if (http.readyState == 4 && http.status == 200) {
  localStorage.setItem(
    "token",
    JSON.stringify(JSON.parse(http.responseText))
  );
  // 登录成功后打开 nacos 控制台页面
  window.location.href = "http://127.0.0.1:8848/nacos/#/";
}
};
http.send(formData);

到这可能还没结束,实际项目部署时过渡页面和跳转的目标页面是不同源的 - 比如过渡页面部署在 80 端口下。既然跨域,保存在 localStorage 中的 token 信息就不能共享,怎么解决呢,nginx 该派上用场了。

先把nginx配置信息给出来。

server {
  listen 80;
  
  # 过渡页面
  location /nacos-middle/ {

    alias /home/www/nacos-middle/;
    expires 1y;

    if ($request_filename ~* ^.*?.(html)$) {

      expires -1;
    }
  }
}

server {
  listen 8847;
  server_name localhost;

  location /nacos/ {
    proxy_pass http://localhost:8848/nacos/;
  }

  location /nacos-middle/ {
    proxy_pass http://localhost/nacos-middle/;
  }
}

对外提供 8847 端口访问,并通过 /nacos/ 和 /nacos-middle/ 这两个 location 分别路由到 nacos 控制台和过渡页面,这样通过 8847 端口访问页面时就解决了跨域的问题。

到这里,我们再对前面的过渡页面脚本进行调整。nacos 登录接口变更为 http://127.0.0.1:8847/nacos/v1/auth/users/login,登录成功后打开 nacos 控制台页面变更为http://127.0.0.1:8847/nacos/#/,最后将门户卡片跳转地址指定为过渡页面http://127.0.0.1:8847/nacos-middle/ 即可。

总结下来,就做了两件事:

  1. 增加一个过渡页面模拟 nacos 控制台登录过程
  2. 通过 nginx 配置解决过渡页面和 nacos 控制台页面不同源的问题,保证用户鉴权成功

像 nacos 控制台这种明文传输用户密码也就是作为内部工具使用可以,真要上线,安全测试肯定不能通过。所以,接下来,我们还有更加复杂的场景。

场景二(token)

我拿项目中用到的一个知识库系统来举例说明,该系统源于开源的 BookStack,系统 体验地址,个人觉得还挺不错的,略微遗憾的是 php 技术栈对前端不是很友好,还经历了一番改造,算是给自己挖的坑,踩坑记录-BookStack 手动部署手册 - 掘金 (juejin.cn),印象深刻。

首页打开登录页面,输入用户名和密码,点击登录,看看调用的接口https://demo.bookstackapp.com/login,还有表单数据_token=04Avz3QF5E60la6R17yGDRtCKZOAwUSoBn9x9vo5&email=admin%40example.com&password=password,除了在页面上输入的用户名和密码,还有一个 _token 字段。那么这个字段是从哪来的,这是我们通过过渡页面模拟登录需要解决的核心问题。

在浏览器的网络请求里,查看登录页面文档内容,发现一个 meta 标签 <meta name="token" content="xxxxxxxxxxxxxxxxxxxxxxxxx">,其 token 值刚好就是登录接口的 _token 字段值。

解决了 token 的问题,接下来就简单了。

我可以在过渡页面中通过一个透明的 iframe 嵌入登录页面,在 iframe 内文档加载完成后拿到 token 然后调用登录接口。看下示例代码。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <style>
      #wiki {
        opacity: 0;
      }
    </style>
  </head>
  <body>
    <iframe
      src="/wiki/login"
      id="wiki"
      frameborder="0"
      onload="handleFrameLoaded()"
    ></iframe>
    <script>
      // 获取登录页面
      var wiki = document.getElementById("wiki");
      function handleFrameLoaded() {
        console.log("frame loaded");

        const uapDocument = wiki.contentWindow.document;
        // 获取 CSRF token
        const csrfToken = uapDocument
          .querySelector("meta[name=token]")
          .getAttribute("content");

        const http = new XMLHttpRequest();
        const url = "http://127.0.0.1/wiki/login";
        const formData = new window.FormData();
        formData.append("_token", csrfToken);
        formData.append("email", "admin@example.com");
        formData.append("password", "password");
        http.open("POST", url, true);
        http.onreadystatechange = function () {
          if (http.readyState == 4 && http.status == 200) {
            // 登录成功后打开系统页面
            window.location.href = "http://127.0.0.1/wiki/";
          }
        };
        http.send(formData);
      }
    </script>
  </body>
</html>

通过 nginx 配置,对外提供 80 端口访问 ,内部通过 /wiki/ 和 /bookstack-middle/ 这两个 location 分别路由到 bookstack 页面和过渡页面。这里涉及到 php 项目在 nginx 中的配置,太过冗长,就不列出来了。

另外还要提一点,在过渡页面嵌入透明 iframe 的操作可能会因为其内容安全策略的限制失败,具体的配置策略可以在 bookstack 请求的响应头中看到content-security-policy: frame-ancestors 'self'; frame-src 'self' https://*.draw.io https://*.youtube.com https://*.youtube-nocookie.com https://*.vimeo.com https://embed.diagrams.net; script-src http: https: 'nonce-ClCsLVRys2oASD1K27vgnhbC' 'strict-dynamic'; object-src 'self'; base-uri 'self',这里就不展开解释了。在我们的日常开发中也应该注意类似的安全问题,防范网页被恶意嵌入 iframe 避免受到点击劫持攻击

场景三(固定公钥加密)

针对场景一明文传输密码的安全性问题,有一种常见的加密方案。利用 RSA Encryption 生成的公钥和私钥对密码进行加密和解密。

const xhr = new XMLHttpRequest();
  xhr.open(
    "POST",
    "http://127.0.0.1:7999/app/auth/accessCode"
  );
  const encrypt = new JSEncrypt();
  encrypt.setPublicKey('xxxxxxxxxxxxxxxxxxxxxxxxxx');
  const password = encrypt.encrypt(password');
  xhr.setRequestHeader("Content-type", "application/json; charset=utf-8");
  xhr.send(JSON.stringify({
    username: 'admin',
    password
  }));
  xhr.onload = () => {
    if (xhr.status === 200) {
      const responseData = JSON.parse(xhr.response);
      window.location.href = `http://127.0.0.1:7999/app/sso/login?clientId=1&code=${responseData.data.code}`;
    }
  };

注意到,这里的公钥是在前端固定写死的,还是会存在泄漏的风险,下面有改进版本。

场景四(动态公钥加密)

为了破除场景三固定公钥泄露的风险,后端为我们生成了随机的密钥对(公钥和私钥),每次在我们打开登录页面时都会拿到一个新的公钥对密码进行加密。

具体到免登录跳转的需求,要怎么实现。还是沿用场景二的办法,通过在过渡页面内嵌一个透明的 iframe 获取公钥信息,填充表单用户密码登录。看下代码。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <script src="./js/jsencrypt.min.js"></script>
    <style>
      #demo {
        opacity: 0;
      }
    </style>
  </head>
  <body>
    <iframe
      src="/demo/login"
      id="demo"
      frameborder="0"
      onload="handleFrameLoaded()"
    ></iframe>
  </body>
  <script>
    // 获取登录页面
    const demo = document.getElementById("demo");
    function handleFrameLoaded() {
      console.log("frame loaded");
      const demoDocument = demo.contentWindow.document;
      // 填充表单用户密码
      demoDocument.getElementById("username").value = "admin";
      demoDocument.getElementById("password").value = "password";
      
      const pwd = demoDocument.getElementById("password").value;
      // rsa加密
      const encrypt = new JSEncrypt();
      // 获取公钥
      const publicKey = demoDocument.getElementById("publicKey").value;
      encrypt.setPublicKey(publicKey);
      const rsa_pwd = encrypt.encrypt(pwd);

      demoDocument.getElementById("password").value = rsa_pwd;
      // 提交表单 登录
      demoDocument.getElementById("fm1").submit();

      setTimeout(() => {
        window.location.href = "http://127.0.0.1:9092/demo-web/";
      }, 500);
    }
  </script>
</html>

对于过渡页面和登录页面之间的跨域问题,参考场景一的解决办法即可。

总结

本文从免登录跳转的需求出发,列举了项目中的四种用户登录场景,引入过渡页面模拟不同的登录过程,并利用 nginx 配置解决了过渡页面和跳转登录页面之间的跨域问题。