Published on

前端登录知多少II

Authors
  • avatar
    Name
    McDaddy(戣蓦)
    Twitter

前端登录知多少II

当我们日常访问网页或者网站应用时,可以发现,除了日常的账号密码登录之外,往往还提供一种扫码登录的入口,例如淘宝、阿里云、飞书等等。那么本文就从原理层面来解析如何实现一个扫码登录的全流程

image-20231102165109037

image-20231102165124116

扫码登录的概念

  • 目的:免去用户去输入账号密码的流程,简化登录过程
  • 前提:扫码的手机端必须是已经登录的状态,否则手机端还是需要再登录一遍
  • 原理:通过已经登录的手机端,来授权未登录的PC页面端直接登录

场景

首先我们来分析一下扫码登录有哪些必要条件和必要步骤

  • 二维码:登录页上的二维码,其背后的信息其实就是一个跳转的链接,让用户在手机扫码后可以自动跳转到一个登录确认页面

  • 一般扫码动作都是发生在Web端产品对应的手机app上的,一般情况下不会出现用一个手机浏览器打开扫码连接,这样做有两个原因

    • 确保扫码的这一端是已经登录的,这样就免去了重新登录的步骤
    • 为了推广自己产品的手机客户端
  • Web页面在被扫码之后,一般都会提示目前操作的状态,比如已扫码/待确认等状态信息,类似下图 image-20231103121720736

  • 在手机端扫码后,会跳转到一个确认登录页,类似下图

    img_v3_024r_6a298aa8-d476-4522-bb55-163dded063eg
  • 点击确认登录之后,PC端页面就会跳转到登录成功的页面

  • 此时PC端的登录态应该是持久化的,即使刷新页面也不会丢失这个登录状态

流程图

经过上面的分析,我们可以把主要的流程总结为下图

image-20231103141127619

DEMO地址

开发实现

生成二维码

  1. 每次刷新二维码,都通过一个UUID来作为生成的key,并把它缓存在一个map中
  2. 二维码中的信息,就是一个指向手机端确认页的链接,同时附带了uuid
const map = new Map<string, QrCodeInfo>();

@Get('qrcode/generate')
async generate(@Req() request: Request) {
  const uuid = randomUUID();
  const dataUrl = await qrcode.toDataURL(
    `${request.protocol}://${request.headers.host}/pages/confirm.html?id=${uuid}`,
  );

  map.set(`qrcode_${uuid}`, {
    status: 'noscan',
  });

  return {
    qrcode_id: uuid,
    img: dataUrl,
  };
}

它的返回就是一个base64字符串可以用来显示图片以及uuid

image-20231103145157955

二维码展示

写一个简单的页面,用来做PC端的登录页

  1. 打开页面就请求/qrcode/generate接口,用来生成二维码
  2. 得到接口返回渲染出二维码图片
  3. 如果图片是未被扫码或扫码后还未确认的状态(即非结束状态),就需要定时轮询二维码当前的状态,并实时反馈到页面
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>扫码登录</title>
    <script src="https://unpkg.com/axios@1.5.0/dist/axios.min.js"></script>
    <script src="https://cdn.tailwindcss.com"></script>
  </head>
  <body>
    <div id="login" class="m-6 flex flex-col items-center gap-4">
      <div class="text-lg">阿外云扫码登录</div>
      <img id="img" src="" alt="" />
      <div id="info"></div>
    </div>
    <div id="content" class="flex items-center p-8 gap-4 hidden">
      <div class="text-lg">
        你好,
        <span id="name"></span>
        ,欢迎来到阿外云
      </div>
      <button id="logout" class="text-blue-500">退出</button>
      <a href="https://www.kuimo.top/blog/2023/fe-login-II" target="_blank" class="text-blue-500">配套文档</a>
    </div>
    <script>
      document.getElementById('logout').addEventListener('click', function () {
        localStorage.removeItem('xx-jwt-token');
        location.reload();
      });

      if (localStorage.getItem('xx-jwt-token')) {
        document.getElementById('login').style.display = 'none';
        axios
          .get('/userInfo', {
            headers: { 'xx-jwt-token': localStorage.getItem('xx-jwt-token') },
          })
          .then((res) => {
            if (res.data.username) {
              document.getElementById('content').style.display = 'flex';
              document.getElementById('name').innerHTML = res.data.username;
            }
          });
      } else {
        axios.get('/qrcode/generate').then((res) => {
          document.getElementById('img').src = res.data.img;
          queryStatus(res.data.qrcode_id);
        });
      }

      function queryStatus(id) {
        axios.get('/qrcode/check?id=' + id).then((res) => {
          const status = res.data.status;

          let content = '';
          switch (status) {
            case 'noscan':
              content = '未扫码';
              break;
            case 'scan-wait-confirm':
              content = '已扫码,等待确认';
              break;
            case 'scan-confirm':
              content = '已确认,即将跳转';
              break;
            case 'scan-cancel':
              content = '已取消';
              break;
          }
          document.getElementById('info').textContent = content;

          if (status === 'noscan' || status === 'scan-wait-confirm') {
            setTimeout(() => queryStatus(id), 1000);
          }
          if (status === 'scan-confirm') {
            window.localStorage.setItem('xx-jwt-token', res.data.token);
            setTimeout(() => {
              document.getElementById('login').style.display = 'none';
              document.getElementById('content').style.display = 'flex';
              document.getElementById('name').innerHTML = res.data.username;
            }, 2000);
          }
        });
      }
    </script>
  </body>
