文章目录
-
-
- 一、总体设计思路
- 二、后端限流策略
-
- [2.1 限流维度与规则](#2.1 限流维度与规则)
- [2.2 Redis 限流实现示例](#2.2 Redis 限流实现示例)
- 三、验证码安全机制
-
- [3.1 验证码基本规则](#3.1 验证码基本规则)
- [3.2 验证码存储方式](#3.2 验证码存储方式)
- [3.3 验证流程与防破解](#3.3 验证流程与防破解)
- [四、图形验证码 / 滑块验证](#四、图形验证码 / 滑块验证)
-
- [4.1 触发时机](#4.1 触发时机)
- [4.2 滑块验证集成方式](#4.2 滑块验证集成方式)
- 五、黑名单机制
-
- [5.1 黑名单对象](#5.1 黑名单对象)
- [5.2 识别规则](#5.2 识别规则)
- [5.3 存储与查询](#5.3 存储与查询)
- 六、接口安全:身份验证、请求签名、防重放
-
- [6.1 全站 HTTPS](#6.1 全站 HTTPS)
- [6.2 请求签名机制(建议 App 场景)](#6.2 请求签名机制(建议 App 场景))
- 七、业务逻辑校验
-
- [7.1 手机号格式验证](#7.1 手机号格式验证)
- [7.2 归属地、运营商验证](#7.2 归属地、运营商验证)
- [7.3 账号状态校验](#7.3 账号状态校验)
- 八、监控告警与审计
-
- [8.1 监控内容](#8.1 监控内容)
- [8.2 告警策略](#8.2 告警策略)
- [8.3 日志审计](#8.3 日志审计)
- [九、缓存策略(Redis 等)](#九、缓存策略(Redis 等))
-
- [9.1 使用场景](#9.1 使用场景)
- [9.2 防止内存溢出与雪崩](#9.2 防止内存溢出与雪崩)
- 十、数据库设计与索引
-
- [10.1 短信发送日志表](#10.1 短信发送日志表)
- [10.2 验证码校验日志表(可选)](#10.2 验证码校验日志表(可选))
- [10.3 黑名单表](#10.3 黑名单表)
- 十一、分布式锁:防重复发送
-
- [11.1 Redis 分布式锁](#11.1 Redis 分布式锁)
- [十二、登录流程与 JWT 集成示例](#十二、登录流程与 JWT 集成示例)
- 十三、前端配合的安全措施
-
- [13.1 请求频率限制](#13.1 请求频率限制)
- [13.2 客户端校验](#13.2 客户端校验)
- [13.3 安全传输与存储](#13.3 安全传输与存储)
- [13.4 用户体验与安全提示](#13.4 用户体验与安全提示)
- 十四、小结
-
一、总体设计思路
- 目标:在支持手机号+短信验证码快捷登录的前提下,最大限度防止短信接口被批量刷号、撞库、薅羊毛和攻击,兼顾安全性、可用性和扩展性。
- 核心手段:限流 + 验证码安全 + 图形/滑块校验 + 黑名单 + 接口签名与防重放 + 严格业务校验 + 监控告警 + 合理缓存 + 审计日志 + 分布式锁。
- 关键技术栈:Spring Boot 3、Spring Web、Spring Validation、Spring Security(可选)、Spring Data Redis、MyBatis/JPA、HTTPs、Nginx 限流(可选)。
后端将短信登录拆分为两个接口:
- 发送短信验证码 :
POST /api/auth/sendSmsCode - 使用短信验证码登录 :
POST /api/auth/loginBySms
二、后端限流策略
2.1 限流维度与规则
建议对以下维度分别限流,并组合判定:
- 单手机号 :
- 每分钟最多 1 次
- 每小时最多 3 次
- 每天最多 5~10 次(视业务而定)
- IP 地址 :
- 每分钟最多 10 次
- 每小时最多 100 次
- 异常区域或代理 IP 收紧阈值
- 设备 ID / 设备指纹 :
- 每分钟最多 3 次
- 每天最多 10 次
- 组合维度 :
phone + IP、phone + deviceId,用于识别代理刷号
2.2 Redis 限流实现示例
使用 Redis 的 INCR + EXPIRE 或 Lua 脚本实现原子限流。
Redis Key 设计示例:
sms:send:phone:{phone}:1msms:send:phone:{phone}:1hsms:send:phone:{phone}:1dsms:send:ip:{ip}:1msms:send:device:{deviceId}:1m
Java 拦截器/切面实现伪代码:
java
@Component
public class SmsRateLimiter {
private final StringRedisTemplate stringRedisTemplate;
public SmsRateLimiter(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
public void checkSendLimit(String phone, String ip, String deviceId) {
// 单手机号 1 分钟一次
assertAllowed(incrWithTtl("sms:send:phone:" + phone + ":1m", 60, 1));
// 单手机号 1 小时 3 次
assertAllowed(incrWithTtl("sms:send:phone:" + phone + ":1h", 3600, 3));
// 单手机号 1 天 10 次
assertAllowed(incrWithTtl("sms:send:phone:" + phone + ":1d", 86400, 10));
// IP 限流
assertAllowed(incrWithTtl("sms:send:ip:" + ip + ":1m", 60, 10));
// 设备限流
if (StringUtils.hasText(deviceId)) {
assertAllowed(incrWithTtl("sms:send:dev:" + deviceId + ":1m", 60, 3));
}
}
private long incrWithTtl(String key, long ttlSeconds, long max) {
Long val = stringRedisTemplate.opsForValue().increment(key);
if (val != null && val == 1L) {
stringRedisTemplate.expire(key, Duration.ofSeconds(ttlSeconds));
}
return val == null ? 0 : val;
}
private void assertAllowed(long current) {
if (current > 0 && current > 10_0000) {
// 安全兜底,可自定义
}
}
}
实际代码中需要根据 key 区分不同规则,并在超过 max 时抛业务异常:
java
if (current > max) {
throw new BusinessException("短信发送太频繁,请稍后再试");
}
在 Controller 入口使用 @ControllerAdvice 捕获异常,返回统一错误码。
三、验证码安全机制
3.1 验证码基本规则
- 长度与复杂度 :
- 一般为 6 位数字(便于输入),高安全场景可使用数字+字母 6~8 位。
- 有效期 :
- 建议 5 分钟内有效,同时单个验证码仅允许 5 次以内的错误尝试。
- 发送间隔 :
- 与限流结合,发送后 60 秒内不允许再次发送。
3.2 验证码存储方式
推荐使用 Redis + 单向散列存储:
- Key 示例:
sms:code:login:{phone} - Value 内容:
hash(code + salt)- 失败次数
failCount - 发送时间
sendTime
可用 JSON 或 Hash 结构存储。例如 Hash:
field: code-> 哈希值field: times-> 已校验次数field: ts-> 发送时间戳
生成和存储伪代码:
java
public class SmsCodeService {
private final StringRedisTemplate redisTemplate;
private final SecureRandom secureRandom = new SecureRandom();
public String generateAndStoreCode(String phone) {
String code = String.format("%06d", secureRandom.nextInt(1_000_000));
String salt = "sms-login-salt"; // 也可每次随机,附加存储
String hash = DigestUtils.sha256Hex(code + salt);
String key = "sms:code:login:" + phone;
Map<String, String> map = new HashMap<>();
map.put("code", hash);
map.put("times", "0");
map.put("ts", String.valueOf(System.currentTimeMillis()));
redisTemplate.opsForHash().putAll(key, map);
redisTemplate.expire(key, Duration.ofMinutes(5));
return code; // 返回给短信通道,不返回给前端
}
}
3.3 验证流程与防破解
- 验证步骤 :
- 从 Redis 读取验证码记录,校验是否存在及是否过期。
- 校验错误次数
times是否超过 5 次,如果超过则删除记录并提示重新获取。 - 对用户输入的验证码做同样的 hash 比对。
- 验证成功后删除 Redis 记录(防止重复使用)。
- 防暴力破解 :
- 错误次数过多后,锁定该手机号一段时间(例如 30 分钟),记录黑名单风险分。
- 结合 IP/设备 ID 统计错误率。
- 返回错误提示要模糊化:不要区分"验证码错误"和"手机号未注册"等细节。
验证码验证伪代码:
java
public boolean verifyCode(String phone, String inputCode) {
String key = "sms:code:login:" + phone;
Map<Object, Object> map = redisTemplate.opsForHash().entries(key);
if (map == null || map.isEmpty()) {
throw new BusinessException("验证码已失效,请重新获取");
}
int times = Integer.parseInt((String) map.getOrDefault("times", "0"));
if (times >= 5) {
redisTemplate.delete(key);
throw new BusinessException("验证码错误次数过多,请重新获取");
}
String salt = "sms-login-salt";
String expectHash = (String) map.get("code");
String actualHash = DigestUtils.sha256Hex(inputCode + salt);
if (!actualHash.equals(expectHash)) {
redisTemplate.opsForHash().put(key, "times", String.valueOf(times + 1));
throw new BusinessException("验证码错误");
}
// 验证通过,删除
redisTemplate.delete(key);
return true;
}
四、图形验证码 / 滑块验证
4.1 触发时机
可配置多级触发策略:
- 低风险:首次请求、单 IP 请求频率正常 → 不触发图形验证码。
- 中风险:同一手机号短时间内多次请求(如 2 次以上)、同一 IP 请求过快 → 要求前端先完成人机校验(图形/滑块)。
- 高风险:IP 命中高危段 / 代理 / 黑名单、过去 24 小时该手机号多次失败 → 强制图形验证码 + 降低限流阈值,必要时直接拒绝。
后端可以在响应中返回一个字段表示需要前端先做人机校验:
json
{
"code": 429,
"message": "需要人机校验",
"data": {
"needCaptcha": true,
"captchaType": "SLIDER"
}
}
4.2 滑块验证集成方式
- 可集成第三方服务(如极验、hCaptcha 等),前端完成交互后拿到
validatetoken。 - 后端接口
sendSmsCode需要携带该 token,并向第三方服务进行二次验证:- 验证成功后才继续短信发送。
- 验证失败则拒绝并计入风险评分。
伪代码示例:
java
public void checkCaptchaIfNeeded(String phone, String ip, String deviceId, String captchaToken) {
boolean needCaptcha = riskService.needCaptcha(phone, ip, deviceId);
if (!needCaptcha) {
return;
}
if (!StringUtils.hasText(captchaToken)) {
throw new BusinessException("请完成人机校验");
}
boolean pass = captchaClient.verify(captchaToken, ip);
if (!pass) {
throw new BusinessException("人机校验失败");
}
}
五、黑名单机制
5.1 黑名单对象
- 手机号:虚拟号段、垃圾号段、被频繁攻击的账号。
- IP / IP 段:来自数据中心、代理、已知攻击源。
- 设备 ID / 设备指纹:频繁撞库、批量注册设备。
5.2 识别规则
- 结合限流数据、验证码错误率、登录失败率、行为特征(夜间大量集中请求等),为每个主体计算风险分。
- 超过阈值后写入黑名单,并设置过期时间(例如 7 天)。
- 对于命中黑名单的请求,直接拒绝或返回"系统繁忙"。
5.3 存储与查询
- Redis 黑名单(快速判定) :
black:phone:{phone}black:ip:{ip}black:dev:{deviceId}
- 数据库黑名单(持久化及运营管理) :
- 表
security_blacklist,定期与 Redis 同步。
- 表
表结构示例:
sql
CREATE TABLE security_blacklist (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
type TINYINT NOT NULL COMMENT '1:phone,2:ip,3:device',
value VARCHAR(64) NOT NULL,
reason VARCHAR(255),
risk_score INT NOT NULL DEFAULT 0,
expire_time DATETIME,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
UNIQUE KEY uk_type_value (type, value),
KEY idx_expire_time (expire_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
六、接口安全:身份验证、请求签名、防重放
6.1 全站 HTTPS
- 所有短信相关接口必须走 HTTPS,防止中间人窃听验证码。
6.2 请求签名机制(建议 App 场景)
- 为移动 App 分配
appId和appSecret。 - 请求头携带:
X-App-Id、X-Timestamp、X-Nonce、X-Signature。 - 签名算法示例:
signature = HMAC-SHA256(appSecret, appId + timestamp + nonce + requestBody)。
后端验证逻辑:
- 根据
appId查appSecret。 - 校验时间戳是否在允许误差范围内(如 5 分钟)。
- 检查
nonce是否在短时间内重复(在 Redis 中记录nonce)。 - 使用相同算法计算签名并与
X-Signature对比。
防重放实现示例(Redis 存储 nonce):
java
public void validateSignature(String appId, String timestamp, String nonce, String signature, String body) {
// 1. 校验时间戳
long ts = Long.parseLong(timestamp);
if (Math.abs(System.currentTimeMillis() - ts) > 5 * 60 * 1000) {
throw new BusinessException("请求已过期");
}
// 2. 校验 nonce
String nonceKey = "req:nonce:" + appId + ":" + nonce;
Boolean success = redisTemplate.opsForValue().setIfAbsent(nonceKey, "1", Duration.ofMinutes(10));
if (Boolean.FALSE.equals(success)) {
throw new BusinessException("重复请求");
}
// 3. 计算签名
String appSecret = appSecretService.getSecret(appId);
String data = appId + timestamp + nonce + body;
String expected = hmacSha256Hex(appSecret, data);
if (!expected.equalsIgnoreCase(signature)) {
throw new BusinessException("签名错误");
}
}
七、业务逻辑校验
7.1 手机号格式验证
- 使用正则在后端强校验:
java
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
private String phone;
结合 Spring Validation:在 Controller 入参 DTO 上增加注解。
7.2 归属地、运营商验证
- 使用本地号段库或第三方服务按需校验:
- 过滤不支持的国家/地区号段。
- 对高风险国家号段可直接拒绝或收紧限流。
- 可增加
phone_region表存储号段归属信息,定期更新。
7.3 账号状态校验
- 登录前检查该手机号对应账号是否处于:
- 冻结、注销、风险控制状态。
- 风控状态下可要求额外的图形验证码或密码验证。
八、监控告警与审计
8.1 监控内容
- 短信发送量:按分钟/小时/天统计,按通道、按国家/地区拆分。
- 发送成功率和失败原因:区分运营商失败/限流/黑名单/第三方通道错误。
- 验证码验证失败率:按手机号、IP、设备聚合。
- 黑名单增长情况:黑名单新增数量、命中次数。
8.2 告警策略
- 单位时间内短信发送量激增或成功率骤降 → 通知运维与安全。
- 单 IP / 单设备 / 单号段在短时间内请求激增 → 风控告警。
- 验证码错误率异常偏高 → 可能存在撞库攻击。
落地方案:
- 使用日志(Logback)输出关键行为到专用索引字段,接入 ELK / Loki + Grafana。
- 配合 Prometheus 指标(
Counter,Gauge),设置告警规则对接钉钉/企业微信/飞书。
8.3 日志审计
- 记录但脱敏:手机号只保留前 3 位 + 后 4 位,例如
138****1234。 - 日志中禁止记录明文验证码和完整身份证号等敏感信息。
九、缓存策略(Redis 等)
9.1 使用场景
- 验证码存储 (
sms:code:login:{phone})。 - 限流计数 (
sms:send:phone:{phone}:1m等)。 - 黑名单快速判定 (
black:phone:{phone})。 - 请求签名防重放 nonce (
req:nonce:{appId}:{nonce})。
9.2 防止内存溢出与雪崩
- TTL 策略:所有短信相关 key 必须设置 TTL,且 ttl 不宜过长。
- Key 前缀规范 :便于运维统计和清理(如
sms:*、black:*)。 - 限流计数使用短 TTL 的整数,不存储大对象。
- 最大内存策略 :在 Redis 中配置
maxmemory和适当的淘汰策略(如allkeys-lru),并监控内存使用率。 - 降级方案 :
- Redis 不可用时,可以暂时禁止短信发送,防止在无法限流时被恶意刷号。
十、数据库设计与索引
10.1 短信发送日志表
用于审计和风控分析,不建议直接作为限流依据(限流放在 Redis)。
sql
CREATE TABLE sms_send_log (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
phone VARCHAR(32) NOT NULL,
ip VARCHAR(64),
device_id VARCHAR(64),
scene VARCHAR(32) NOT NULL COMMENT 'login, register 等',
template_code VARCHAR(64) NOT NULL,
content VARCHAR(512) NOT NULL,
status TINYINT NOT NULL COMMENT '0:待发送,1:成功,2:失败',
fail_reason VARCHAR(255),
channel VARCHAR(32) COMMENT '短信通道商',
created_at DATETIME NOT NULL,
sent_at DATETIME,
INDEX idx_phone_scene_time (phone, scene, created_at),
INDEX idx_ip_time (ip, created_at),
INDEX idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
- 可按时间(如按月)进行分表(结合 MySQL 分区/分表功能)。
10.2 验证码校验日志表(可选)
记录验证码验证失败情况,用于风控建模。
sql
CREATE TABLE sms_verify_log (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
phone VARCHAR(32) NOT NULL,
ip VARCHAR(64),
device_id VARCHAR(64),
scene VARCHAR(32) NOT NULL,
success TINYINT NOT NULL,
reason VARCHAR(255),
created_at DATETIME NOT NULL,
INDEX idx_phone_time (phone, created_at),
INDEX idx_ip_time (ip, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
10.3 黑名单表
参考前文 security_blacklist 设计,增加索引 uk_type_value 用于快速查找。
十一、分布式锁:防重复发送
在高并发场景下,需要避免同一手机号在同一时间窗口内被多次发送验证码。
11.1 Redis 分布式锁
- 锁 Key:
lock:sms:send:{phone},锁过期时间设置为 5~10 秒。 - 发送验证码前先抢锁,成功后执行发送逻辑,最后释放锁(或等待过期)。
伪代码示例:
java
public void sendSmsCode(String phone, String ip, String deviceId, String captchaToken) {
String lockKey = "lock:sms:send:" + phone;
String lockVal = UUID.randomUUID().toString();
Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, lockVal, Duration.ofSeconds(10));
if (Boolean.FALSE.equals(locked)) {
throw new BusinessException("短信发送中,请稍后再试");
}
try {
// 1. 黑名单校验
blacklistService.check(phone, ip, deviceId);
// 2. 限流校验
smsRateLimiter.checkSendLimit(phone, ip, deviceId);
// 3. 图形/滑块验证
checkCaptchaIfNeeded(phone, ip, deviceId, captchaToken);
// 4. 生成并存储验证码
String code = smsCodeService.generateAndStoreCode(phone);
// 5. 调用第三方短信通道发送
smsChannelClient.sendLoginCode(phone, code);
// 6. 记录日志
smsLogService.recordSendLog(...);
} finally {
// 释放锁(注意只释放自己持有的锁)
String val = redisTemplate.opsForValue().get(lockKey);
if (lockVal.equals(val)) {
redisTemplate.delete(lockKey);
}
}
}
生产环境可优先考虑使用 Redisson 等成熟组件实现分布式锁。
十二、登录流程与 JWT 集成示例
短信登录成功后,一般会签发一个访问令牌(如 JWT),后续接口基于令牌鉴权。
登录接口伪代码:
java
@PostMapping("/api/auth/loginBySms")
public Result<LoginResp> loginBySms(@Valid @RequestBody SmsLoginReq req, HttpServletRequest httpRequest) {
String ip = IpUtils.getClientIp(httpRequest);
String deviceId = req.getDeviceId();
// 1. 黑名单 & 风控
blacklistService.check(req.getPhone(), ip, deviceId);
// 2. 验证短信验证码
smsCodeService.verifyCode(req.getPhone(), req.getCode());
// 3. 获取或创建用户
User user = userService.getOrCreateByPhone(req.getPhone());
// 4. 生成 JWT
String token = jwtUtil.generateToken(user.getId(), user.getRole());
// 5. 记录登录日志
loginLogService.recordLogin(user.getId(), ip, deviceId, "SMS");
LoginResp resp = new LoginResp();
resp.setToken(token);
resp.setUserId(user.getId());
return Result.success(resp);
}
JWT 本身也要注意:
- 使用足够长度的对称秘钥或非对称密钥。
- 设置合理过期时间(如 2 小时),并结合刷新令牌机制。
- 优先在服务端维护 Token 黑名单(踢下线时使用 Redis 存储失效 token 的 jti)。
十三、前端配合的安全措施
13.1 请求频率限制
- 点击"获取验证码"后,按钮进入倒计时状态(如 60 秒),期间禁止再次点击。
- 对同一页面或设备上的连续操作做防抖/节流处理(例如 1~2 秒内禁止多次触发)。
13.2 客户端校验
- 在前端进行手机号格式校验(正则),避免无效请求直达服务端。
- 对登录页面进行基础防刷:
- 使用图形/滑块验证码组件。
- 随机化部分重要字段的名称/顺序增加脚本抓取难度(有限防护)。
13.3 安全传输与存储
- 前端必须通过 HTTPS 发起请求。
- 不在浏览器本地持久化存储验证码,不在控制台或日志输出验证码。
- JWT 建议使用
Authorization: Bearer xxx头传输,避免放在 URL 参数。
13.4 用户体验与安全提示
- 对频繁操作的用户给出友好提示,不暴露系统内部风控策略。
- 登录成功后,对新设备/新地区登录可提示用户注意账户安全。
十四、小结
通过 Redis 限流 + 验证码安全存储 + 图形/滑块验证码 + 黑名单 + 签名与防重放 + 严格业务校验 + 监控告警 + 分布式锁 等组合手段,可以大幅降低短信登录接口被恶意盗刷和滥用的风险。
在 Spring Boot 项目中落地时,需要:
- 封装统一的限流与风控组件,避免散落在各个 Controller 中。
- 统一错误码与返回结构,避免向攻击者泄露过多信息。
- 定期复盘日志与监控指标,持续优化规则与阈值。