feat(backend): 注册限流阈值改为可配置
This commit is contained in:
1 parent
3403a521eb
commit
758eb69f55
5 files changed
+73
-11
No files matched your search
+13
-2
@@ -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");
|
||||
|
||||
+14
-9
@@ -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, "该设备注册过于频繁,请稍后再试");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user