</html>

样子差不多就是这样

image-20231103150306074

实现轮询

这里需要定义好二维码的几个状态

  • noscan 未扫描

  • scan-wait-confirm -已扫描,等待用户确认

  • scan-confirm 已扫描,用户同意授权

  • scan-cancel 已扫描,用户取消授权

  • expired 已过期

轮询仅在未扫描已扫描,等待用户确认两个状态下发生,因为其他三个状态都是状态不可逆的状态,不需要再轮询

此接口接受一个uuid作为参数,返回它的当前状态

@Get('qrcode/check') // 面向pc
async check(@Query('id') id: string) {
  const info = map.get(`qrcode_${id}`);
  if (info.status === 'scan-confirm') {
    return {
      token: await this.jwtService.sign({
        username: info.userInfo.username,
      }),
      ...info,
      username: info.userInfo.username,
    };
  }
  return info;
}

登录确认页

当用户扫了上面的二维码之后,手机端就会跳转到一个确认登录的手机页。

它的地址是http://localhost/pages/confirm.html?id={uuid}

这个页面主要就是为了展示一个确认登录的按钮(假设目前是一个手机端已经登录的状态,就不去校验手机端是否登录了)

  • 一旦打开这个页面,第一件事就是通知服务端,这个码已经被扫了,从而改变二维码的显示状态
  • 如果点击了确认登录按钮,就通知服务端,这个码的登录已经被确认。此时手机端把自己的jwt token返回给服务端,即等于告诉服务端,是哪个登录的用户扫了码
  • 如果点击了取消按钮,就通知服务端,这个扫码已取消
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>扫码登录确认</title>
    <script src="https://unpkg.com/axios@1.5.0/dist/axios.min.js"></script>
    <script src="https://cdn.tailwindcss.com"></script>
  </head>
  <body>
    <div
      id="login"
      class="flex flex-col justify-center w-full h-[100vh] bg-gray-100"
    >
      <div id="form">
        <div class="flex flex-col gap-2 px-8 mb-6">
          <div class="text-base font-bold text-lg">你好,</div>
          <div class="text-base font-bold text-lg">阿外云扫码登录确认</div>
          <div class="text-gray-500">为保障账户安全,请确认是本人操作</div>
          <div>请输入你的用户名(昵称默认为admin)</div>
          <input id="input" />
        </div>
        <div class="flex flex-col gap-4 w-full">
          <button
            id="confirm"
            class="border rounded mx-4 leading-8 text-white bg-blue-400"
          >
            确认登录
          </button>
          <button id="cancel" class="border rounded mx-4 leading-8">
            取消登录
          </button>
        </div>
      </div>
      <div id="success" class="text-xl flex items-center justify-center hidden">
        登录成功!
      </div>
    </div>

    <script>
      const params = new URLSearchParams(window.location.search.slice(1));
      const idInput = (document.getElementById('input').value = 'admin');

      const id = params.get('id');

      axios.get('/qrcode/scan?id=' + id).catch((e) => {
        alert('二维码已过期');
      });

      document.getElementById('confirm').addEventListener('click', async () => {
        const username = document.getElementById('input').value ?? 'admin';
        const res = await axios.get(`/login?username=${username}`);
        await axios
          .get('/qrcode/confirm?id=' + id, {
            headers: { 'xx-jwt-token': res.data.token },
          })
          .catch((e) => {
            alert('二维码已过期');
          });
        document.getElementById('success').style.display = 'flex';
        document.getElementById('form').style.display = 'none';
      });

      document.getElementById('cancel').addEventListener('click', () => {
        axios.get('/qrcode/cancel?id=' + id).catch((e) => {
          alert('二维码已过期');
        });
      });
    </script>
  </body>
