SpringBoot中使用TOTP实现MFA(多因素认证)
一、MFA简介
定义:多因素认证(MFA)要求用户在登录时提供至少两种不同类别的身份验证因子,以提升账户安全性
核心目标:解决单一密码认证的脆弱性(如暴力破解、钓鱼攻击),将账户被盗风险降低80%以上;通过组合不同的验证因素,MFA 能够显著降低因密码泄露带来的风险
二、核心原理
MFA通过多步骤验证构建安全屏障:
- 初始验证:用户输入用户名和密码(知识因子)
- 二次验证:系统要求额外因子(如手机接收OTP码、指纹扫描)
- 动态授权:高风险操作(如转账)可触发更多验证(如硬件令牌+生物识别)
- 访问控制:所有因子验证通过后,授予最小必要权限
安全增强逻辑:
- 攻击者即使破解密码(知识因子),仍需突破所有权或生物因子,难度呈指数级增长
- 例如:钓鱼攻击中窃取密码后,因无法获取动态令牌或生物特征而失败
三、主流技术方案与对比
| 认证方式 | 安全性 | 用户体验 | 实施成本 | 场景 |
| TOTP动态码 | 高 | 优 | 低 | 通用:企业系统、云服务等(推荐首选) |
| 短信验证码 | 中 | 中 | 中 | 金融支付、社交平台(需运营商集成) |
| 生物识别(如人脸、指纹等) | 极高 | 优 | 高 | 移动设备、高安全系统 |
| 硬件令牌(如YubiKey) | 极高 | 中 | 高 | 金融、政府、军事系统 |
四、TOTP简介
- 基于时间的一次性密码,动态验证码每30秒更新,基于共享密钥(Secret Key)和当前时间戳通过HMAC-SHA1算法生成6位数字。
- 优势:离线可用、无需短信成本、兼容Google Authenticator等标准应用
五、SpringBoot集成TOTP
a.登录流程图(这里原系统使用 SA-Token,其他逻辑应该也大差不差)

