mirror of
https://gitee.com/dromara/dax-pay
synced 2026-08-10 14:56:06 +08:00
fix(auth): 修复双因素认证若干问题
- 预认证令牌改为先读取后删除, 允许验证码错误后重试 - 关闭/重置备用码支持备用码验证(TOTP不可用时不被锁死) - lastVerifyTime 改用 OffsetDateTime, 走国际化时间格式化 - 修复 error.auth.* i18n 文件路径映射(auth.json 移至 error/ 下)
This commit is contained in:
@@ -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) {
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,4 @@ public class TwoFactorStatusResult {
|
||||
|
||||
@Schema(description = "剩余可用备用验证码数量")
|
||||
private Integer backupCodesRemaining;
|
||||
|
||||
@Schema(description = "最后验证时间(UTC, 毫秒时间戳), 未验证返回null")
|
||||
private Long lastVerifyTime;
|
||||
}
|
||||
|
||||
@@ -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())) {
|
||||
|
||||
Reference in New Issue
Block a user