diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/error/auth/auth.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/error/auth.json similarity index 100% rename from daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/error/auth/auth.json rename to daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/error/auth.json diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/error/auth/auth.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/error/auth.json similarity index 100% rename from daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/error/auth/auth.json rename to daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/error/auth.json diff --git a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/auth/service/twofactor/TwoFactorPreAuthService.java b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/auth/service/twofactor/TwoFactorPreAuthService.java index 1a0e46ca2..096e5e040 100644 --- a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/auth/service/twofactor/TwoFactorPreAuthService.java +++ b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/auth/service/twofactor/TwoFactorPreAuthService.java @@ -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) { } diff --git a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/controller/user/UserTwoFactorController.java b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/controller/user/UserTwoFactorController.java index fafd21469..b3e2db84b 100644 --- a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/controller/user/UserTwoFactorController.java +++ b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/controller/user/UserTwoFactorController.java @@ -51,13 +51,13 @@ public class UserTwoFactorController { @Operation(summary = "关闭双因素认证") @PostMapping("/disable") public Result 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 regenerateBackupCodes(@RequestBody @Validated TwoFactorCodeParam param) { - return Res.ok(userTwoFactorService.regenerateBackupCodes(param.getCode())); + return Res.ok(userTwoFactorService.regenerateBackupCodes(param.getCode(), param.getCodeType())); } } diff --git a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/endpoint/TokenService.java b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/endpoint/TokenService.java index 58c06c621..ab24ac474 100644 --- a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/endpoint/TokenService.java +++ b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/endpoint/TokenService.java @@ -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() diff --git a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/entity/twofactor/UserTwoFactor.java b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/entity/twofactor/UserTwoFactor.java index f9e1a4a4b..cc8d9a79f 100644 --- a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/entity/twofactor/UserTwoFactor.java +++ b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/entity/twofactor/UserTwoFactor.java @@ -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; } diff --git a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/param/twofactor/TwoFactorCodeParam.java b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/param/twofactor/TwoFactorCodeParam.java index d27ea1cb0..59bd1fe50 100644 --- a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/param/twofactor/TwoFactorCodeParam.java +++ b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/param/twofactor/TwoFactorCodeParam.java @@ -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; } diff --git a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/result/twofactor/TwoFactorStatusResult.java b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/result/twofactor/TwoFactorStatusResult.java index d0a287434..0a602e51c 100644 --- a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/result/twofactor/TwoFactorStatusResult.java +++ b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/result/twofactor/TwoFactorStatusResult.java @@ -21,7 +21,4 @@ public class TwoFactorStatusResult { @Schema(description = "剩余可用备用验证码数量") private Integer backupCodesRemaining; - - @Schema(description = "最后验证时间(UTC, 毫秒时间戳), 未验证返回null") - private Long lastVerifyTime; } diff --git a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/service/twofactor/UserTwoFactorService.java b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/service/twofactor/UserTwoFactorService.java index ef7dae7e9..5482a6eb1 100644 --- a/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/service/twofactor/UserTwoFactorService.java +++ b/daxpay-platform/daxpay-platform-service/service-iam/src/main/java/cn/daxpay/open/platform/iam/service/twofactor/UserTwoFactorService.java @@ -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 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())) {