SpringBoot集成TOTP双因素认证(2FA)实战

一、双因素认证的概念

双因素认证(2FA,Two Factor Authentication)又称双因子认证、两步验证,指的是是一种安全认证过程,需要用户提供两种不同类型的认证因子来表明自己的身份,包括密码、指纹、短信验证码、智能卡、生物识别等多种因素组合,从而提高用户账户的安全性和可靠性。

2FA认证流程如下:

  1. 用户登录应用程序。
  2. 用户输入登录凭证,通常是账号和密码,做初始身份验证。
  3. 验证成功后,提示用户提交第二个身份验证因子。
  4. 用户将第二个身份验证因子输入至应用程序,如果第二个身份验证因子通过,用户将通过身份验证并被授予对应的系统操作权限。

举个简单的例子,我们使用账号密码登录微博、豆瓣等应用时,命名账号密码都对了,但是还要输入手机验证码二次验证以确保安全性,这就是双因素验证。

虽然短信验证码实现简单,但是在实际场景中,一般会使用其它2FA方式替代:一则短信发送会产生费用,二则它也不是那么安全,它容易被拦截和伪造,SIM 卡也可以克隆。

一般来说,安全的双因素认证不是密码 + 短消息,而是密码+ TOTP

二、TOTP

TOTP 的全称是"基于时间的一次性密码"(Time-based One-time Password)。它是公认的可靠解决方案,已经写入国际标准 RFC6238

1、TOTP步骤

第一步,用户开启双因素认证后,服务器生成一个密钥。

第二步:服务器提示用户扫描二维码(或者使用其他方式),把密钥保存到用户的手机。也就是说,服务器和用户的手机,现在都有了同一把密钥。

注意,密钥必须跟手机绑定。一旦用户更换手机,就必须生成全新的密钥。

第三步,用户登录时,手机客户端使用这个密钥和当前时间戳,生成一个哈希,有效期默认为30秒。用户在有效期内,把这个哈希提交给服务器。

第四步,服务器也使用密钥和当前时间戳,生成一个哈希,跟用户提交的哈希比对。只要两者不一致,就拒绝登录。

2、TOTP原理

仔细看上面的步骤,你可能会有一个问题:手机客户端和服务器,如何保证30秒期间都得到同一个哈希呢?

答案就是下面的公式。

bash 复制代码
TC = floor((unixtime(now) − unixtime(T0)) / TS)

上面的公式中,TC 表示一个时间计数器,unixtime(now)是当前 Unix 时间戳,unixtime(T0)是约定的起始时间点的时间戳,默认是0,也就是1970年1月1日。TS 则是哈希有效期的时间长度,默认是30秒。因此,上面的公式就变成下面的形式。

bash 复制代码
TC = floor(unixtime(now) / 30)

所以,只要在 30 秒以内,TC 的值都是一样的。前提是服务器和手机的时间必须同步。

接下来,就可以算出哈希了。

bash 复制代码
TOTP = HASH(SecretKey, TC)

上面代码中,HASH就是约定的哈希函数,默认是 SHA-1。

接下来在SpringBoot中集成TOTP实现双因素认证。

三、TOTP双因素认证实战

1、开源项目:GoogleAuth

TOTP自己手写还是稍稍有些复杂,去网上找了开源项目,发现一个比较好的实现:GoogleAuth,使用时需要引入Maven依赖:

xml 复制代码
<dependency>
    <groupId>com.warrenstrange</groupId>
    <artifactId>googleauth</artifactId>
    <version>1.5.0</version>
</dependency>

为了生成图片二维码,还需要引入:

xml 复制代码
<properties>
    <maven.compiler.source>8</maven.compiler.source>
    <maven.compiler.target>8</maven.compiler.target>
    <zxing.version>3.4.0</zxing.version>
</properties>
<dependency>
    <groupId>com.google.zxing</groupId>
    <artifactId>core</artifactId>
    <version>${zxing.version}</version>
</dependency>
<dependency>
    <groupId>com.google.zxing</groupId>
    <artifactId>javase</artifactId>
    <version>${zxing.version}</version>
</dependency>

GoogleAuth的API比较简单,常见API如下:

生成secret

java 复制代码
/**
 * 测试获取秘钥
 */
@Test
public void testGetKey() {
    GoogleAuthenticator gAuth = new GoogleAuthenticator();
    final GoogleAuthenticatorKey key = gAuth.createCredentials();
    String key1 = key.getKey();
    log.info(key1);
}

生成一次性验证码

java 复制代码
/**
 * 测试生成一次性验证码
 */
@Test
public void testGetTotpPassword() {
    GoogleAuthenticator gAuth = new GoogleAuthenticator();
    int code = gAuth.getTotpPassword("PWTBUDW6OAPV6E2EVMBHX2X7LH6MXRNE");
    log.info("{}", code);
}

验证码验证

java 复制代码
/**
 * 测试秘钥验证
 */
@Test
public void testAuthorize() {
    GoogleAuthenticatorConfig config = new GoogleAuthenticatorConfig
            .GoogleAuthenticatorConfigBuilder()
            //设置容忍度最小
            .setWindowSize(1)
            .build();
    GoogleAuthenticator gAuth = new GoogleAuthenticator(config);
    int verificationCode = 448247;
    String secretKey = "6VRFLPHNPQ4P2WAQWEIYPCQ43KIHVCJO";
    boolean isCodeValid = gAuth.authorize(secretKey, verificationCode);
    if (isCodeValid) {
        log.info("匹配");
    } else {
        log.info("不匹配");
    }
}

获取二维码(图片链接格式)

java 复制代码
/**
 * 获取图片二维码
 */
