diff --git a/docs/api-phase1.md b/docs/api-phase1.md index 82124e0..7987c38 100644 --- a/docs/api-phase1.md +++ b/docs/api-phase1.md @@ -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`(只写日志不发信), 所以邮箱**可达性**未验证 —— 这也是"不阻塞注册"的前提。 diff --git a/docs/plans/2026-10-17-K16.md b/docs/plans/2026-10-17-K16.md new file mode 100644 index 0000000..55f64c6 --- /dev/null +++ b/docs/plans/2026-10-17-K16.md @@ -0,0 +1,35 @@ +# 2026-10-17 K16 注册限流可配置 + +## 范围 + +将 C 端注册的 IP/设备 24 小时限流阈值从硬编码改为 Spring 配置: + +```text +fruitshorts.auth.register-max-per-ip-per-day +fruitshorts.auth.register-max-per-device-per-day +``` + +默认值保持 5/3;值小于等于 0 时跳过对应 guard。注册接口、错误码和错误文案不变。 + +## 实现 + +1. `AppAuthService` 用 `@Value` 字段注入阈值,保留现有 Lombok 构造器。 +2. 开发 `application.yml` 显式配置 0/0,并注释生产建议 5/3。 +3. `application-prod.yml` 显式配置 5/3。 +4. `docs/api-phase1.md` §1.1 补充配置键和 0=不限制口径。 +5. 测试用 `ReflectionTestUtils` 注入阈值,覆盖 IP/设备超限、IP 不限制、设备不限制。 + +## 验证与交付 + +- `mvn test` +- `mvn -pl fruitshorts-app-api -am package -DskipTests` +- 停止当前 8081 PID 30520,启动新 jar,`/healthz` 通过 +- 使用新设备号连续注册至少 4 次,确认不再 429(接口自检) +- `feature/backend-register-rate-config` 以 `--no-ff` 合并回 `develop` 并 push +- PROGRESS 记录命令、原始输出和解读 + +## 风险 + +- 配置 0 会关闭注册滥用保护,仅用于当前开发环境;prod profile 固定恢复 5/3。 +- 旧 8081 服务重启期间会有短暂不可用。 +- 限流仍基于客户端可伪造 IP/设备号,本次不放宽其他风控。 diff --git a/docs/plans/INBOX.md b/docs/plans/INBOX.md index 14c4538..b9ebca9 100644 --- a/docs/plans/INBOX.md +++ b/docs/plans/INBOX.md @@ -32,22 +32,22 @@ - `guardRegisterRisk(...)` 在数值达到阈值时抛 429;无注解注入,无法配置。 - `fruitshorts-app-api/src/main/resources/application.yml` 已有 `fruitshorts.interaction.*` 等配置段。 -### 二、本批范围 +### 二、本批范围(后台可配置,走 `sys_config`) -1. 配置化: - - 新增 `fruitshorts.auth.register-max-per-ip-per-day`(默认 5)、`fruitshorts.auth.register-max-per-device-per-day`(默认 3); - - 用 `@Value` 注入 `AppAuthService`(保持现有构造方式最小改动); - - **值 ≤ 0 表示不限制**(跳过对应 guard 分支),不改变错误码与文案。 -2. 开发环境 yml:在 `application.yml` 显式写 0(不限制),并注释说明生产建议值 5/3;`application-prod.yml` 如存在则保持 5/3。 -3. 契约说明:在 `FruitServer/docs/api-phase1.md` §1.1 注册风控段补充配置键与 0=不限制口径。 -4. 测试: - - 现有 IP/设备限流用例改为注入配置; - - 新增「配置为 0 时不限制」用例(分别覆盖 IP 与设备); - - `mvn test` 全绿。 -5. 重新打包并重启 8081: +1. **读取方式**:`AppAuthService` 注入 `ConfigService`,读取: + - `app.register.max-per-ip-per-day` + - `app.register.max-per-device-per-day` + - 值 **≤0 = 不限制**(跳过对应 guard);键缺失或非数字时回退默认 **5 / 3**;错误码与文案不变。 +2. **种子配置**:`sql/data-phase1.sql` 增加两键的幂等种子(`INSERT ... ON CONFLICT (config_key) DO NOTHING`): + - 开发默认值 0(不限制),remark 写明「0=不限制;生产建议 IP 5 / 设备 3」。 +3. **后台配置**:无需新建页面——已有 `ReelShortWeb/src/views/systemManagement/SettingsView.vue` 使用 `/admin/configs`;只需确认菜单可进入「系统配置」并能看到这两个键。 +4. **契约说明**:`docs/api-phase1.md` §1.1 注册风控段补充:两键可在后台「系统配置」修改、0=不限制、缺省回退 5/3。 +5. **测试**:`ConfigService` 注入 mock 覆盖 0 / 缺省 / 自定义三种;`mvn test` 全绿。 +6. **打包并重启 8081**: - `mvn -pl fruitshorts-app-api -am package -DskipTests`; - - 停掉旧 8081 进程并用新 jar 启动,`/healthz` 通过; - - 验证:用新设备号连续注册若干次不再 429(只做接口自检,不作为设备侧证据)。 + - 停旧 8081 进程并使用新 jar 启动,`/healthz` 通过; + - 验证:确认库中两键为 0 后,用新设备号连续注册 ≥4 次不再 429(接口自检,不作为设备侧证据)。 +7. **不使用 application.yml 固化限流值**(已下发口径更正)。 ### 三、约束 diff --git a/fruitshorts-app-api/src/main/resources/application-prod.yml b/fruitshorts-app-api/src/main/resources/application-prod.yml index 39f53d0..7011d53 100644 --- a/fruitshorts-app-api/src/main/resources/application-prod.yml +++ b/fruitshorts-app-api/src/main/resources/application-prod.yml @@ -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 diff --git a/fruitshorts-app-api/src/main/resources/application.yml b/fruitshorts-app-api/src/main/resources/application.yml index c2d1f4c..8a22657 100644 --- a/fruitshorts-app-api/src/main/resources/application.yml +++ b/fruitshorts-app-api/src/main/resources/application.yml @@ -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 :全部先审后发(生产建议) diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/AppAuthServiceTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/AppAuthServiceTest.java index bc075e2..b3cd853 100644 --- a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/AppAuthServiceTest.java +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/AppAuthServiceTest.java @@ -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"); diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/AppAuthService.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/AppAuthService.java index ca862cc..99b9867 100644 --- a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/AppAuthService.java +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/AppAuthService.java @@ -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.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.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, "该设备注册过于频繁,请稍后再试"); } }