跳到内容

← 返回知识库WIKI/代码示例/ARTICLE

一个简单的验证码风控逻辑 ​

前几天小 A 让在项目中加了一个登录验证码的逻辑,今天刚吃过饭,正躺在工位上喜滋滋地刷着沐落自留地的文章,小 A 急忙忙地找过来,说客户发飙了,每次登录都要验证码,烦死了。

为了平息客户的怒火,趁中午休息时间,加一个验证码的风控逻辑。

一、什么时候需要验证码? ​

  1. 用户更换浏览器 —— 展示验证码
  2. 用户登录地址 IP 变更 —— 展示验证码
  3. 用户频繁登录:2 小时内,登录大于 5 次 —— 展示验证码
  4. 验证码记忆:用户 7 天内未使用验证码登录过 —— 展示验证码

二、校验时机 ​

在用户点击登录时,调用风控逻辑校验是否需要展示验证码。

验证码生成与校验流程:登录 → 风控监控 → 生成验证码 → 存储验证码 → 返回验证码图片和 uuid → 输入验证码点击登录 → 校验证证码 → 清除验证码缓存 → 返回登录信息安全验证弹窗:验证码输入框与验证码图片验证码接口响应:code 200、msg 成功,data 中返回 uuid 与 base64 图片

三、核心代码实现 ​

1. 验证码风控逻辑 ​

java
import cn.hutool.core.lang.UUID;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.concurrent.TimeUnit;

@Slf4j
@Service
@RequiredArgsConstructor
public class CaptchaService {
    private final RedisUtils redisUtils;
    private final SysLoginLogService sysLoginLogService;

    // 验证码缓存前缀
    private static final String CAPTCHA_KEY_PREFIX = "captcha:";
    private static final String CAPTCHA_REQUIRE_KEY_PREFIX = "captcha:require:";
    private static final String CAPTCHA_LOGIN_SUCCESS_PREFIX = "captcha:login:";

    // 过期时间统一以秒计
    // 需要验证码时缓存 5 分钟
    private static final long CAPTCHA_REQUIRE_EXPIRE = 5 * 60;
    // 不需要验证码时缓存 2 小时
    private static final long CAPTCHA_NOT_REQUIRE_EXPIRE = 120 * 60;
    // 验证码成功登录记忆 7 天
    private static final long CAPTCHA_RECORD_SUCCESS_FULL_LOGIN_EXPIRE = 7 * 24 * 60 * 60;

    // 频繁登录检查周期:2 小时
    private static final long FREQUENT_LOGIN_HOURS = 2;
    // 频繁登录阈值:5 次
    private static final int FREQUENT_LOGIN_THRESHOLD = 5;

    /**
     * 判断当前登录是否需要验证码
     */
    public boolean requireCaptcha(String username, String currentIp, String currentUserAgent) {
        // 生成环境特征键
        String environmentKey = CaptchaUtil.generateEnvironmentKey(currentIp, currentUserAgent);
        String cacheKey = CAPTCHA_REQUIRE_KEY_PREFIX + username + ":" + environmentKey;

        // 检查环境缓存
        if (redisUtils.hasKey(cacheKey)) {
            Boolean cachedResult = redisUtils.get(cacheKey, Boolean.class);
            log.debug("用户 {} 环境 {} 缓存命中: {}", username, environmentKey, cachedResult);
            return cachedResult;
        }

        // 实时检查验证码需求
        boolean requireCaptcha = checkCaptchaRequirements(username, currentIp, currentUserAgent);

        // 需要验证码:缓存 5 分钟
        if (requireCaptcha) {
            redisUtils.setBySetTime(cacheKey, true, CAPTCHA_REQUIRE_EXPIRE, TimeUnit.SECONDS);
        } else {
            // 不需要验证码:缓存 2 小时
            redisUtils.setBySetTime(cacheKey, false, CAPTCHA_NOT_REQUIRE_EXPIRE, TimeUnit.SECONDS);
        }
        return requireCaptcha;
    }

    /**
     * 记录验证码使用和登录成功(登录成功后调用)
     */
    public void recordSuccessfulLogin(String username, boolean usedCaptcha) {
        String cacheKey = CAPTCHA_LOGIN_SUCCESS_PREFIX + username;
        if (usedCaptcha) {
            redisUtils.setBySetTime(cacheKey, true, CAPTCHA_RECORD_SUCCESS_FULL_LOGIN_EXPIRE, TimeUnit.SECONDS);
        }
    }

    /**
     * 检查所有验证码条件
     */
    private boolean checkCaptchaRequirements(String username, String currentIp, String currentUserAgent) {
        // 条件 1:检查 2 小时内登录频率
        boolean frequentLogin = checkFrequentLogin(username);
        if (frequentLogin) {
            log.info("用户 {} 2小时内登录频繁,需要验证码", username);
            return true;
        }

        // 条件 2:检查环境变化(IP 或浏览器变更)
        boolean environmentChanged = checkEnvironmentChange(username, currentIp, currentUserAgent);
        if (environmentChanged) {
            return true;
        }

        // 条件 3:检查 7 天内是否弹过验证码并成功登录
        return checkCaptchaMemory(username);
    }

    /**
     * 检查 2 小时内登录频率
     */
    private boolean checkFrequentLogin(String username) {
        LocalDateTime twoHoursAgo = LocalDateTime.now().minusHours(FREQUENT_LOGIN_HOURS);
        // 查询 2 小时内的登录次数(包括成功和失败)
        Integer loginCount = sysLoginLogService.countLoginAttempts(username, twoHoursAgo);
        boolean isFrequent = loginCount != null && loginCount >= FREQUENT_LOGIN_THRESHOLD;
        if (isFrequent) {
            log.warn("用户 {} 2小时内登录 {} 次,超过阈值 {}", username, loginCount, FREQUENT_LOGIN_THRESHOLD);
        }
        return isFrequent;
    }

