fix(auth): 修复双因素认证若干问题

- 预认证令牌改为先读取后删除, 允许验证码错误后重试
- 关闭/重置备用码支持备用码验证(TOTP不可用时不被锁死)
- lastVerifyTime 改用 OffsetDateTime, 走国际化时间格式化
- 修复 error.auth.* i18n 文件路径映射(auth.json 移至 error/ 下)
This commit is contained in:
DaxPay Dev
2026-06-25 22:43:34 +08:00
parent d4c5d715b3
commit 4f9081d648
9 changed files with 47 additions and 51 deletions

View File

@@ -32,21 +32,25 @@ public class TwoFactorPreAuthService {
return token;
}
/// 消费预认证令牌(取出并立即删除, 单次有效), 不存在或已过期返回 null
public PreAuthContext consume(String token) {
/// 读取预认证令牌上下文(不删除, 供二次验证重试), 不存在或已过期返回 null
public PreAuthContext get(String token) {
if (token == null || token.isBlank()) {
return null;
}
String key = PREFIX + token;
String json = stringRedisTemplate.opsForValue().get(key);
String json = stringRedisTemplate.opsForValue().get(PREFIX + token);
if (json == null) {
return null;
}
// 立即删除, 保证单次有效
stringRedisTemplate.delete(key);
return JSONUtil.toBean(json, PreAuthContext.class);
}
/// 删除预认证令牌(二次验证通过后调用, 保证单次有效)
public void delete(String token) {
if (token != null && !token.isBlank()) {
stringRedisTemplate.delete(PREFIX + token);
}
}
/// 预认证上下文
public record PreAuthContext(Long userId, String clientCode, String loginType) {
}

View File

@@ -51,13 +51,13 @@ public class UserTwoFactorController {
@Operation(summary = "关闭双因素认证")
@PostMapping("/disable")
public Result<Void> disable(@RequestBody @Validated TwoFactorCodeParam param) {
userTwoFactorService.disable(param.getCode());
userTwoFactorService.disable(param.getCode(), param.getCodeType());
return Res.ok();
}
@Operation(summary = "重新生成备用验证码")
@PostMapping("/regenerate-backup-codes")
public Result<BackupCodeResult> regenerateBackupCodes(@RequestBody @Validated TwoFactorCodeParam param) {
return Res.ok(userTwoFactorService.regenerateBackupCodes(param.getCode()));
return Res.ok(userTwoFactorService.regenerateBackupCodes(param.getCode(), param.getCodeType()));
}
}

View File

@@ -92,7 +92,7 @@ public class TokenService {
/// 双因素认证二次验证: 校验预认证令牌 + 动态码/备用码, 通过后建立会话
public String secondVerify(HttpServletRequest request, HttpServletResponse response,
String preAuthToken, String code, String codeType) {
TwoFactorPreAuthService.PreAuthContext context = twoFactorPreAuthService.consume(preAuthToken);
TwoFactorPreAuthService.PreAuthContext context = twoFactorPreAuthService.get(preAuthToken);
// 预认证令牌无效或已过期
if (context == null) {
throw new LoginFailureException("error.auth.twoFactorPreAuthExpired");
@@ -111,6 +111,8 @@ public class TokenService {
// 双因素认证: 动态码或备用码错误
throw new LoginFailureException(userId, userInfoResult.getAccount(), "error.auth.twoFactorCodeError");
}
// 验证通过, 删除预认证令牌(单次有效)
twoFactorPreAuthService.delete(preAuthToken);
// 恢复登录流程
UserDetail userDetail = userInfoResult.toUserDetail();
AuthInfoResult authInfoResult = new AuthInfoResult()

View File

@@ -9,8 +9,6 @@ import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import java.time.OffsetDateTime;
/// # 用户双因素认证绑定记录
///
/// 一对一关联用户, 记录存在即代表该用户已启用 TOTP 双因素认证。
@@ -38,7 +36,4 @@ public class UserTwoFactor extends MpBaseEntity {
/// 剩余可用备用验证码数量(冗余字段, 便于查询展示)
private Integer backupCodesRemaining;
/// 最后验证时间 (UTC)
private OffsetDateTime lastVerifyTime;
}

View File

@@ -5,19 +5,23 @@ import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import lombok.experimental.Accessors;
/// # 双因素认证动态码参数
/// # 双因素认证验证码参数
///
/// 绑定确认 / 关闭 / 重新生成备用码 均需提交一个 TOTP 动态码二次确认
/// 绑定确认 / 关闭 / 重新生成备用码 均需提交验证码二次确认,
/// 关闭和重新生成支持 TOTP 动态码或备用码(BACKUP)两种类型。
///
@Data
@Accessors(chain = true)
@Schema(title = "双因素认证动态码参数")
@Schema(title = "双因素认证验证码参数")
public class TwoFactorCodeParam {
@Schema(description = "TOTP 动态码")
@Schema(description = "验证码(TOTP 动态码或备用码)")
@NotBlank(message = "{validation.field.twoFactorCode.notBlank}")
private String code;
@Schema(description = "验证码类型: TOTP(动态码, 默认) | BACKUP(备用码)")
private String codeType;
@Schema(description = "绑定初始化返回的密钥(仅绑定确认时必传, 其它场景从已绑定记录读取)")
private String secret;
}

View File

@@ -21,7 +21,4 @@ public class TwoFactorStatusResult {
@Schema(description = "剩余可用备用验证码数量")
private Integer backupCodesRemaining;
@Schema(description = "最后验证时间(UTC, 毫秒时间戳), 未验证返回null")
private Long lastVerifyTime;
}

View File

@@ -1,7 +1,5 @@
package cn.daxpay.open.platform.iam.service.twofactor;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Optional;
@@ -59,9 +57,7 @@ public class UserTwoFactorService {
if (bound.isPresent()) {
UserTwoFactor entity = bound.get();
result.setBound(true)
.setBackupCodesRemaining(entity.getBackupCodesRemaining())
.setLastVerifyTime(entity.getLastVerifyTime() == null ? null
: entity.getLastVerifyTime().toInstant().toEpochMilli());
.setBackupCodesRemaining(entity.getBackupCodesRemaining());
}
else {
result.setBound(false);
@@ -110,31 +106,30 @@ public class UserTwoFactorService {
.setUserId(userId)
.setSecret(secret)
.setBackupCodes(JSONUtil.toJsonStr(generated.entries()))
.setBackupCodesRemaining(count)
.setLastVerifyTime(OffsetDateTime.now(ZoneOffset.UTC));
.setBackupCodesRemaining(count);
userTwoFactorManager.save(entity);
return new BackupCodeResult()
.setCodes(generated.plaintextCodes())
.setTotal(count);
}
/// 关闭双因素认证: 需校验动态码二次确认
/// 关闭双因素认证: 需校验动态码或备用码二次确认
@Transactional(rollbackFor = Exception.class)
public void disable(String code) {
public void disable(String code, String codeType) {
Long userId = SecurityUtil.getUserId();
UserTwoFactor entity = requireBound(userId);
if (!totpService.verifyCode(entity.getSecret(), code)) {
if (!verifyByCodeType(entity, code, codeType)) {
throw new OperationFailException("error.iam.twoFactor.codeError");
}
userTwoFactorManager.deleteById(entity.getId());
}
/// 重新生成备用验证码: 需校验动态码, 旧备用码全部作废
/// 重新生成备用验证码: 需校验动态码或备用码, 旧备用码全部作废
@Transactional(rollbackFor = Exception.class)
public BackupCodeResult regenerateBackupCodes(String code) {
public BackupCodeResult regenerateBackupCodes(String code, String codeType) {
Long userId = SecurityUtil.getUserId();
UserTwoFactor entity = requireBound(userId);
if (!totpService.verifyCode(entity.getSecret(), code)) {
if (!verifyByCodeType(entity, code, codeType)) {
throw new OperationFailException("error.iam.twoFactor.codeError");
}
int count = defaultBackupCodesCount();
@@ -166,20 +161,22 @@ public class UserTwoFactorService {
return false;
}
boolean valid = totpService.verifyCode(entity.getSecret(), code);
if (valid) {
updateLastVerifyTime(entity);
}
return valid;
}
/// 校验并消费备用验证码(命中即置 used, 返回 true; 无效返回 false)
/// 校验并消费备用验证码(登录二次验证用, 通过 userId 查 entity)
@Transactional(rollbackFor = Exception.class)
public boolean consumeBackupCode(Long userId, String code) {
if (StrUtil.isBlank(code)) {
UserTwoFactor entity = userTwoFactorManager.findByUserId(userId).orElse(null);
if (entity == null) {
return false;
}
UserTwoFactor entity = userTwoFactorManager.findByUserId(userId).orElse(null);
if (entity == null || StrUtil.isBlank(entity.getBackupCodes())) {
return consumeBackupCodeInternal(entity, code);
}
/// 校验并消费备用验证码(内部方法, 已持有 entity, 命中即置 used)
private boolean consumeBackupCodeInternal(UserTwoFactor entity, String code) {
if (StrUtil.isBlank(code) || StrUtil.isBlank(entity.getBackupCodes())) {
return false;
}
List<BackupCodeEntry> entries = JSONUtil.toList(entity.getBackupCodes(), BackupCodeEntry.class);
@@ -196,25 +193,22 @@ public class UserTwoFactorService {
}
if (consumed) {
entity.setBackupCodes(JSONUtil.toJsonStr(entries))
.setBackupCodesRemaining(remaining)
.setLastVerifyTime(OffsetDateTime.now(ZoneOffset.UTC));
.setBackupCodesRemaining(remaining);
userTwoFactorManager.updateById(entity);
}
return consumed;
}
/// 更新最后验证时间(登录验证成功后调用)
public void updateLastVerifyTime(Long userId) {
userTwoFactorManager.findByUserId(userId).ifPresent(this::updateLastVerifyTime);
/// 按验证码类型校验: BACKUP 校验并消费备用码, 其他校验 TOTP 动态码
private boolean verifyByCodeType(UserTwoFactor entity, String code, String codeType) {
if ("BACKUP".equalsIgnoreCase(codeType)) {
return consumeBackupCodeInternal(entity, code);
}
return totpService.verifyCode(entity.getSecret(), code);
}
// ==================== 内部方法 ====================
private void updateLastVerifyTime(UserTwoFactor entity) {
entity.setLastVerifyTime(OffsetDateTime.now(ZoneOffset.UTC));
userTwoFactorManager.updateById(entity);
}
/// 校验平台是否启用双因素认证
private void checkPlatformEnabled() {
if (!Boolean.TRUE.equals(getConfig().getEnabled())) {