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/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, "该设备注册过于频繁,请稍后再试"); } }