SpringBoot 敏感操作二次验证:资金转账、删库等高危操作防护实战
在金融科技和企业级应用中,敏感操作的安全性至关重要。本文将深入探讨如何在 SpringBoot 应用中实现灵活、可靠的二次验证机制,为资金转账、数据删除等高危操作提供双重保障。
目录
为什么需要二次验证
真实案例警示
案例1:某电商平台因员工账号被盗,攻击者在已登录状态下直接发起大额转账,
造成数百万损失。若有关键操作二次验证,可有效阻断。
案例2:某SaaS公司运维人员误操作执行了 DROP DATABASE,导致全站数据丢失。
若有敏感操作二次确认,可避免悲剧发生。
案例3:某银行系统遭遇CSRF攻击,用户在已登录状态下被诱导发起转账请求。
二次验证可有效防御此类攻击。
适用场景
| 风险等级 | 操作类型 | 建议验证方式 |
|---|---|---|
| 🔴 极高 | 资金转账、账户注销、权限变更 | 人脸 + 短信双因子 |
| 🟠 高 | 批量数据删除、系统配置修改 | 短信验证码 |
| 🟡 中 | 密码修改、绑定手机/邮箱 | 短信或邮件验证码 |
| 🟢 低 | 普通信息修改、查询操作 | 可选验证或无需验证 |
整体架构设计
系统架构图
flowchart TB
subgraph 客户端层
Web[Web前端]
App[移动App]
Admin[管理后台]
end
subgraph 网关层
Gateway[API网关]
Auth[认证中心]
end
subgraph 业务服务层
Controller[Controller层]
AOP[二次验证AOP拦截器]
Service[业务服务层]
end
subgraph 验证服务层
SMS[短信验证服务]
Face[人脸识别服务]
Email[邮件验证服务]
Risk[风控评估服务]
end
subgraph 基础设施层
Redis[(Redis<br/>验证状态缓存)]
DB[(数据库)]
MQ[消息队列]
end
Web --> Gateway
App --> Gateway
Admin --> Gateway
Gateway --> Auth
Auth --> Controller
Controller --> AOP
AOP --> Service
AOP -.-> SMS
AOP -.-> Face
AOP -.-> Email
AOP -.-> Risk
SMS --> Redis
Face --> Redis
Risk --> Redis
Service --> DB
SMS --> MQ
核心设计思想
- 声明式验证:通过注解标记需要二次验证的方法
- 策略模式:支持多种验证方式(短信、人脸、邮箱等)
- AOP拦截:无侵入式实现,业务代码零耦合
- 状态管理:Redis缓存验证状态,支持过期时间
- 风控集成:结合用户行为分析,动态调整验证强度
核心实现方案
1. 定义验证级别枚举
/**
* 敏感操作验证级别
*/
public enum VerificationLevel {
/**
* 无需验证
*/
NONE(0, "无需验证"),
/**
* 短信验证码
*/
SMS(1, "短信验证码"),
/**
* 邮箱验证码
*/
EMAIL(2, "邮箱验证码"),
/**
* 人脸识别
*/
FACE(3, "人脸识别"),
/**
* 短信 + 人脸双因子
*/
SMS_AND_FACE(4, "短信+人脸双因子"),
/**
* 根据风控动态决定
*/
DYNAMIC(5, "动态风控验证");
private final int code;
private final String description;
VerificationLevel(int code, String description) {
this.code = code;
this.description = description;
}
public int getCode() {
return code;
}
public String getDescription() {
return description;
}
}
2. 核心注解定义
/**
* 敏感操作二次验证注解
* 标记在Controller方法上,触发二次验证流程
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface SensitiveOperation {
/**
* 验证级别
*/
VerificationLevel level() default VerificationLevel.SMS;
/**
* 操作类型标识
*/
String operationType();
/**
* 操作描述(用于短信/日志)
*/
String operationDesc() default "";
/**
* 验证有效期(分钟)
*/
int verifyTimeout() default 5;
/**
* 验证通过后操作有效期(分钟)
*/
int actionTimeout() default 10;
/**
* 是否需要验证密码
*/
boolean requirePassword() default false;
/**
* 风险参数SpEL表达式(用于动态风控)
*/
String riskExpression() default "";
/**
* 自定义验证器(扩展用)
*/
Class<? extends CustomVerifier>[] customVerifiers() default {};
}
3. 验证上下文对象
/**
* 二次验证上下文
* 贯穿整个验证流程
*/
@Data
@Builder
public class VerificationContext {
/**
* 用户ID
*/
private String userId;
/**
* 操作类型
*/
private String operationType;
/**
* 操作描述
*/
private String operationDesc;
/**
* 验证级别
*/
private VerificationLevel level;
/**
* 请求IP
*/
private String clientIp;
/**
* 设备指纹
*/
private String deviceFingerprint;
/**
* 验证令牌(首次验证后生成)
*/
private String verifyToken;
/**
* 验证状态
*/
private VerificationStatus status;
/**
* 扩展属性
*/
private Map<String, Object> extraParams;
/**
* 创建时间
*/
private LocalDateTime createTime;
public enum VerificationStatus {
PENDING, // 待验证
SMS_VERIFIED, // 短信已验证
FACE_VERIFIED, // 人脸已验证
COMPLETED, // 验证完成
EXPIRED // 已过期
}
}
短信验证码验证
短信验证流程
sequenceDiagram
participant U as 用户
participant C as 客户端
participant S as 服务端
participant R as Redis
participant SMS as 短信服务商
U->>C: 发起敏感操作(如转账)
C->>S: POST /api/transfer/apply
S->>S: AOP拦截,检测到需要二次验证
S->>R: 生成verifyToken,存储验证上下文
S->>SMS: 发送验证码
SMS->>U: 短信:【XXX】验证码123456
S->>C: 返回:需要二次验证,verifyToken=xxx
U->>C: 输入验证码
C->>S: POST /api/verify/sms<br/>{verifyToken, code}
S->>R: 校验验证码
S->>R: 更新验证状态为SMS_VERIFIED
S->>C: 验证成功
C->>S: POST /api/transfer/confirm<br/>{verifyToken, ...}
S->>R: 校验verifyToken有效性
S->>S: 执行转账操作
S->>R: 删除验证状态
S->>C: 操作成功
短信验证服务实现
@Service
@Slf4j
public class SmsVerificationService {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private SmsSender smsSender;
private static final String SMS_CODE_PREFIX = "sensitive:sms:";
private static final String VERIFY_TOKEN_PREFIX = "sensitive:token:";
private static final String SMS_COUNT_PREFIX = "sensitive:sms:count:";
/**
* 发送短信验证码
*/
public SmsCodeSendResult sendVerificationCode(VerificationContext context) {
String userId = context.getUserId();
String operationType = context.getOperationType();
// 限流检查:同一用户1分钟内只能发送1次
String countKey = SMS_COUNT_PREFIX + userId;
String count = redisTemplate.opsForValue().get(countKey);
if (count != null && Integer.parseInt(count) >= 1) {
throw new VerificationException("发送过于频繁,请1分钟后再试");
}
// 生成6位验证码
String code = RandomStringUtils.randomNumeric(6);
// 生成验证令牌
String verifyToken = UUID.randomUUID().toString().replace("-", "");
// 存储验证码
String smsKey = SMS_CODE_PREFIX + verifyToken;
redisTemplate.opsForValue().set(smsKey, code, 5, TimeUnit.MINUTES);
// 存储验证上下文
String tokenKey = VERIFY_TOKEN_PREFIX + verifyToken;
context.setVerifyToken(verifyToken);
context.setStatus(VerificationContext.VerificationStatus.PENDING);
context.setCreateTime(LocalDateTime.now());
redisTemplate.opsForValue().set(
tokenKey,
JSON.toJSONString(context),
context.getLevel().getActionTimeout(),
TimeUnit.MINUTES
);
// 记录发送次数
redisTemplate.opsForValue().increment(countKey);
redisTemplate.expire(countKey, 1, TimeUnit.MINUTES);
// 发送短信
String message = String.format("【%s】您正在进行%s操作,验证码:%s,%d分钟内有效。如非本人操作,请忽略。",
"安全中心",
context.getOperationDesc(),
code,
5
);
// 异步发送短信
CompletableFuture.runAsync(() -> {
try {
smsSender.send(context.getExtraParams().get("phone").toString(), message);
log.info("短信发送成功,userId={}, operation={}", userId, operationType);
} catch (Exception e) {
log.error("短信发送失败", e);
}
});
return SmsCodeSendResult.builder()
.verifyToken(verifyToken)
.expireSeconds(300)
.build();
}
/**
* 校验短信验证码
*/
public boolean verifySmsCode(String verifyToken, String inputCode) {
String smsKey = SMS_CODE_PREFIX + verifyToken;
String storedCode = redisTemplate.opsForValue().get(smsKey);
if (storedCode == null) {
throw new VerificationException("验证码已过期,请重新获取");
}
if (!storedCode.equals(inputCode)) {
// 记录错误次数,超过3次删除验证码
String errorKey = smsKey + ":error";
Long errors = redisTemplate.opsForValue().increment(errorKey);
if (errors >= 3) {
redisTemplate.delete(smsKey);
redisTemplate.delete(errorKey);
throw new VerificationException("验证码错误次数过多,请重新获取");
}
redisTemplate.expire(errorKey, 5, TimeUnit.MINUTES);
throw new VerificationException("验证码错误,还剩" + (3 - errors) + "次机会");
}
// 验证成功,删除验证码
redisTemplate.delete(smsKey);
redisTemplate.delete(smsKey + ":error");
// 更新验证状态
updateVerificationStatus(verifyToken, VerificationContext.VerificationStatus.SMS_VERIFIED);
return true;
}
/**
* 更新验证状态
*/
private void updateVerificationStatus(String verifyToken,
VerificationContext.VerificationStatus status) {
String tokenKey = VERIFY_TOKEN_PREFIX + verifyToken;
String contextJson = redisTemplate.opsForValue().get(tokenKey);
if (contextJson == null) {
throw new VerificationException("验证会话已过期");
}
VerificationContext context = JSON.parseObject(contextJson, VerificationContext.class);
context.setStatus(status);
redisTemplate.opsForValue().set(tokenKey, JSON.toJSONString(context));
}
}
人脸识别验证
人脸验证流程
sequenceDiagram
participant U as 用户
participant C as 客户端
participant S as 服务端
participant R as Redis
participant Face as 人脸识别服务<br/>(阿里云/腾讯云/百度AI)
U->>C: 发起敏感操作
C->>S: POST /api/sensitive/apply
S->>R: 生成verifyToken
S->>C: 返回:需要人脸验证,verifyToken=xxx
U->>C: 拍摄人脸
C->>S: POST /api/verify/face/get-token<br/>{verifyToken}
S->>Face: 获取人脸核验Token
Face->>S: 返回核验URL/Token
S->>C: 返回人脸核验页面URL
U->>Face: 完成人脸采集和比对
Face->>S: 回调:核验结果
S->>R: 更新验证状态为FACE_VERIFIED
S->>C: 通知:人脸验证通过
C->>S: POST /api/sensitive/confirm<br/>{verifyToken, ...}
S->>S: 执行敏感操作
S->>C: 操作成功
人脸验证服务实现
@Service
@Slf4j
public class FaceVerificationService {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private FaceRecognitionClient faceClient;
private static final String FACE_VERIFY_PREFIX = "sensitive:face:";
private static final String VERIFY_TOKEN_PREFIX = "sensitive:token:";
/**
* 获取人脸验证Token
*/
public FaceVerifyTokenResult getFaceVerifyToken(String verifyToken, String userId) {
// 获取用户已注册的人脸特征ID
String faceId = getUserFaceId(userId);
if (faceId == null) {
throw new VerificationException("您尚未开通人脸验证,请先完成人脸注册");
}
// 调用人脸识别服务获取核验Token
FaceVerifyRequest request = FaceVerifyRequest.builder()
.faceId(faceId)
.verifyToken(verifyToken)
.callbackUrl("https://api.example.com/callback/face")
.build();
FaceVerifyResponse response = faceClient.createVerifySession(request);
// 存储人脸验证会话
String faceKey = FACE_VERIFY_PREFIX + verifyToken;
redisTemplate.opsForValue().set(
faceKey,
JSON.toJSONString(response),
10,
TimeUnit.MINUTES
);
return FaceVerifyTokenResult.builder()
.verifyToken(verifyToken)
.faceVerifyUrl(response.getVerifyUrl())
.expireSeconds(600)
.build();
}
/**
* 人脸验证回调处理
*/
@Transactional
public void handleFaceVerifyCallback(FaceVerifyCallback callback) {
String verifyToken = callback.getVerifyToken();
String faceKey = FACE_VERIFY_PREFIX + verifyToken;
// 验证回调签名(防止伪造)
if (!verifyCallbackSignature(callback)) {
log.error("人脸验证回调签名验证失败");
throw new VerificationException("非法回调请求");
}
// 检查验证结果
if (!"SUCCESS".equals(callback.getResult())) {
log.warn("人脸验证失败,verifyToken={}, reason={}", verifyToken, callback.getFailReason());
// 记录失败次数
recordFailAttempt(verifyToken);
return;
}
// 验证成功,更新状态
String tokenKey = VERIFY_TOKEN_PREFIX + verifyToken;
String contextJson = redisTemplate.opsForValue().get(tokenKey);
if (contextJson == null) {
log.error("验证会话已过期,verifyToken={}", verifyToken);
return;
}
VerificationContext context = JSON.parseObject(contextJson, VerificationContext.class);
// 根据当前状态判断是单因子还是双因子验证
if (context.getStatus() == VerificationContext.VerificationStatus.SMS_VERIFIED) {
// 双因子验证完成
context.setStatus(VerificationContext.VerificationStatus.COMPLETED);
} else {
// 单因子验证完成
context.setStatus(VerificationContext.VerificationStatus.FACE_VERIFIED);
}
redisTemplate.opsForValue().set(tokenKey, JSON.toJSONString(context));
// 发送验证成功通知(WebSocket或SSE)
notifyClientVerifySuccess(verifyToken);
log.info("人脸验证成功,userId={}, operation={}", context.getUserId(), context.getOperationType());
}
/**
* 验证人脸验证结果
*/
public boolean verifyFaceResult(String verifyToken) {
String tokenKey = VERIFY_TOKEN_PREFIX + verifyToken;
String contextJson = redisTemplate.opsForValue().get(tokenKey);
if (contextJson == null) {
throw new VerificationException("验证会话已过期");
}
VerificationContext context = JSON.parseObject(contextJson, VerificationContext.class);
return context.getStatus() == VerificationContext.VerificationStatus.FACE_VERIFIED
|| context.getStatus() == VerificationContext.VerificationStatus.COMPLETED;
}
/**
* 获取用户人脸ID
*/
private String getUserFaceId(String userId) {
// 从数据库或缓存获取用户注册的人脸特征ID
// 这里简化处理,实际应从用户表查询
return redisTemplate.opsForValue().get("user:face:id:" + userId);
}
/**
* 验证回调签名
*/
private boolean verifyCallbackSignature(FaceVerifyCallback callback) {
// 实现签名验证逻辑
// 使用HMAC-SHA256验证回调数据完整性
String sign = callback.getSign();
String data = callback.getVerifyToken() + callback.getResult() + callback.getTimestamp();
String expectedSign = HmacUtils.hmacSha256Hex(faceClient.getSecretKey(), data);
return expectedSign.equals(sign);
}
/**
* 记录失败尝试
*/
private void recordFailAttempt(String verifyToken) {
String failKey = FACE_VERIFY_PREFIX + verifyToken + ":fail";
Long fails = redisTemplate.opsForValue().increment(failKey);
redisTemplate.expire(failKey, 30, TimeUnit.MINUTES);
if (fails >= 3) {
// 失败3次,清除验证会话
redisTemplate.delete(FACE_VERIFY_PREFIX + verifyToken);
redisTemplate.delete(VERIFY_TOKEN_PREFIX + verifyToken);
}
}
/**
* 通知客户端验证成功
*/
private void notifyClientVerifySuccess(String verifyToken) {
// 通过WebSocket或SSE推送验证结果
// 这里简化处理
log.info("通知客户端验证成功,verifyToken={}", verifyToken);
}
}
组合验证策略
双因子验证流程(短信+人脸)
sequenceDiagram
participant U as 用户
participant C as 客户端
participant S as 服务端
participant R as Redis
U->>C: 发起大额转账
C->>S: POST /api/transfer/large/apply
Note over S: @SensitiveOperation(level = SMS_AND_FACE)
S->>R: 创建验证会话
S->>C: 返回:需要短信+人脸双因子验证
par 短信验证
S->>U: 发送短信验证码
U->>C: 输入验证码
C->>S: POST /api/verify/sms
S->>R: 标记SMS_VERIFIED
and 人脸验证
U->>C: 启动人脸验证
C->>S: POST /api/verify/face/get-token
S->>C: 返回人脸核验URL
U->>C: 完成人脸采集
C->>S: 人脸验证回调
S->>R: 标记FACE_VERIFIED → COMPLETED
end
C->>S: POST /api/transfer/large/confirm<br/>{verifyToken, ...}
S->>R: 检查验证状态为COMPLETED
S->>S: 执行转账
S->>R: 清除验证状态
S->>C: 转账成功
验证策略管理器
@Component
public class VerificationStrategyManager {
@Autowired
private SmsVerificationService smsService;
@Autowired
private FaceVerificationService faceService;
@Autowired
private RiskControlService riskService;
/**
* 执行验证流程
*/
public VerificationResult initiateVerification(VerificationContext context) {
VerificationLevel level = determineVerificationLevel(context);
switch (level) {
case SMS:
return initiateSmsVerification(context);
case FACE:
return initiateFaceVerification(context);
case SMS_AND_FACE:
return initiateDualVerification(context);
case DYNAMIC:
return initiateDynamicVerification(context);
default:
throw new UnsupportedOperationException("不支持的验证级别");
}
}
/**
* 确定验证级别(支持动态风控)
*/
private VerificationLevel determineVerificationLevel(VerificationContext context) {
if (context.getLevel() != VerificationLevel.DYNAMIC) {
return context.getLevel();
}
// 动态风控评估
RiskAssessment assessment = riskService.assess(context);
if (assessment.getRiskScore() > 80) {
return VerificationLevel.SMS_AND_FACE;
} else if (assessment.getRiskScore() > 50) {
return VerificationLevel.FACE;
} else {
return VerificationLevel.SMS;
}
}
/**
* 双因子验证初始化
*/
private VerificationResult initiateDualVerification(VerificationContext context) {
// 先生成统一的verifyToken
String verifyToken = UUID.randomUUID().toString().replace("-", "");
context.setVerifyToken(verifyToken);
// 存储验证上下文
storeVerificationContext(context);
// 发送短信验证码
smsService.sendVerificationCode(context);
// 获取人脸验证Token
FaceVerifyTokenResult faceResult = faceService.getFaceVerifyToken(verifyToken, context.getUserId());
return VerificationResult.builder()
.verifyToken(verifyToken)
.level(VerificationLevel.SMS_AND_FACE)
.smsSent(true)
.faceVerifyUrl(faceResult.getFaceVerifyUrl())
.message("请完成短信验证和人脸识别")
.build();
}
/**
* 动态验证(基于风控)
*/
private VerificationResult initiateDynamicVerification(VerificationContext context) {
VerificationLevel level = determineVerificationLevel(context);
context.setLevel(level);
log.info("动态验证级别判定:userId={}, level={}", context.getUserId(), level);
return initiateVerification(context);
}
/**
* 验证最终状态
*/
public boolean verifyFinalStatus(String verifyToken, VerificationLevel requiredLevel) {
VerificationContext context = getVerificationContext(verifyToken);
if (context == null || context.getStatus() == VerificationContext.VerificationStatus.EXPIRED) {
throw new VerificationException("验证会话已过期");
}
switch (requiredLevel) {
case SMS:
return context.getStatus() == VerificationContext.VerificationStatus.SMS_VERIFIED
|| context.getStatus() == VerificationContext.VerificationStatus.COMPLETED;
case FACE:
return context.getStatus() == VerificationContext.VerificationStatus.FACE_VERIFIED
|| context.getStatus() == VerificationContext.VerificationStatus.COMPLETED;
case SMS_AND_FACE:
return context.getStatus() == VerificationContext.VerificationStatus.COMPLETED;
default:
return false;
}
}
}
安全增强措施
1. 风控规则引擎
@Service
public class RiskControlService {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private UserBehaviorService behaviorService;
/**
* 风险评估
*/
public RiskAssessment assess(VerificationContext context) {
int riskScore = 0;
List<String> riskFactors = new ArrayList<>();
// 规则1:异地登录检测
if (isAbnormalLocation(context.getUserId(), context.getClientIp())) {
riskScore += 30;
riskFactors.add("异地登录");
}
// 规则2:非常用设备
if (isNewDevice(context.getUserId(), context.getDeviceFingerprint())) {
riskScore += 20;
riskFactors.add("非常用设备");
}
// 规则3:操作频率异常
if (isHighFrequency(context.getUserId(), context.getOperationType())) {
riskScore += 25;
riskFactors.add("操作频率异常");
}
// 规则4:敏感时间段(凌晨操作)
if (isSensitiveTime()) {
riskScore += 15;
riskFactors.add("敏感时间段");
}
// 规则5:大额操作
if (isLargeAmount(context)) {
riskScore += 20;
riskFactors.add("大额操作");
}
return RiskAssessment.builder()
.riskScore(Math.min(riskScore, 100))
.riskFactors(riskFactors)
.suggestedLevel(determineLevelByScore(riskScore))
.build();
}
/**
* 异地登录检测
*/
private boolean isAbnormalLocation(String userId, String currentIp) {
String usualIp = behaviorService.getUsualIp(userId);
if (usualIp == null) {
return false;
}
return !usualIp.equals(currentIp);
}
/**
* 是否敏感时间(0点-6点)
*/
private boolean isSensitiveTime() {
int hour = LocalDateTime.now().getHour();
return hour >= 0 && hour < 6;
}
private VerificationLevel determineLevelByScore(int score) {
if (score > 80) return VerificationLevel.SMS_AND_FACE;
if (score > 50) return VerificationLevel.FACE;
if (score > 20) return VerificationLevel.SMS;
return VerificationLevel.NONE;
}
}
2. 防重放攻击
@Component
public class ReplayAttackPrevention {
@Autowired
private StringRedisTemplate redisTemplate;
private static final String NONCE_PREFIX = "sensitive:nonce:";
/**
* 生成随机数
*/
public String generateNonce() {
return UUID.randomUUID().toString().replace("-", "");
}
/**
* 验证随机数(防止重放)
*/
public boolean validateNonce(String nonce) {
String key = NONCE_PREFIX + nonce;
Boolean success = redisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.MINUTES);
return Boolean.TRUE.equals(success);
}
/**
* 请求签名验证
*/
public boolean verifyRequestSignature(HttpServletRequest request, String secret) {
String timestamp = request.getHeader("X-Timestamp");
String nonce = request.getHeader("X-Nonce");
String signature = request.getHeader("X-Signature");
// 检查时间戳(5分钟内有效)
long requestTime = Long.parseLong(timestamp);
if (System.currentTimeMillis() - requestTime > 5 * 60 * 1000) {
return false;
}
// 检查随机数
if (!validateNonce(nonce)) {
return false;
}
// 验证签名
String data = timestamp + nonce + request.getRequestURI();
String expectedSign = HmacUtils.hmacSha256Hex(secret, data);
return expectedSign.equals(signature);
}
}
3. 审计日志
@Aspect
@Component
@Slf4j
public class SensitiveOperationAuditAspect {
@Autowired
private AuditLogRepository auditLogRepository;
@Around("@annotation(sensitiveOperation)")
public Object around(ProceedingJoinPoint point, SensitiveOperation sensitiveOperation) throws Throwable {
String operationType = sensitiveOperation.operationType();
String operationDesc = sensitiveOperation.operationDesc();
// 获取当前用户和请求信息
String userId = SecurityUtils.getCurrentUserId();
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder
.getRequestAttributes()).getRequest();
// 记录操作开始
AuditLog auditLog = AuditLog.builder()
.userId(userId)
.operationType(operationType)
.operationDesc(operationDesc)
.clientIp(IpUtils.getIpAddress(request))
.userAgent(request.getHeader("User-Agent"))
.requestParams(getRequestParams(point))
.startTime(LocalDateTime.now())
.status(OperationStatus.PENDING)
.build();
auditLogRepository.save(auditLog);
try {
Object result = point.proceed();
// 记录成功
auditLog.setStatus(OperationStatus.SUCCESS);
auditLog.setEndTime(LocalDateTime.now());
auditLog.setResultSummary("操作成功");
auditLogRepository.save(auditLog);
return result;
} catch (Exception e) {
// 记录失败
auditLog.setStatus(OperationStatus.FAILED);
auditLog.setEndTime(LocalDateTime.now());
auditLog.setErrorMessage(e.getMessage());
auditLogRepository.save(auditLog);
throw e;
}
}
}
完整代码示例
AOP拦截器核心实现
@Aspect
@Component
@Slf4j
public class SensitiveOperationAspect {
@Autowired
private VerificationStrategyManager strategyManager;
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private ReplayAttackPrevention replayPrevention;
private static final String VERIFY_TOKEN_HEADER = "X-Verify-Token";
@Around("@annotation(sensitiveOperation)")
public Object aroundSensitiveOperation(ProceedingJoinPoint point,
SensitiveOperation sensitiveOperation) throws Throwable {
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder
.getRequestAttributes()).getRequest();
// 1. 防重放攻击检查
if (!replayPrevention.verifyRequestSignature(request, getSecretKey())) {
throw new SecurityException("非法请求");
}
// 2. 获取验证令牌
String verifyToken = request.getHeader(VERIFY_TOKEN_HEADER);
// 3. 构建验证上下文
VerificationContext context = buildVerificationContext(point, sensitiveOperation, request);
// 4. 如果没有验证令牌,发起验证流程
if (StringUtils.isBlank(verifyToken)) {
log.info("敏感操作需要二次验证:userId={}, operation={}",
context.getUserId(), sensitiveOperation.operationType());
VerificationResult result = strategyManager.initiateVerification(context);
// 返回需要验证的响应
return ResponseEntity.status(HttpStatus.PRECONDITION_REQUIRED)
.body(ApiResponse.verificationRequired(result));
}
// 5. 验证令牌有效性
if (!strategyManager.verifyFinalStatus(verifyToken, sensitiveOperation.level())) {
throw new VerificationException("验证未完成或已过期");
}
// 6. 验证通过,执行原方法
log.info("敏感操作验证通过,执行操作:userId={}, operation={}",
context.getUserId(), sensitiveOperation.operationType());
try {
Object result = point.proceed();
// 7. 操作成功,清除验证状态
clearVerificationStatus(verifyToken);
return result;
} catch (Exception e) {
log.error("敏感操作执行失败", e);
throw e;
}
}
/**
* 构建验证上下文
*/
private VerificationContext buildVerificationContext(ProceedingJoinPoint point,
SensitiveOperation annotation,
HttpServletRequest request) {
return VerificationContext.builder()
.userId(SecurityUtils.getCurrentUserId())
.operationType(annotation.operationType())
.operationDesc(annotation.operationDesc())
.level(annotation.level())
.clientIp(IpUtils.getIpAddress(request))
.deviceFingerprint(request.getHeader("X-Device-Fingerprint"))
.extraParams(extractRiskParams(point))
.build();
}
/**
* 提取风险参数(用于动态风控)
*/
private Map<String, Object> extractRiskParams(ProceedingJoinPoint point) {
Map<String, Object> params = new HashMap<>();
// 从方法参数中提取金额等敏感信息
Object[] args = point.getArgs();
for (Object arg : args) {
if (arg instanceof TransferRequest) {
TransferRequest req = (TransferRequest) arg;
params.put("amount", req.getAmount());
params.put("targetAccount", req.getTargetAccount());
params.put("phone", req.getPhone());
}
}
return params;
}
/**
* 清除验证状态
*/
private void clearVerificationStatus(String verifyToken) {
redisTemplate.delete("sensitive:token:" + verifyToken);
redisTemplate.delete("sensitive:sms:" + verifyToken);
redisTemplate.delete("sensitive:face:" + verifyToken);
}
}
Controller使用示例
@RestController
@RequestMapping("/api/transfer")
@Slf4j
public class TransferController {
@Autowired
private TransferService transferService;
/**
* 普通转账 - 短信验证
*/
@PostMapping("/normal")
@SensitiveOperation(
level = VerificationLevel.SMS,
operationType = "TRANSFER_NORMAL",
operationDesc = "普通转账",
verifyTimeout = 5,
actionTimeout = 10
)
public ApiResponse<TransferResult> normalTransfer(@RequestBody @Valid TransferRequest request) {
TransferResult result = transferService.executeTransfer(request);
return ApiResponse.success(result);
}
/**
* 大额转账 - 短信+人脸双因子验证
*/
@PostMapping("/large")
@SensitiveOperation(
level = VerificationLevel.SMS_AND_FACE,
operationType = "TRANSFER_LARGE",
operationDesc = "大额转账",
verifyTimeout = 5,
actionTimeout = 5,
riskExpression = "#request.amount > 50000"
)
public ApiResponse<TransferResult> largeTransfer(@RequestBody @Valid TransferRequest request) {
// 额外的大额风控检查
if (request.getAmount().compareTo(new BigDecimal("100000")) > 0) {
// 超过10万需要额外审批
transferService.submitForApproval(request);
return ApiResponse.success("转账申请已提交,等待审批");
}
TransferResult result = transferService.executeTransfer(request);
return ApiResponse.success(result);
}
/**
* 动态风控转账 - 根据风险评估动态选择验证方式
*/
@PostMapping("/dynamic")
@SensitiveOperation(
level = VerificationLevel.DYNAMIC,
operationType = "TRANSFER_DYNAMIC",
operationDesc = "智能风控转账"
)
public ApiResponse<TransferResult> dynamicTransfer(@RequestBody @Valid TransferRequest request) {
TransferResult result = transferService.executeTransfer(request);
return ApiResponse.success(result);
}
/**
* 删除数据库 - 最高级别验证
*/
@PostMapping("/admin/database/drop")
@SensitiveOperation(
level = VerificationLevel.SMS_AND_FACE,
operationType = "DATABASE_DROP",
operationDesc = "删除数据库",
requirePassword = true,
verifyTimeout = 3,
actionTimeout = 3
)
@PreAuthorize("hasRole('SUPER_ADMIN')")
public ApiResponse<String> dropDatabase(@RequestBody @Valid DropDatabaseRequest request) {
// 额外的密码验证
if (!verifyAdminPassword(request.getAdminPassword())) {
throw new VerificationException("管理员密码错误");
}
// 二次确认
if (!request.isConfirmed()) {
throw new VerificationException("请确认已备份数据并勾选确认框");
}
// 执行删除
databaseService.dropDatabase(request.getDatabaseName());
// 发送告警通知
alertService.sendCriticalAlert("数据库被删除", request);
return ApiResponse.success("数据库已删除");
}
}
验证接口Controller
@RestController
@RequestMapping("/api/verify")
@Slf4j
public class VerificationController {
@Autowired
private SmsVerificationService smsService;
@Autowired
private FaceVerificationService faceService;
@Autowired
private VerificationStrategyManager strategyManager;
/**
* 发送短信验证码
*/
@PostMapping("/sms/send")
public ApiResponse<SmsCodeSendResult> sendSmsCode(@RequestBody SendSmsRequest request) {
VerificationContext context = buildContext(request);
SmsCodeSendResult result = smsService.sendVerificationCode(context);
return ApiResponse.success(result);
}
/**
* 校验短信验证码
*/
@PostMapping("/sms/verify")
public ApiResponse<VerifyResult> verifySmsCode(@RequestBody VerifySmsRequest request) {
boolean success = smsService.verifySmsCode(request.getVerifyToken(), request.getCode());
if (success) {
// 检查是否还需要其他验证
boolean allCompleted = strategyManager.verifyFinalStatus(
request.getVerifyToken(),
VerificationLevel.SMS
);
return ApiResponse.success(VerifyResult.builder()
.verified(true)
.allCompleted(allCompleted)
.verifyToken(request.getVerifyToken())
.build());
}
return ApiResponse.error("验证码错误");
}
/**
* 获取人脸验证Token
*/
@PostMapping("/face/token")
public ApiResponse<FaceVerifyTokenResult> getFaceVerifyToken(@RequestBody FaceVerifyRequest request) {
FaceVerifyTokenResult result = faceService.getFaceVerifyToken(
request.getVerifyToken(),
SecurityUtils.getCurrentUserId()
);
return ApiResponse.success(result);
}
/**
* 人脸验证回调
*/
@PostMapping("/face/callback")
public ApiResponse<String> faceVerifyCallback(@RequestBody FaceVerifyCallback callback) {
faceService.handleFaceVerifyCallback(callback);
return ApiResponse.success("处理成功");
}
/**
* 查询验证状态
*/
@GetMapping("/status/{verifyToken}")
public ApiResponse<VerificationStatusResult> getVerificationStatus(@PathVariable String verifyToken) {
VerificationContext context = strategyManager.getVerificationContext(verifyToken);
if (context == null) {
return ApiResponse.error("验证会话不存在或已过期");
}
return ApiResponse.success(VerificationStatusResult.builder()
.status(context.getStatus())
.level(context.getLevel())
.expireTime(context.getCreateTime().plusMinutes(context.getLevel().getActionTimeout()))
.build());
}
}
最佳实践总结
1. 验证级别选择建议
| 场景 | 推荐验证级别 | 说明 |
|---|---|---|
| 小额转账 (< 1万) | SMS | 平衡安全与体验 |
| 大额转账 (> 5万) | SMS_AND_FACE | 双因子保障 |
| 账户注销 | SMS_AND_FACE | 不可逆操作 |
| 权限变更 | FACE 或 SMS_AND_FACE | 防止内部威胁 |
| 数据删除 | SMS_AND_FACE + 密码 | 最高级别保护 |
| API调用 | DYNAMIC | 基于风控动态调整 |
2. 安全 checklist
- 所有敏感操作必须经过二次验证
- 验证令牌使用 HTTPS 传输
- 验证码/验证结果使用一次性令牌
- 实现请求签名防止重放攻击
- 验证状态存储在服务端(Redis)
- 敏感操作记录完整审计日志
- 异常操作实时告警通知
- 支持强制下线/撤销验证会话
3. 性能优化建议
// 1. 使用Redis Pipeline批量操作
// 2. 短信发送异步化
// 3. 人脸验证结果缓存
// 4. 风控规则预计算
// 5. 验证状态本地缓存(Caffeine)
4. 监控指标
# 建议监控的指标
sensitive_operation:
- verification_success_rate # 验证成功率
- verification_latency # 验证耗时
- sms_send_success_rate # 短信发送成功率
- face_verify_success_rate # 人脸验证成功率
- risk_trigger_count # 风控触发次数
- audit_log_queue_size # 审计日志队列大小
小结
本文详细介绍了在 SpringBoot 应用中实现敏感操作二次验证的完整方案,包括:
- 架构设计:声明式注解 + AOP拦截 + 策略模式
- 验证方式:短信验证码、人脸识别、双因子验证
- 安全增强:风控规则、防重放攻击、审计日志
- 生产实践:完整的代码示例和最佳实践
通过这套方案,可以有效保护资金转账、数据删除等高危操作的安全性,同时保持良好的用户体验。
互动话题
- 你所在的团队是如何处理敏感操作的安全验证的?
- 在人脸识别和短信验证之间,你更倾向于使用哪种?为什么?
- 对于内部员工的敏感操作,你认为还需要哪些额外的防护措施?
标题:SpringBoot 敏感操作二次验证:资金转账、删库等高危操作防护实战
作者:jiangyi
地址:http://www.jiangyi.space/articles/2026/03/03/1772431014340.html
公众号:服务端技术精选
- 目录
- 为什么需要二次验证
- 真实案例警示
- 适用场景
- 整体架构设计
- 系统架构图
- 核心设计思想
- 核心实现方案
- 1. 定义验证级别枚举
- 2. 核心注解定义
- 3. 验证上下文对象
- 短信验证码验证
- 短信验证流程
- 短信验证服务实现
- 人脸识别验证
- 人脸验证流程
- 人脸验证服务实现
- 组合验证策略
- 双因子验证流程(短信+人脸)
- 验证策略管理器
- 安全增强措施
- 1. 风控规则引擎
- 2. 防重放攻击
- 3. 审计日志
- 完整代码示例
- AOP拦截器核心实现
- Controller使用示例
- 验证接口Controller
- 最佳实践总结
- 1. 验证级别选择建议
- 2. 安全 checklist
- 3. 性能优化建议
- 4. 监控指标
- 小结
- 互动话题
评论
0 评论