feat(backend): 注册限流阈值改为可配置

This commit is contained in:
Zhou Weidong committed 2026-10-17 23:22:28 +08:00
1 parent 3403a521eb
commit 758eb69f55
5 files changed
+73 -11

No files matched your search

+13 -2
View File
@@ -113,10 +113,21 @@ POST /app/auth/register
| `targetType=PHONE` | 维持原状:`code` **必填** |
| 邮箱规范化 | 去空格 + 转小写(`batch1@Example.com ` → `batch1@example.com`);格式不合法直接 `400 邮箱格式不正确` |
| 密码强度 | 8–64 位,且**同时包含字母与数字**(不强制特殊字符:低端机输入成本高,拦机器注册靠频控) |
| 注册频控 | 同 IP 24h ≤ **5** 个、同设备 24h ≤ **3** 个,超出返回 `429`(`TOO_MANY_REQUESTS`) |
| 注册频控 | 同 IP / 同设备 24h 默认上限分别为 5 / 3 个,超出返回 `429`(`TOO_MANY_REQUESTS`) |
| 邮箱验证状态 | 免码注册时 `app_user_credential.verified = 0`(保留"待验证"),**不阻塞登录**;只有**验证码登录**会把 `verified` 置 1,密码登录不改变该状态 |
频控口径落库在 `app_user.register_ip` / `register_device_id` 上(按 24h 计数,不新建计数表)。
频控口径落库在 `app_user.register_ip` / `app_user.register_device_id` 上(按 24h 计数,不新建计数表)。
注册频控阈值通过以下配置键调整:
```text
fruitshorts.auth.register-max-per-ip-per-day
fruitshorts.auth.register-max-per-device-per-day
```
未配置时默认 5 / 3;值小于等于 0 表示对应维度不限制。开发
`application.yml` 显式设为 0 / 0,`application-prod.yml` 固定为 5 / 3。
**残留风险**:IP 取自 `X-Forwarded-For`、设备号由客户端上报,都能伪造,只能挡脚本批量;
真要防住需要接图形/行为验证码或风控服务,本阶段未做。验证码通道目前是 `LogOtpSender`(只写日志不发信),
所以邮箱**可达性**未验证 —— 这也是"不阻塞注册"的前提。
@@ -17,6 +17,9 @@ spring:
timeout-per-shutdown-phase: 30s
fruitshorts:
auth:
register-max-per-ip-per-day: 5
register-max-per-device-per-day: 3
media:
# 生产必须开启播放地址签名(docs/12-上线检查清单)
signature-required: true
@@ -43,6 +43,10 @@ springdoc:
path: /v3/api-docs
fruitshorts:
auth:
# 注册频控;小于等于 0 表示不限制。开发环境默认关闭,生产建议 5 / 3。
register-max-per-ip-per-day: 0
register-max-per-device-per-day: 0
interaction:
# MACHINE_FIRST:机审通过即发布,命中 REVIEW 级敏感词才转人工
# PRE_REVIEW :全部先审后发(生产建议)
@@ -25,6 +25,7 @@ import com.fruitshorts.user.vo.TokenRespVO;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Duration;
import java.time.LocalDateTime;
@@ -164,6 +165,7 @@ class AppAuthServiceTest {
@Test
void should_reject_register_when_ip_reaches_the_daily_limit() {
setRegisterLimits(5, 3);
given(userMapper.selectCount(any())).willReturn(5L);
assertThatThrownBy(() -> authService.register(emailRegister("abcd1234", null), "203.0.113.8"))
@@ -173,6 +175,38 @@ class AppAuthServiceTest {
.hasMessageContaining("当前网络注册过于频繁");
}
@Test
void should_reject_register_when_device_reaches_the_daily_limit() {
setRegisterLimits(5, 3);
given(userMapper.selectCount(any())).willReturn(3L);
assertThatThrownBy(() -> authService.register(emailRegister("abcd1234", null), "203.0.113.8"))
.isInstanceOf(ServiceException.class)
.satisfies(ex -> assertThat(((ServiceException) ex).getCode())
.isEqualTo(ErrorCode.TOO_MANY_REQUESTS.getCode()))
.hasMessage("该设备注册过于频繁,请稍后再试");
}
@Test
void should_not_limit_by_ip_when_ip_limit_is_zero() {
stubHappyRegister();
setRegisterLimits(0, 3);
given(userMapper.selectCount(any())).willReturn(2L);
assertThat(authService.register(emailRegister("abcd1234", null), "203.0.113.8").getAccessToken())
.isEqualTo("access-1");
}
@Test
void should_not_limit_by_device_when_device_limit_is_zero() {
stubHappyRegister();
setRegisterLimits(5, 0);
given(userMapper.selectCount(any())).willReturn(4L);
assertThat(authService.register(emailRegister("abcd1234", null), "203.0.113.8").getAccessToken())
.isEqualTo("access-1");
}
@Test
void should_return_tokens_when_password_matches() {
stubLogin(credential("bcrypt-hash"), user(AppUserStatus.NORMAL));
@@ -268,6 +302,11 @@ class AppAuthServiceTest {
deviceMapper, realOtpService, hasher, tokenProvider, passwordEncoder);
}
private void setRegisterLimits(int perIp, int perDevice) {
ReflectionTestUtils.setField(authService, "maxRegisterPerIpPerDay", perIp);
ReflectionTestUtils.setField(authService, "maxRegisterPerDevicePerDay", perDevice);
}
private RegisterReqVO emailRegister(String password, String code) {
RegisterReqVO req = new RegisterReqVO();
req.setTargetType("EMAIL");
@@ -25,6 +25,7 @@ import com.fruitshorts.user.vo.LogoutReqVO;
import com.fruitshorts.user.vo.RegisterReqVO;
import com.fruitshorts.user.vo.TokenRespVO;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
@@ -301,10 +302,14 @@ public class AppAuthService {
return resp;
}
/** 每个 IP 24 小时内最多注册几个账号 */
private static final int MAX_REGISTER_PER_IP_PER_DAY = 5;
/** 每台设备 24 小时内最多注册几个账号 */
private static final int MAX_REGISTER_PER_DEVICE_PER_DAY = 3;
/** 每个 IP 24 小时内最多注册几个账号;小于等于 0 表示不限制。 */
@Value("${fruitshorts.auth.register-max-per-ip-per-day:5}")
private int maxRegisterPerIpPerDay = 5;
/** 每台设备 24 小时内最多注册几个账号;小于等于 0 表示不限制。 */
@Value("${fruitshorts.auth.register-max-per-device-per-day:3}")
private int maxRegisterPerDevicePerDay = 3;
private static final int PASSWORD_MIN_LENGTH = 8;
private static final int PASSWORD_MAX_LENGTH = 64;
private static final Pattern HAS_LETTER = Pattern.compile("[A-Za-z]");
@@ -328,7 +333,7 @@ public class AppAuthService {
}
/**
* 注册频控(邮箱免码注册必须带的兜底):同 IP 24h ≤5 个、同设备 24h ≤3 个。
* 注册频控(邮箱免码注册必须带的兜底):阈值由配置提供,≤0 时跳过对应限制。
*
* 直接数 `app_user` 上的 register_ip / register_device_id,不新建计数表:
* 注册量本来就低,这两列也已经有数据,省一张表与一套清理任务。
@@ -337,20 +342,20 @@ public class AppAuthService {
private void guardRegisterRisk(String clientIp, String deviceId) {
LocalDateTime since = LocalDateTime.now().minusDays(1);
String ip = AppIdentifier.nullSafe(clientIp);
if (StringUtils.hasText(ip)) {
if (maxRegisterPerIpPerDay > 0 && StringUtils.hasText(ip)) {
Long byIp = appUserMapper.selectCount(Wrappers.<AppUser>lambdaQuery()
.eq(AppUser::getRegisterIp, ip)
.ge(AppUser::getCreatedAt, since));
if (byIp != null && byIp >= MAX_REGISTER_PER_IP_PER_DAY) {
if (byIp != null && byIp >= maxRegisterPerIpPerDay) {
throw new ServiceException(ErrorCode.TOO_MANY_REQUESTS, "当前网络注册过于频繁,请稍后再试");
}
}
String device = AppIdentifier.nullSafe(deviceId);
if (StringUtils.hasText(device)) {
if (maxRegisterPerDevicePerDay > 0 && StringUtils.hasText(device)) {
Long byDevice = appUserMapper.selectCount(Wrappers.<AppUser>lambdaQuery()
.eq(AppUser::getRegisterDeviceId, device)
.ge(AppUser::getCreatedAt, since));
if (byDevice != null && byDevice >= MAX_REGISTER_PER_DEVICE_PER_DAY) {
if (byDevice != null && byDevice >= maxRegisterPerDevicePerDay) {
throw new ServiceException(ErrorCode.TOO_MANY_REQUESTS, "该设备注册过于频繁,请稍后再试");
}
}