b.代码实现
1.添加Maven依赖
<dependency>
<groupId>com.warrenstrange</groupId>
<artifactId>googleauth</artifactId>
<version>1.5.0</version>
</dependency>
<dependency>
<groupId>commons-net</groupId>
<artifactId>commons-net</artifactId>
<version>3.9.0</version>
</dependency>
2.Mfz服务类
@Log4j2
@Service
public class MfaService { @Lazy
@Resource
private IotUserService iotUserService;
@Resource
private RedisUtil redisUtil;
private final GoogleAuthenticator gAuth = new GoogleAuthenticator();
/**
* 为用户启用MFA,生成密钥和备用码
*/
public MfaSetupResult setupMfa(String userId) { GoogleAuthenticatorKey key = gAuth.createCredentials();
String secret = key.getKey();
List<String> backupCodes = generateBackupCodes();
// 加密存储(生产环境需替换为KMS加密)
String encryptedSecret = encrypt(secret);
log.info(secret + "====二维码生成===" + encryptedSecret);
String encryptedBackupCodes = encrypt(String.join(",", backupCodes));
IotUser user = iotUserService.getById(userId);
if (user == null) {
throw new RuntimeException("用户不存在");
}
// 更新数据库
user.setMfaSecret(encryptedSecret);
user.setBackupCodes(encryptedBackupCodes);
user.setMfaEnabled(1);
iotUserService.updateById(user);
String qr = "otpauth://totp/" + userId + "?secret=" + secret + "&issuer=IOT_Platform"
+ "&image=https://iot-dev.xxxxxx.cn/static/img/logo.34793a79.png";
return new MfaSetupResult(qr, backupCodes);
}
/**
* 生成10个备用验证码(一次性使用)
*/
private List<String> generateBackupCodes() {
return new Random().ints(10, 100000, 999999)
.mapToObj(code -> String.format("%06d", code))
.collect(Collectors.toList());
}
/**
* 验证TOTP或备用码
*/
public boolean verifyCode(String userId, String code) {
IotUser user = iotUserService.getById(userId);
if (user == null) {
throw new RuntimeException("用户不存在");
}
// 1. 获取加密的密钥和备用码
String encryptedSecret = user.getMfaSecret();
String encryptedBackupCodes = user.getBackupCodes();
String secret = decrypt(encryptedSecret);
log.info(secret + "校验" + encryptedSecret);
List<String> backupCodes = new ArrayList<>(
Arrays.asList(decrypt(encryptedBackupCodes).split(","))
);
// 2. 验证TOTP(允许时间偏差)
if (gAuth.authorize(secret, Integer.parseInt(code))) {
return true;
}
// 3. 验证备用码
if (backupCodes.contains(code)) {
backupCodes.remove(code);
// 更新数据库
user.setBackupCodes(encrypt(String.join(",", backupCodes)));
iotUserService.updateById(user);
return true;
}
return false;
} /**
* 开启7天免MFA认证
*/
public void setMfaSkip(String userId, String userAgent, String ip) {
String deviceHash = DigestUtils.sha256Hex(userAgent + ip).substring(0, 8);
String key = "mfa_skip:" + userId + ":" + deviceHash;
long expireAt = System.currentTimeMillis() + 7 * 86_400_000L;
String value = expireAt + "|" + userAgent;
redisUtil.setEx(key, value, 7, TimeUnit.DAYS);
}
/**
* 验证是否已开启免MFA认证
*/
public boolean isMfaSkipped(String userId, String userAgent, String ip) {
String deviceHash = DigestUtils.sha256Hex(userAgent + ip).substring(0, 8);
String key = "mfa_skip:" + userId + ":" + deviceHash;
String value = redisUtil.get(key);
if (value == null) {
return false;
}
// 验证设备信息一致性(防盗用)
String[] parts = value.split("\\|");
long expireAt = Long.parseLong(parts[0]);
String storedUserAgent = parts[1];
return expireAt > System.currentTimeMillis()
&& storedUserAgent.equals(userAgent);
}
// --- AES加密工具方法 ---
private String encrypt(String data) {
// 实际实现需使用AES-GCM(此处简化)
return Base64.getEncoder().encodeToString(data.getBytes());
}
private String decrypt(String encrypted) {
return new String(Base64.getDecoder().decode(encrypted));
}
}
3. IP获取工具IpUtils
public class IpUtils {
public static String getClientIp(HttpServletRequest request) {
// 1. 优先级解析代理头部
String[] headers = {"X-Forwarded-For", "Proxy-Client-IP", "WL-Proxy-Client-IP", "HTTP_CLIENT_IP", "HTTP_X_FORWARDED_FOR"};
for (String header : headers) {
String ip = request.getHeader(header);
if (isValidIp(ip)) {
return parseFirstIp(ip);
}
}
// 2. 直接获取远程地址
String ip = request.getRemoteAddr();
// 3. 处理本地环回地址(开发环境)
if ("127.0.0.1".equals(ip) || "0:0:0:0:0:0:0:1".equals(ip)) {
try {
return InetAddress.getLocalHost().getHostAddress();
} catch (Exception e) {
return "127.0.0.1";
}
}
return ip;
}
private static boolean isValidIp(String ip) {
return ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip);
}
private static String parseFirstIp(String ip) {
// 处理多IP场景(如:X-Forwarded-For: client, proxy1, proxy2)
return ip.contains(",") ? ip.split(",")[0].trim() : ip;
}
}
4.登录、Mfa开启、Mfa校验、Mfa二维码以及10个备用一次性code生成(服务类省略)
@Override
public LoginResult login(LoginParam loginParam, HttpServletRequest request) {
IotUser iotUser = this.getOne(new LambdaQueryWrapper<IotUser>().eq(IotUser::getAccount, loginParam.getAccount())
.eq(IotUser::getStatus, 0));
// 校验用户是否存在
if (ObjectUtil.isNull(iotUser)) {
throw new ServiceException(IotUserExceptionEnum.LOGIN_ERROR);
}
// 验证账号密码是否正常
String requestMd5 = SaltUtil.md5Encrypt(loginParam.getPassword(), iotUser.getSalt());
String dbMd5 = iotUser.getPassword();
if (dbMd5 == null || !dbMd5.equalsIgnoreCase(requestMd5)) {
throw new ServiceException(IotUserExceptionEnum.LOGIN_ERROR);
}
// 账号被冻结
if (iotUser.getStatus().equals(1)) {
throw new ServiceException(IotUserExceptionEnum.ACCOUNT_FREEZE_ERROR);
}
// 密码校验成功后登录,一行代码实现登录
StpUtil.login(iotUser.getUserId());
StpUtil.getSession().set(Constants.USER_INFO_KEY, userDto(iotUser));
/** 获取当前登录用户的Token信息 */
SaTokenInfo saTokenInfo = StpUtil.getTokenInfo();
LoginResult loginResult = new LoginResult();
loginResult.setToken(saTokenInfo.getTokenValue());
loginResult.setMfaEnabled(iotUser.getMfaEnabled());
// 开启了MFA认证
if (iotUser.getMfaEnabled() == 1) {
String ua = request.getHeader("User-Agent");
String ip = IpUtils.getClientIp(request);
log.info("登录请求IP:" + ip);
if (mfaService.isMfaSkipped(iotUser.getUserId(), ua, ip)) {
// 触发免验证:激活安全会话
StpUtil.openSafe( 7 * 24 * 60 * 60);
} else {
loginResult.setNeedMfa(true);
}
}
return loginResult;
}
@Override
public VerifyResult verify(MfaVerifyParam verifyParam, HttpServletRequest request) {
if (ObjectUtil.isNull(verifyParam.getCode())) {
throw new ServiceException("验证码不能为空");
}
if (ObjectUtil.isNull(verifyParam.getRemember())) {
verifyParam.setRemember(false);
}
String userId = StpUtil.getLoginIdAsString();
// 1. 验证TOTP/备用码
if (!mfaService.verifyCode(userId, verifyParam.getCode())) {
throw new ServiceException("验证码无效");
}
// 2. 若选择免认证7天,更新数据库
if (Boolean.TRUE.equals(verifyParam.getRemember())) {
String userAgent = request.getHeader("User-Agent");
String ip = IpUtils.getClientIp(request);
log.info("MFA验证请求IP:" + ip);
mfaService.setMfaSkip(userId, userAgent, ip);
}
// 3. 激活SA-Token安全会话(7天或一次性)
StpUtil.openSafe(verifyParam.getRemember() ? 7 * 24 * 60 * 60 : 120);
VerifyResult verifyResult = new VerifyResult();
verifyResult.setToken(StpUtil.getTokenValue());
verifyResult.setMsg("验证成功");
return verifyResult;
}
@Override
public MfaSetupResult qrCode() {
String userId = StpUtil.getLoginIdAsString();
return mfaService.setupMfa(userId);
}
@Override
public void openMfa() {
String userId = StpUtil.getLoginIdAsString();
IotUser iotUser = this.getById(userId);
if (ObjectUtil.isNull(iotUser)) {
throw new ServiceException("用户不存在");
}
iotUser.setMfaEnabled(1);
this.updateById(iotUser);
}
5.Mfa校验入参类
@Data
public class MfaVerifyParam {
/**
* Mfa动态、一次性备用代码
*/
private String code;
/**
* 当前机器近7天是否跳过Mfa校验
*/
private Boolean remember;
}
6.控制类
@RestController
public class IotPlatFormAuthController {
@Resource
private IotUserService iotUserService;
/**
* @description: 登录
* @param: [loginParam]
* @return: com.honyar.core.model.response.ResponseData
* @author: zhouhong
*/
@PostMapping("/auth/login")
public ResponseData login(@RequestBody LoginParam loginParam, HttpServletRequest request) {
return new SuccessResponseData(iotUserService.login(loginParam, request));
}
/**
* @description: 开启MFA
* @param: []
* @return: com.honyar.core.model.response.ResponseData
* @author: zhouhong
*/
@PostMapping("/auth/mfa/openMfa")
public ResponseData openMfa() {
iotUserService.openMfa();
return new SuccessResponseData();
}
/**
* @description: 获取MFA二维码
* @param: []
* @return: com.honyar.core.model.response.ResponseData
* @author: zhouhong
*/
@PostMapping("/auth/mfa/qrcode")
public ResponseData qrCode() {
return new SuccessResponseData(iotUserService.qrCode());
}
/**
* @description: MFA验证
* @param: [verifyParam]
* @return: com.honyar.core.model.response.ResponseData
* @author: zhouhong
*/
@PostMapping("/auth/mfa/verify")
public ResponseData verify(@RequestBody MfaVerifyParam verifyParam, HttpServletRequest request) {
return new SuccessResponseData(iotUserService.verify(verifyParam, request));
}
/**
* @description: 登出
* @param: []
* @return: com.honyar.core.model.response.ResponseData
* @author: zhouhong
*/
@PostMapping("/auth/logout")
public ResponseData logout() {
iotUserService.logout();
return new SuccessResponseData();
}
}
c.演示
1.调用登录接口

