双因素认证(2FA)详解及实践

724 阅读5分钟

前言

在前一段时间想和小伙伴一起玩游戏,出于一系列的原因,只能使用远程控制软件来玩。搜了一下Parsec不错。但是需要2FA认证。那就认证吧,按步骤一步一步来,一直到要通过这两个平台下东西。那不是完蛋了,不玩了!

image.png

过了没几天,登录GitHub也强制需要2FA认证,游戏可以不玩,GitHub不能不用!去研究了一下。

原来是叫做双因素认证(Two-Factor Authentication),简称2FA。有几种方式来实现

  1. 基于时间的一次性密码(TOTP)
  2. 短信验证码
  3. 硬件令牌

ParsecGitHub都是使用TOTP来实现认证。在web端可以通过浏览器插件--身份验证器来帮助我们认证

  1. TOTP(Time-Based One-Time Password,基于时间的一次性密码)是一种基于时间的动态密码生成算法。 算法主要基于 HMAC(Hash-based Message Authentication Code)和一个时间戳来生成一次性密码。在不同的设备上,只要确保密钥正确时间同步或者相差不大的情况下,生成的一次性密码是一致的。这是TOTP可以用于认证的关键原因
  2. OTP(One-Time Password,一次性密码),在一定时间周期(通常为30秒)内生成一个一次性密码(通常为6位),即使密码被截获,也无法在后续的认证中再次使用。

后面有实践的完整代码地址

正文

2FA流程

在需要进行2FA时,第一次登录时,服务器会提供一个二维码,用户使用支持TOTP的应用程序扫描这个二维码,应用程序就会保存这个密钥,之后程序会根据时间和密钥生成一次性密码,并且会在一定周期内刷新密码,用户输入账号和账号密码无误后再输入一次性密码一次性密码正确则登录成功。之后的登录服务器无需再提供二维码,用户直接使用账号账号密码和在应用程序内一定时间刷新的一次性密码进行验证登录即可。

GitHub还提供了一次性密码弄丢(程序卸载)的备用方案,在你第一次登录成功时,会叫你保存一个github-recovery-codes.txt的文件,用这个文件里面的Code也能登录,然后重新进行认证。

纯前端现实生成密钥二维码和解析一次性密码

密钥二维码是由后端提供的,我这边不想去搭后端的环境,就直接前端实现了

先来看一下效果

动画2.gif

需要用到的几个库,

@otplib/preset-browser  //生成密钥、OTP URI(后面解释)和生成一次性密码 
qrcode  //生成二维码
html5-qrcode  //解析二维码
生成二维码

首先使用@otplib/preset-browsergenerateSecret方法生成密钥,再将密钥通过keyuri得到OTP URI。最后通过qrcodetoDataURL方法生成二维码图片

OTP URI 是一种标准格式,遵循 otpauth:// 方案。它包含了所有必要的信息,如用户的账户名、服务提供商的名称和密钥。这个格式被广泛支持,可以确保各种 TOTP 应用(如 Google Authenticator、Authy)能够识别和正确配置。

  1. Label: 通常是 账户名@服务提供商,用于标识具体的账户。
  2. Secret: 用户和服务器共享的密钥,用于生成 OTP
  3. Issuer: 用于标识服务提供商(应用程序名称)。
  4. 其他可选参数: 可能包括时间步长(period)、数字位数(digits)、算法(algorithm)等

参考文档 github.com/google/goog…

关键实现代码

import React, { useEffect, useRef, useState } from 'react';
import { authenticator } from '@otplib/preset-browser';
import QRCode from 'qrcode';
import { Button, Upload, UploadFile } from 'antd';

//配置 OTP URI
authenticator.options = {
  digits: 6, //一次性密码的长度。默认值为 6。
  step: 30, // 有效期限(以秒为单位)。默认值为 30。
  algorithm: 'sha1' //sha1(默认)  sha256  sha512
};


export default function Index() {
  const [url, setUrl] = useState('');

  const handleClick = () => {
    //随机的字符串
    const secret = authenticator.generateSecret();
    //生成一个OTP URI
    const otpauth = authenticator.keyuri('user@example.com', 'My App', secret);
    console.log('otpauth:', otpauth);

    QRCode.toDataURL(otpauth, (err: any, imageUrl: string) => {
      if (err) {
        console.error('Error generating QR code', err);
        return;
      }
      setUrl(imageUrl);
      console.log('QR code image URL:', imageUrl);
    });
  };

  return (
    <>
      <div
        style={{
          height: '200px',
        }}
      ></div>
      <h2>生成二维码</h2>
      <section>
        {url && (
          <img
            style={{
              height: '200px',
              width: '200px',
            }}
            src={url}
            alt="QR code"
          />
        )}
        <div>
          <Button onClick={handleClick}>点击生成二维码</Button>
        </div>
      </section>
    </>
  );
}

解析生成的二维码

通过html5-qrcode创建扫描器,并解析二维码得到密钥,最后通过@otplib/preset-browsergenerate方法得到一次性密码

关键实现代码

import React, { useEffect, useRef, useState } from 'react';
import { authenticator } from '@otplib/preset-browser';
import QRCode from 'qrcode';
import { Button, Upload, UploadFile } from 'antd';
import { Html5QrcodeScanner, Html5QrcodeScanType } from 'html5-qrcode';

authenticator.options = {
  digits: 6, //一次性密码的长度。默认值为 6。
  step: 30, // 有效期限(以秒为单位)。默认值为 30。
  algorithm: 'sha1' //sha1(默认)  sha256  sha512
};

export default function Index() {
  /** 密钥*/
  const secret = useRef('');
  return (
    <>
      <h2>解析二维码</h2>
      <div id="scanFile" ></div>

      <section>
        <div>
          <Button
            onClick={() => {
              //创建扫描器
              let reader = new Html5QrcodeScanner(
                'scanFile',
                {
                  fps: 10,
                  qrbox: { width: 150, height: 150 },
                  supportedScanTypes: [Html5QrcodeScanType.SCAN_TYPE_FILE],
                },
                false
              );
              
              //进行解析二维码
              reader.render(data => {
                const urlObj = new URL(data);
                const label = decodeURIComponent(urlObj.pathname.replace(/^\/+/, ''));
                const params = Object.fromEntries(urlObj.searchParams.entries());
                //获取密钥
                secret.current = params.secret;
              });
            }}
          >
            上传图片
          </Button>
          <Button
            onClick={() => {
              //解析密钥得到一次性密码
              const otp = authenticator.generate(secret.current);
              // 一次性密码
              console.log(otp, 'otp');
            }}
          >
            获取OTP
          </Button>
        </div>
      </section>
    </>
  );
}

结语

感兴趣的可以去试试

仓库地址: gitee.com/lin-zhiteng…