    /**
     * 检查登录环境变化(IP 或浏览器变更)
     */
    private boolean checkEnvironmentChange(String username, String currentIp, String currentUserAgent) {
        // 获取用户最后一次成功登录的记录
        SysLoginLog lastSuccessLogin = sysLoginLogService.selectLastSuccessLogin(username);
        if (lastSuccessLogin == null) {
            log.info("用户 {} 无成功登录记录,需要验证环境", username);
            return true; // 新用户或从未成功登录过
        }

        // 检查 IP 是否变化
        boolean ipChanged = !currentIp.equals(lastSuccessLogin.getIp());
        // 检查浏览器是否变化
        boolean browserChanged = CaptchaUtil.isBrowserChanged(currentUserAgent, lastSuccessLogin.getUserAgent());
        boolean environmentChanged = ipChanged || browserChanged;
        log.debug("用户 {} 环境变化检查: IP变更={}({}->{}), 浏览器变更={}, 总体变化={}",
                username, ipChanged, lastSuccessLogin.getIp(), currentIp,
                browserChanged, environmentChanged);
        return environmentChanged;
    }

    /**
     * 检查 7 天内是否有验证码记忆(弹过验证码且成功登录)
     */
    private boolean checkCaptchaMemory(String username) {
        String key = CAPTCHA_LOGIN_SUCCESS_PREFIX + username;
        boolean hasMemory = redisUtils.hasKey(key);
        return !hasMemory;
    }
}

2. 系统指纹校验逻辑 ​

java
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.DigestUtils;
import java.nio.charset.StandardCharsets;

@Slf4j
public class CaptchaUtil {
    /**
     * 生成环境特征键
     */
    public static String generateEnvironmentKey(String ip, String userAgent) {
        try {
            String browserFingerprint = extractBrowserFingerprint(userAgent);
            String combined = ip + "|" + browserFingerprint;
            return DigestUtils.md5DigestAsHex(combined.getBytes(StandardCharsets.UTF_8));
        } catch (Exception e) {
            log.warn("生成环境键失败,使用IP作为降级方案", e);
            return DigestUtils.md5DigestAsHex(ip.getBytes(StandardCharsets.UTF_8));
        }
    }

    /**
     * 检查浏览器是否变更
     */
    public static boolean isBrowserChanged(String currentUserAgent, String lastUserAgent) {
        if (lastUserAgent == null || currentUserAgent == null) {
            return true;
        }
        CaptchaBrowserInfo currentBrowser = parseBrowserInfo(currentUserAgent);
        CaptchaBrowserInfo lastBrowser = parseBrowserInfo(lastUserAgent);
        return !isSameBrowser(currentBrowser, lastBrowser);
    }

    /**
     * 判断是否为同一浏览器
     */
    private static boolean isSameBrowser(CaptchaBrowserInfo current, CaptchaBrowserInfo last) {
        return current != null && last != null &&
                current.getBrowser().equals(last.getBrowser()) &&
                current.getOs().equals(last.getOs());
    }

    /**
     * 解析浏览器信息
     */
    private static CaptchaBrowserInfo parseBrowserInfo(String userAgent) {
        if (userAgent == null) {
            return new CaptchaBrowserInfo("Unknown", "Unknown");
        }
        String browser = "Unknown";
        String os = "Unknown";

        // 解析浏览器
        if (userAgent.contains("Edg")) {
            browser = "Edge";
        } else if (userAgent.contains("Chrome")) {
            browser = "Chrome";
        } else if (userAgent.contains("Firefox")) {
            browser = "Firefox";
        } else if (userAgent.contains("Safari")) {
            browser = "Safari";
        }

        // 解析操作系统
        if (userAgent.contains("Windows NT 10.0")) {
            os = "Windows 10";
        } else if (userAgent.contains("Windows NT 6.1")) {
            os = "Windows 7";
        } else if (userAgent.contains("Macintosh")) {
            os = "Mac OS";
        } else if (userAgent.contains("Linux")) {
            os = "Linux";
        } else if (userAgent.contains("Android")) {
            os = "Android";
        } else if (userAgent.contains("iPhone")) {
            os = "iOS";
        }
        return new CaptchaBrowserInfo(browser, os);
    }

    /**
     * 提取浏览器指纹
     */
    private static String extractBrowserFingerprint(String userAgent) {
        if (userAgent == null) return "unknown";
        // 提取关键特征
        String fingerprint = userAgent
                .replaceAll("Mozilla/\\d+\\.\\d+\\s*", "")
                .replaceAll("AppleWebKit/\\d+\\.\\d+\\s*", "")
                .replaceAll("Chrome/\\d+\\.\\d+\\.\\d+\\.\\d+", "Chrome")
                .replaceAll("Safari/\\d+\\.\\d+", "Safari")
                .replaceAll("\\s*\\([^)]*\\)", "")
                .replaceAll("\\s+", " ")
                .trim();
        return fingerprint.length() > 100 ?
                fingerprint.substring(0, 100) : fingerprint;
    }
}

3. 浏览器信息对象 ​

java
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class CaptchaBrowserInfo {
    private String browser;
    private String os;
}

到这里恭喜你,已经实现了验证码的简单风控逻辑。