找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

1683

积分

0

好友

216

主题
发表于 2026-2-11 21:15:44 | 查看: 32| 回复: 0

项目结构(生产推荐)

captcha-service 是一个基于 Spring Boot 构建的验证码微服务生产骨架,它整合了滑块验证与行为验证,并采用 Redis 进行状态管理。

captcha-service
├── controller
│   └── CaptchaController.java
├── service
│   ├── SliderCaptchaService.java
│   ├── BehaviorCaptchaService.java
│   └── RiskDecisionService.java
├── model
│   ├── SliderTrack.java
│   ├── BehaviorEvent.java
│   ├── CaptchaResult.java
│   └── CaptchaDecision.java
├── util
│   ├── TokenUtil.java
│   └── RiskUtil.java
├── config
│   └── RedisConfig.java
└── CaptchaApplication.java

核心数据模型

1️⃣ 决策枚举

public enum CaptchaDecision {
    PASS,
    SLIDER,
    REJECT
}

2️⃣ 返回结果

@Data
@AllArgsConstructor
public class CaptchaResult {
    private CaptchaDecision decision;
    private String captchaToken;
}

3️⃣ 滑块轨迹

@Data
public class SliderTrack {
    private int x;
    private int y;
    private long t;
}

4️⃣ 行为事件

@Data
public class BehaviorEvent {
    private String type;   // move / click / key
    private int x;
    private int y;
    private long timestamp;
}

Token 工具(一次性 + 防重放)

为每次验证生成一个一次性令牌,是防止重放攻击的基础。

public class TokenUtil {

    private static final String SECRET = "captcha-secret";

    public static String generate(String sessionId) {
        String raw = sessionId + ":" + System.currentTimeMillis() + ":" + UUID.randomUUID();
        return DigestUtils.sha256Hex(raw + SECRET);
    }
}

滑块验证码 Service(重点)

滑块验证的核心逻辑包含两个维度:最终位置校验和拖拽轨迹分析。

@Service
public class SliderCaptchaService {

    public boolean verifyPosition(int userX, int targetX) {
        return Math.abs(userX - targetX) <= 5;
    }

    public boolean verifyTrack(List<SliderTrack> tracks) {
        if (tracks == null || tracks.size() < 10) return false;

        long duration = tracks.get(tracks.size() - 1).getT()
                - tracks.get(0).getT();
        if (duration < 300) return false;

        boolean acc = false, dec = false;

        for (int i = 2; i < tracks.size(); i++) {
            int dx1 = tracks.get(i - 1).getX() - tracks.get(i - 2).getX();
            int dx2 = tracks.get(i).getX() - tracks.get(i - 1).getX();
            if (dx2 > dx1) acc = true;
            if (dx2 < dx1) dec = true;
        }
        return acc && dec;
    }

    public boolean verify(int userX, int targetX, List<SliderTrack> tracks) {
        return verifyPosition(userX, targetX) && verifyTrack(tracks);
    }
}

⚠️ 注意:
前端轨迹只是“参考”,最终裁决一定在服务端

行为验证码 Service(风控核心)

无感验证码的核心在于通过收集用户在页面上的行为数据(鼠标移动、点击、键盘事件)进行风险评分。

@Service
public class BehaviorCaptchaService {

    public double score(List<BehaviorEvent> events) {
        double score = 0;
        score += timeScore(events);
        score += trajectoryScore(events);
        score += entropyScore(events);
        return score;
    }

    private double timeScore(List<BehaviorEvent> events) {
        long duration = events.get(events.size() - 1).getTimestamp()
                - events.get(0).getTimestamp();

        if (duration < 500) return -50;
        if (duration > 10000) return -10;
        return 30;
    }

    private double trajectoryScore(List<BehaviorEvent> events) {
        int straight = 0;
        for (int i = 2; i < events.size(); i++) {
            int dx1 = events.get(i - 1).getX() - events.get(i - 2).getX();
            int dx2 = events.get(i).getX() - events.get(i - 1).getX();
            if (dx1 == dx2) straight++;
        }
        return straight > 5 ? -30 : 30;
    }