说明:登录返回当前用户是否已经开启Mfa,当用户已经开启mfa(mfaEnable=1)并且needMfa(需要进行mfa)时需要前端拉起mfa校验页面调用mfa校验接口进行二次校验;当mfaEnable=1并且needMfa=false时,说明当前设备已经开启7天面mfa校验,直接登录成功进入系统;当mfaEnable=0时,说明用户为开启mfa,则引导用户调用接口先开启mfa(数据库用户mfaEnable字段置为1即可),然后再调用mfa校验接口进行mfa校验,如果用户选择不开启则直接登录成功进入系统。
2.调用mfa二维码、备用一次性code生成接口

说明:调用这个接口后前端根据 qrUrl信息生成一个二维码,并且同时浏览器下载备用code 到本地,用户使用Authenticator APP进行扫码添加用户,然后再使用 Authenticator 里面生成的code调用校验Mfa接口校验成功后进入系统;第二次用户直接从Authenticator获取code进行二次认证即可


3.调用Mfa校验接口

说明:校验成功后进入系统
SpringBoot中使用TOTP实现MFA(多因素认证)的更多相关文章
- 业余草双因素认证(2FA)教程
所谓认证(authentication)就是确认用户的身份,是网站登录必不可少的步骤.密码是最常见的认证方法,但是不安全,容易泄露和冒充.越来越多的地方,要求启用双因素认证(Two-factor au ...
- SpringBoot中yaml配置对象
转载请在页首注明作者与出处 一:前言 YAML可以代替传统的xx.properties文件,但是它支持声明map,数组,list,字符串,boolean值,数值,NULL,日期,基本满足开发过程中的所 ...
- 如何在SpringBoot中使用JSP ?但强烈不推荐,果断改Themeleaf吧
做WEB项目,一定都用过JSP这个大牌.Spring MVC里面也可以很方便的将JSP与一个View关联起来,使用还是非常方便的.当你从一个传统的Spring MVC项目转入一个Spring Boot ...
- springboot中swaggerUI的使用
demo地址:demo-swagger-springboot springboot中swaggerUI的使用 1.pom文件中添加swagger依赖 2.从github项目中下载swaggerUI 然 ...
- spring-boot+mybatis开发实战:如何在spring-boot中使用myabtis持久层框架
前言: 本项目基于maven构建,使用mybatis-spring-boot作为spring-boot项目的持久层框架 spring-boot中使用mybatis持久层框架与原spring项目使用方式 ...
- 由浅入深学习springboot中使用redis
很多时候,我们会在springboot中配置redis,但是就那么几个配置就配好了,没办法知道为什么,这里就详细的讲解一下 这里假设已经成功创建了一个springboot项目. redis连接工厂类 ...
- Springboot中使用AOP统一处理Web请求日志
title: Springboot中使用AOP统一处理Web请求日志 date: 2017-04-26 16:30:48 tags: ['Spring Boot','AOP'] categories: ...
- SpringBoot 中常用注解
本篇博文将介绍几种SpringBoot 中常用注解 其中,各注解的作用为: @PathVaribale 获取url中的数据 @RequestParam 获取请求参数的值 @GetMapping 组合注 ...
- SpringBoot中关于Mybatis使用的三个问题
SpringBoot中关于Mybatis使用的三个问题 转载请注明源地址:http://www.cnblogs.com/funnyzpc/p/8495453.html 原本是要讲讲PostgreSQL ...
- 在SpringBoot中配置aop
前言 aop作为spring的一个强大的功能经常被使用,aop的应用场景有很多,但是实际的应用还是需要根据实际的业务来进行实现.这里就以打印日志作为例子,在SpringBoot中配置aop 已经加入我 ...
随机推荐
- Flutter图片组件的定制开发与配置实践
@charset "UTF-8"; .markdown-body { line-height: 1.75; font-weight: 400; font-size: 15px; o ...
- ubuntu 踩过的坑
ubuntu安装中文输入法成功教程: https://zhuanlan.zhihu.com/p/508797663 博主希望尽量的不去宿主机中操作,达到对原系统的保护的效果,并且能够进行日常的深度学习 ...
- Web前端入门第 60 问:JavaScript 各种数组定义与数组取值方法
数组可以算是程序里面最常用的数据结构了,但凡网页上任何一个列表数据,基本都是以数组的形式存在,像表格.banner图.菜单列表.商品列表,分类列表等等,在前端领域都是以数组处理. 数组的定义 JS 的 ...
- Java 提取url的域名
有时候,我们需要校验URL的域名是否在白名单中,故需要提取其中的域名.可以使用java标准类库java.net.URL进行提取,方法如下: import org.apache.commons.la ...
- MySQL中自增长序列(@i:=@i+1)的用处及用法
问题分析 Oracle中的伪列 ROWNUM 是一组递增的序列,在查询数据时生成,为结果集中每一行标识一个行号, 每条记录会因为输出的顺序不同而获得不同的逻辑编号:此自增长序列可以视作起始值为 ...
- FastAPI安全机制:从OAuth2到JWT的魔法通关秘籍
title: FastAPI安全机制:从OAuth2到JWT的魔法通关秘籍 date: 2025/06/07 08:40:35 updated: 2025/06/07 08:40:35 author: ...
- shell脚本加密软件shc
一.简单介绍 shc是linux的一款加密脚本的插件,将shc放到系统的可执行目录下我们可以直接运行shc命令 二.shc的安装 [root@disk ~]#yum install gcc -y [r ...
- 使用Oracle数据库的递归查询语句生成菜单树
SQL 格式 SELECT * FROM TABLE WHERE [...结果过滤语句] START WITH [...递归开始条件] CONNECT BY PRIOR [...递归执行条件] 查询所 ...
- AI 赋能指标管理分析,开启企业数智领航时代
以下为本次分享的回顾: 在大数据时代,企业数字化转型的核心目标在于让数据发挥真正的价值.从数据报表到分析平台,再到日常取数,企业所依赖的不仅仅是数据本身,而是通过数据所呈现出对业务的分析.业务的查看以 ...
- ChatGPT学习之旅 (3) Prompt进阶用法
大家好,我是Edison. 上一篇:Hello Prompt 复习Prompt用法 还记得上一篇学到的黄金公式吗? 这里,我们先来复习一下,假如我们想要ChatGPT来扮演一个[私人营养师]为我们给出 ...