外观
一个简单的验证码风控逻辑
前几天小 A 让在项目中加了一个登录验证码的逻辑,今天刚吃过饭,正躺在工位上喜滋滋地刷着沐落自留地的文章,小 A 急忙忙地找过来,说客户发飙了,每次登录都要验证码,烦死了。
为了平息客户的怒火,趁中午休息时间,加一个验证码的风控逻辑。
一、什么时候需要验证码?
- 用户更换浏览器 —— 展示验证码
- 用户登录地址 IP 变更 —— 展示验证码
- 用户频繁登录:2 小时内,登录大于 5 次 —— 展示验证码
- 验证码记忆:用户 7 天内未使用验证码登录过 —— 展示验证码
二、校验时机
在用户点击登录时,调用风控逻辑校验是否需要展示验证码。



三、核心代码实现
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;
}到这里恭喜你,已经实现了验证码的简单风控逻辑。