    private double entropyScore(List<BehaviorEvent> events) {
        Set<Integer> xs = events.stream().map(BehaviorEvent::getX).collect(Collectors.toSet());
        return xs.size() < 5 ? -20 : 20;
    }
}

风险决策 Service(非常关键)

基于行为验证的评分,进行风险分级决策。

@Service
public class RiskDecisionService {

    public CaptchaDecision decide(double score) {
        if (score >= 60) return CaptchaDecision.PASS;
        if (score >= 20) return CaptchaDecision.SLIDER;
        return CaptchaDecision.REJECT;
    }
}

Controller(完整链路)

这是整合行为验证与决策的主入口。

@RestController
@RequestMapping("/captcha")
public class CaptchaController {

    @Autowired
    private BehaviorCaptchaService behaviorService;
    @Autowired
    private RiskDecisionService decisionService;

    @PostMapping("/check")
    public CaptchaResult check(@RequestBody List<BehaviorEvent> events,
                               HttpServletRequest request) {

        double score = behaviorService.score(events);
        CaptchaDecision decision = decisionService.decide(score);

        if (decision == CaptchaDecision.PASS) {
            return new CaptchaResult(decision, null);
        }

        String token = TokenUtil.generate(request.getSession().getId());
        // TODO: token + 状态写入 Redis,TTL 5min

        return new CaptchaResult(decision, token);
    }
}

Redis Key 设计(上线必备)

验证码令牌在 Redis 中的存储结构。

captcha:token:{token}
{
  "decision": "SLIDER",
  "deviceFp": "...",
  "expire": 300
}

TTL:5 分钟
✔ 一次使用
✔ 自动过期

这套代码已经具备什么能力?

✅ 抗脚本
✅ 抗重放
✅ 支持无感 → 滑块升级
✅ 支持后续点选 / 短信
✅ 能接入风控 / 黑名单
✅ 可灰度 / 可观测

Redis + Lua = 原子性 + 防重放 + 防并发绕

以下是可直接用在 Spring Boot 项目中的完整实现,核心是使用 Redis Lua 脚本保证校验的原子性。

✅ Redis Key 设计
✅ Lua 原子校验脚本
✅ Java 调用 Lua
✅ 防重放 / 防并发 / 防多次提交
✅ 验证码生命周期闭环

为什么必须用 Lua(而不是 Java if 判断)

很多项目在验证码校验时存在并发漏洞。

错误做法(99% 项目中都在犯)

if (redis.exists(key)) {
    redis.delete(key);
    return PASS;
}

👉 问题:

  • 并发请求可同时 exists
  • Token 可被多次使用
  • 被脚本重放直接绕过

正确做法

校验 + 使用 + 删除 = 一个原子操作

Redis Lua 脚本天生满足原子性要求,可以一次性完成所有检查和状态更新。

Redis Key 设计(生产级)

1️⃣ 验证码 Token Key

Key: captcha:token:{token}
Type: Hash
TTL: 300s
{
  "type": "SLIDER",
  "status": "INIT",
  "deviceFp": "fp_hash",
  "risk": "0.42"
}

2️⃣ 使用状态约定

status 含义
INIT 未使用
USED 已验证
EXPIRE 过期

Lua 脚本:防重放核心(重点)

1️⃣ Lua 校验逻辑说明

一次 Lua 脚本执行 5 件事:

  1. key 是否存在
  2. 是否已使用
  3. 设备指纹是否一致
  4. 标记为 USED
  5. 返回结果

2️⃣ Lua 脚本(直接可用)

-- captcha_verify.lua

-- KEYS[1] = captcha key
-- ARGV[1] = device fingerprint

local key = KEYS[1]
local fp = ARGV[1]

if redis.call("EXISTS", key) == 0 then
    return -1  -- token 不存在
end

local status = redis.call("HGET", key, "status")
if status ~= "INIT" then
    return -2  -- 已使用 or 非法状态