@Test
public void testGetOtpAuthURL() {
    GoogleAuthenticator gAuth = new GoogleAuthenticator();
    final GoogleAuthenticatorKey key = gAuth.createCredentials();
    log.info(key.getKey());
    String otpAuthURL = GoogleAuthenticatorQRGenerator.getOtpAuthURL(
            ISSUER,
            userName,
            key
    );
    log.info(otpAuthURL);
}

获取二维码(字节流)

java 复制代码
@Test
public void testGetOtpAuthQrByteArrayOutputStream() throws WriterException, IOException {
    GoogleAuthenticator gAuth = new GoogleAuthenticator();
    final GoogleAuthenticatorKey key = gAuth.createCredentials();
    String otpAuthUri = GoogleAuthenticatorQRGenerator.getOtpAuthTotpURL(
            ISSUER,
            userName,
            key);

    QRCodeWriter qrWriter = new QRCodeWriter();
    BitMatrix bitMatrix = qrWriter.encode(otpAuthUri, BarcodeFormat.QR_CODE, 200, 200);
    BufferedImage bufferedImage = MatrixToImageWriter.toBufferedImage(bitMatrix);
    ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
    ImageIO.write(bufferedImage, "png", outputStream);
    ImageIO.write(bufferedImage, "png", new File("temp.png"));
}

2、开源项目:光年Admin模板

为了能更直观的展示TOTP功能集成到SpringBoot的样子,我决定基于开源项目 Light Year Admin v5 去做前端的开发。Light Year Admin v5 是一个管理端模板,基于Bootstrap 5.1.3。线上体验地址:http://lyear.itshubao.com/v5/ ,也可以下载下来以后使用http-server快速启动查看效果:

该项目是一个纯前端项目,为了更方便的集成到SpringBoot,我将其集成到了freemarker:

3、项目实战:2fa-demo

项目地址:https://gitee.com/kdyzm/2fa-demo

该项目依赖于MySQL,所以在运行前需要先准备好MySQL环境。

运行前准备

需要创建Mysql数据库,运行如下脚本:

sql 复制代码
CREATE DATABASE `2fa_demo` ;
USE `2fa_demo`;

CREATE TABLE `sys_user` (
  `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
  `user_name` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '用户名',
  `nick_name` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '用户昵称',
  `password` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '用户密码',
  `two_fa_secret` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '两步验证的秘钥',
  `tow_fa_enabled` tinyint(1) DEFAULT '0' COMMENT '是否启用两步验证',
  `create_by` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT 'sys' COMMENT '创建人',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_by` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '更新人',
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  `del_flag` tinyint(1) NOT NULL DEFAULT '0' COMMENT '删除标志,0:未删除;1:已删除',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='用户表';

insert  into `sys_user`(`id`,`user_name`,`nick_name`,`password`,`two_fa_secret`,`tow_fa_enabled`,`create_by`,`create_time`,`update_by`,`update_time`,`del_flag`) values 
(1,'kdyzm','狂盗一枝梅','123456','H5C7U7M3FJN6DGL6EAAWHF6TVAAINAGU',0,'kdyzm','2025-06-16 13:51:03',NULL,'2025-06-17 13:37:13',0);

CREATE TABLE `sys_user_2fa` (
  `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
  `user_id` bigint DEFAULT NULL COMMENT '用户id',
  `secret_key` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '秘钥',
  `scratch_codes` varchar(128) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '静态验证码',
  `create_by` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '创建人',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_by` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '更新人',
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  `del_flag` tinyint(1) NOT NULL DEFAULT '0' COMMENT '删除标志,0:未删除;1:已删除',
  PRIMARY KEY (`id`),
  UNIQUE KEY `unique_user_id` (`user_id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='两步验证相关临时配置表';

之后修改配置文件中的Mysql配置信息:

yaml 复制代码
JDBC_MYSQL_HOST: localhost
JDBC_MYSQL_PORT: 3306
JDBC_MYSQL_DATABASE: 2fa_demo
JDBC_MYSQL_USERNAME: root
JDBC_MYSQL_PASSWORD: '123456'

项目启动

将项目导入Intelij,运行Application,出现如下即可表示运行成功

打开链接,进入登录页面

账号密码登录

在未登录的情况下打开链接http://localhost:8024,就会进入登录页面
登录账号:kdyzm

登录密码:123456

登录成功之后进入首页。

开启两步验证

登录成功之后进入首页,点击首页右上角两步验证

进入两步验证页面,由于未设置过二次验证,所以会提示去设置

点击"开启二次验证"按钮,进入两步骤验证向导

点击下一步输入电子邮件

点击下一步,进入关键的验证器配置步骤

在这一步,IOS下可以安装Authenticator或者微信小程序搜索"MFA",使用"腾讯身份验证器"扫描二维码完成验证器设置,注意,如果多次扫描相同的二维码,需要删除上次扫描的记录

点击下一步,校验配置的正确性:

提示配置开启成功即表示已配置成功。

验证两步验证

退出登录,回到登录页,输入账号密码登录,登录成功后不再跳转到首页,而是跳转到二次验证页面:

从手机上获取动态验证码,即可成功登录系统。

关闭两步验证

进入首页后,再次进入两步验证页面,即可看到关闭按钮

关闭后再次登录系统,就不会进入两步验证页面了。

4、实战总结

由于本项目案例只关注2FA相关的内容,而光年Admin模板的静态模板只实现了登录以及2FA相关的功能,而且时间匆忙代码比较糙。。。

该项目还剩下一些问题没实现

  1. 二次验证的记住设备功能
  2. 8位数的静态码未实现校验功能
  3. 设备丢失静态码找回功能未实现

以后有时间再补充了。

最后,欢迎关注我的博客呀:一枝梅的博客

END.