</html>

扫码动作

当手机端打开确认页自动调用,把二维码的状态置为待确认

@Get('qrcode/scan')
async scan(@Query('id') id: string) {
  const info = map.get(`qrcode_${id}`);
  if (!info) {
    throw new BadRequestException('二维码已过期');
  }
  info.status = 'scan-wait-confirm';
  return 'success';
}

此时因为PC端会轮询,所以会变化状态

image-20231103170414833

手机端登录

简化条件,不需要密码即可登录

@Get('login')
async login(@Query('username') username: string) {
  return {
    token: await this.jwtService.sign({
      username: username,
    }),
  };
}

确认动作

这里假设会在点击确认时,带上手机端的jwt token,即等于告诉服务端这个uuid对应的二维码被xx确认了,当然实际实现除了传jwt token肯定还有别的方式

  • 这里会先验证下jwt token的有效性
  • 更新二维码状态为已确认
  • 把这个二维码的userInfo设置为当前登录用户
@Get('qrcode/confirm') // 面向手机端
async confirm(
  @Query('id') id: string,
  @Headers('xx-jwt-token') auth: string,
) {
  let user;
  try {
    const info = await this.jwtService.verify(auth);

    user = { username: info.username };
  } catch (e) {
    throw new UnauthorizedException('token 过期,请重新登录');
  }

  const info = map.get(`qrcode_${id}`);
  if (!info) {
    throw new BadRequestException('二维码已过期');
  }
  info.status = 'scan-confirm';
  info.userInfo = { username: user.username };
  return 'success';
}

客户端跳转

这里就需要涉及到刚才轮询接口的状态判断功能,如果当前二维码已确认,就从map中把二维码对应的user信息拿出来,同时重新签名一个新的token,返回给PC端

if (info.status === 'scan-confirm') {
  return {
    token: await this.jwtService.sign({
      userId: info.userInfo.userId,
    }),
    ...info,
  };
}

此时的PC端,会在收到的轮询返回中得到一个额外的token字段,然后把它存到localstorage里面去,同时完成页面的跳转

// index.html
if (status === 'scan-confirm') {
  window.localStorage.setItem('xx-jwt-token', res.data.token);
  // show app content
}

状态保持

此时再去刷新PC端页面时,因为本地已经存了token,此时会调用一个校验接口来校验jwt token是否有效,如果有效就正常显示页面。所以此时只有在本地删除这个token或者等待它过期,否则登录状态将一直保持!

if (localStorage.getItem('xx-jwt-token')) {
  axios.get('/userInfo', {
    headers: { 'xx-jwt-token': localStorage.getItem('xx-jwt-token') },
  }).then(res => {
    if (res.data.username) {
      // show page
    }
  });
}

退出登录

登出就很简单了,只需清除本地存储的token即可

document.getElementById('logout').addEventListener('click', function () {
  localStorage.removeItem('xx-jwt-token');
  location.reload();
});

总结

经过上面的梳理,实现扫码登录的流程应该就非常清晰了,实际具体实现的细节可能会有出入,但总体的流程基本就是如此。

GitHub地址