end

local savedFp = redis.call("HGET", key, "deviceFp")
if savedFp ~= fp then
    return -3  -- 设备不一致
end

redis.call("HSET", key, "status", "USED")
redis.call("EXPIRE", key, 60) -- 使用后保留一小段时间用于审计

return 1

3️⃣ Lua 返回码约定

返回值 含义
1 校验成功
-1 Token 不存在
-2 Token 已使用
-3 设备指纹不匹配

Spring Boot Redis 配置

@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<String, Object> redisTemplate(
            RedisConnectionFactory factory) {

        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);
        template.setKeySerializer(new StringRedisSerializer());
        template.setHashKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
        return template;
    }
}

Java 调用 Lua(关键代码)

1️⃣ Lua Script Bean

@Component
public class CaptchaLuaScript {

    private final DefaultRedisScript<Long> script;

    public CaptchaLuaScript() {
        script = new DefaultRedisScript<>();
        script.setResultType(Long.class);
        script.setScriptText(load());
    }

    private String load() {
        return """
            local key = KEYS[1]
            local fp = ARGV[1]

            if redis.call("EXISTS", key) == 0 then
                return -1
            end

            local status = redis.call("HGET", key, "status")
            if status ~= "INIT" then
                return -2
            end

            local savedFp = redis.call("HGET", key, "deviceFp")
            if savedFp ~= fp then
                return -3
            end

            redis.call("HSET", key, "status", "USED")
            redis.call("EXPIRE", key, 60)

            return 1
        """;
    }

    public DefaultRedisScript<Long> getScript() {
        return script;
    }
}

2️⃣ Redis 校验 Service

@Service
public class CaptchaRedisService {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @Autowired
    private CaptchaLuaScript luaScript;

    public boolean verifyOnce(String token, String deviceFp) {
        String key = "captcha:token:" + token;

        Long result = redisTemplate.execute(
                luaScript.getScript(),
                Collections.singletonList(key),
                deviceFp
        );

        return result != null && result == 1;
    }
}

验证码生成时写 Redis(完整)

public void saveCaptchaToken(String token, String deviceFp, double risk) {
    String key = "captcha:token:" + token;

    Map<String, String> data = new HashMap<>();
    data.put("status", "INIT");
    data.put("deviceFp", deviceFp);
    data.put("risk", String.valueOf(risk));

    redisTemplate.opsForHash().putAll(key, data);
    redisTemplate.expire(key, 5, TimeUnit.MINUTES);
}

Controller 中的最终校验(闭环)

@PostMapping("/verify")
public ResponseEntity<?> verify(@RequestParam String token,
                                @RequestParam String deviceFp) {

    boolean ok = captchaRedisService.verifyOnce(token, deviceFp);

    if (!ok) {
        return ResponseEntity.status(403).body("CAPTCHA INVALID");
    }

    return ResponseEntity.ok("PASS");
}

这套 Redis + Lua 防住了什么?

攻击方式 是否可行
重放 Token
并发绕过
跨设备使用
多次提交
脚本抢占

生产级增强(你下一步一定要做)

🔥 Lua + 限流融合

-- 同 IP / 设备 5 秒最多 3 次

🔥 USED Token 行为审计

captcha:used:{date}

🔥 风险反哺模型

USED + 后续异常 → 封设备

架构师一句实话

验证码真正的安全,不在前端,也不在算法
而在:
“服务端原子性 + 一次性语义”

本文提供的代码框架旨在抛砖引玉,你可以根据自身业务需求,在 云栈社区 与更多开发者交流,进一步扩展和优化风控模型与校验逻辑。




上一篇:谷歌CEO桑达尔・皮查伊的领导哲学:从钦奈到硅谷的长期主义坚守
下一篇:CTF必备:详解SQL注入四种核心漏洞原理与实战技巧(含整数、字符、报错、盲注)
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-2-23 15:38 , Processed in 0.501037 second(s), 40 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表