feat(passport): staging 限定免真实短信验证登录——内测解锁(profile 门控·生产不受影响)
创始人:「allow login and registration without SMS verification for now」。staging 未接真实 短信网关,随机码外人无从得知→创始人人测/e2e UI 走查均无法登录。 实现(信任边界=后端服务层 PassportServiceImpl,profile/配置级门控): - 新增 @Value sms-mock-enabled(缺省 false)+ sms-mock-code(缺省 8888) - sendSmsCode:开关开→mock 受理成功(不依赖真实网关) - smsLogin:开关开 ∧ 提交码==固定码 才跳过 system_sms_code 校验,否则真实校验(双条件,非无脑放行) - 仅 application-staging.yaml 置 true;生产/dev/local 不配→@Value 缺省 false→真实短信校验绝不削弱 - log 经 DesensitizedUtil 脱敏手机号;安全注记三处冗余(Javadoc/行内+log.warn/yaml) ⚠ 公网上线前必须关闭(=实名/短信网关日历闸门):开启=任何人凭 8888 登录任意号,绝不可进生产。 编译 BUILD SUCCESS + 20/20 单测(含 5 新增,2 个"开关关→生产不受影响"反证)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3c6e3d4ad0
commit
23decfefaf
@ -86,6 +86,30 @@ public class PassportServiceImpl implements PassportService {
|
||||
@Value("${wanxiang.passport.invite-register-enabled:true}")
|
||||
private boolean inviteRegisterEnabled;
|
||||
|
||||
/**
|
||||
* ⚠⚠⚠ 仅内测/staging「免真实短信验证」总开关(信任边界=后端,profile/配置级门控)。
|
||||
* <p>
|
||||
* 背景:staging 未接真实短信网关,发码会因无短信渠道/模板而失败,且随机验证码外人无从得知,
|
||||
* 导致创始人人测与 e2e UI 走查均无法登录。开启后:① 发码不依赖真实网关(mock 受理成功)
|
||||
* ② sms-login 接受固定测试码({@link #smsMockCode})即登录/自动注册成功(任意合法手机号皆可)。
|
||||
* <p>
|
||||
* <b>缺省 false(生产/本地默认关闭,走真实短信发码+校验,绝不被本改动削弱)</b>;
|
||||
* 仅 application-staging.yaml 显式置 true。门控在后端服务层(本类,§6.3 既定信任边界),不依赖前端。
|
||||
* <p>
|
||||
* ⚠ 公网上线前必须关闭(=实名/短信网关日历闸门,见 mvp-binding-constraint-calendar-gates 记忆):
|
||||
* 开启 = 任何人凭固定码 {@link #smsMockCode} 即可登录任意手机号,<b>绝不可进生产</b>。
|
||||
*/
|
||||
@Value("${wanxiang.passport.sms-mock-enabled:false}")
|
||||
private boolean smsMockEnabled;
|
||||
|
||||
/**
|
||||
* ⚠ 内测「免真实短信验证」固定测试码(仅 {@link #smsMockEnabled}=true 时生效,可配,默认 8888)。
|
||||
* <p>
|
||||
* sms-login 提交此码即视为验证通过(跳过 system_sms_code 表校验)。生产不开启 sms-mock-enabled 时本值无意义。
|
||||
*/
|
||||
@Value("${wanxiang.passport.sms-mock-code:8888}")
|
||||
private String smsMockCode;
|
||||
|
||||
@Override
|
||||
public void sendSmsCode(String mobile, String clientIp) {
|
||||
// R6 §7.2:先做 IP 维度频控(每小时/每日上限),不改上游 SmsCodeServiceImpl(手机号维度频控由其内部承载)。
|
||||
@ -100,6 +124,15 @@ public class PassportServiceImpl implements PassportService {
|
||||
throw exception(GlobalErrorCodeConstants.TOO_MANY_REQUESTS);
|
||||
}
|
||||
}
|
||||
// ⚠ 仅内测/staging:免真实短信验证开关开启时,发码不依赖真实网关(mock 受理成功直接返回)。
|
||||
// 原因:staging 未配短信渠道/模板,走 SmsCodeService.sendSmsCode 会因渠道/模板缺失抛异常(发不出码)。
|
||||
// 开启后 sms-login 用固定测试码 smsMockCode 登录(见 smsLogin),故此处无需真实落库验证码。
|
||||
// 生产默认关闭(smsMockEnabled=false),不进此分支,照常走真实发码+校验,绝不被削弱。
|
||||
if (smsMockEnabled) {
|
||||
log.warn("[sendSmsCode] ⚠ 内测免短信验证开关开启,跳过真实发码(mock 受理)mobile={}(仅 staging,公网上线前必须关闭)",
|
||||
DesensitizedUtil.mobilePhone(mobile));
|
||||
return;
|
||||
}
|
||||
// 防枚举:不校验手机号是否已注册(已注册→登录、未注册→自动注册均发码),统一受理。
|
||||
SmsCodeSendReqDTO reqDTO = new SmsCodeSendReqDTO();
|
||||
reqDTO.setMobile(mobile);
|
||||
@ -114,12 +147,21 @@ public class PassportServiceImpl implements PassportService {
|
||||
@Transactional(rollbackFor = Exception.class) // 自动注册建号与验证码使用需同一本地事务(避免验证码已用但建号失败的半截态)
|
||||
public LoginRespVO smsLogin(SmsLoginReqVO reqVO, String clientIp) {
|
||||
// 1) 校验并使用验证码(场景 MEMBER_LOGIN);不存在/过期/已用由 SmsCodeService 抛精确错误码
|
||||
SmsCodeUseReqDTO useReqDTO = new SmsCodeUseReqDTO();
|
||||
useReqDTO.setMobile(reqVO.getMobile());
|
||||
useReqDTO.setCode(reqVO.getCode());
|
||||
useReqDTO.setScene(SmsSceneEnum.MEMBER_LOGIN.getScene());
|
||||
useReqDTO.setUsedIp(clientIp);
|
||||
smsCodeService.useSmsCode(useReqDTO);
|
||||
// ⚠ 仅内测/staging:免短信验证开关开启 且 提交码==固定测试码 smsMockCode 时,跳过 system_sms_code 表校验,
|
||||
// 直接视为验证通过(任意合法手机号 + 固定码即可登录/自动注册)。否则一律走真实校验。
|
||||
// 信任边界在后端:生产 smsMockEnabled=false(缺省)不进此分支,真实验证码校验不可绕过,绝不被削弱。
|
||||
// ⚠ 公网上线前必须关闭 sms-mock-enabled(实名/短信网关日历闸门):开启=任何人凭 8888 登录任意号。
|
||||
if (smsMockEnabled && smsMockCode.equals(reqVO.getCode())) {
|
||||
log.warn("[smsLogin] ⚠ 内测免短信验证开关开启,固定测试码命中,跳过验证码校验 mobile={}(仅 staging,公网上线前必须关闭)",
|
||||
DesensitizedUtil.mobilePhone(reqVO.getMobile()));
|
||||
} else {
|
||||
SmsCodeUseReqDTO useReqDTO = new SmsCodeUseReqDTO();
|
||||
useReqDTO.setMobile(reqVO.getMobile());
|
||||
useReqDTO.setCode(reqVO.getCode());
|
||||
useReqDTO.setScene(SmsSceneEnum.MEMBER_LOGIN.getScene());
|
||||
useReqDTO.setUsedIp(clientIp);
|
||||
smsCodeService.useSmsCode(useReqDTO);
|
||||
}
|
||||
|
||||
// 2) 已注册→登录;未注册→自动注册(register_channel=sms,验证码即占有证明,拍板3)
|
||||
PlayerDO player = playerMapper.selectByMobile(reqVO.getMobile());
|
||||
|
||||
@ -65,6 +65,9 @@ class PassportServiceImplTest extends BaseMockitoUnitTest {
|
||||
ReflectionTestUtils.setField(passportService, "smsIpLimitPerHour", 10);
|
||||
ReflectionTestUtils.setField(passportService, "smsIpLimitPerDay", 30);
|
||||
ReflectionTestUtils.setField(passportService, "inviteRegisterEnabled", true);
|
||||
// ⚠ 免短信验证开关默认 false(与生产缺省一致);mock 路径用例在各自 case 内显式置 true
|
||||
ReflectionTestUtils.setField(passportService, "smsMockEnabled", false);
|
||||
ReflectionTestUtils.setField(passportService, "smsMockCode", "8888");
|
||||
}
|
||||
|
||||
// ============================== sendSmsCode IP 频控 ==============================
|
||||
@ -110,6 +113,95 @@ class PassportServiceImplTest extends BaseMockitoUnitTest {
|
||||
verify(smsCodeService, never()).sendSmsCode(any());
|
||||
}
|
||||
|
||||
// ============================== ⚠ 内测免短信验证开关(仅 staging) ==============================
|
||||
|
||||
@Test
|
||||
@DisplayName("免短信发码:开关开 → 跳过真实发码(mock 受理)+ IP 频控仍生效")
|
||||
void testSendSmsCode_mockEnabledSkipsRealSend() {
|
||||
ReflectionTestUtils.setField(passportService, "smsMockEnabled", true);
|
||||
when(smsIpCounterRedisDAO.incrHourAndGet("1.2.3.4")).thenReturn(1L);
|
||||
when(smsIpCounterRedisDAO.incrDayAndGet("1.2.3.4")).thenReturn(1L);
|
||||
|
||||
passportService.sendSmsCode("13800000000", "1.2.3.4");
|
||||
|
||||
// 开关开:不调用真实发码(不依赖短信渠道/模板,规避 staging 无网关失败)
|
||||
verify(smsCodeService, never()).sendSmsCode(any());
|
||||
// IP 频控仍执行(先增后判,防刷不被绕过)
|
||||
verify(smsIpCounterRedisDAO).incrHourAndGet("1.2.3.4");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("免短信登录:开关开 + 固定测试码 8888 → 跳过验证码校验,自动注册并发 token")
|
||||
void testSmsLogin_mockEnabledFixedCodeBypass() {
|
||||
ReflectionTestUtils.setField(passportService, "smsMockEnabled", true);
|
||||
SmsLoginReqVO reqVO = new SmsLoginReqVO();
|
||||
reqVO.setMobile("13800000000");
|
||||
reqVO.setCode("8888"); // 固定测试码
|
||||
when(playerMapper.selectByMobile("13800000000")).thenReturn(null); // 未注册→自动注册
|
||||
doAnswer(inv -> {
|
||||
PlayerDO d = inv.getArgument(0);
|
||||
d.setId(2048L);
|
||||
return 1;
|
||||
}).when(playerMapper).insert(any(PlayerDO.class));
|
||||
stubToken(2048L);
|
||||
|
||||
LoginRespVO resp = passportService.smsLogin(reqVO, "1.2.3.4");
|
||||
|
||||
// 固定码命中:跳过 system_sms_code 校验(不调 useSmsCode)
|
||||
verify(smsCodeService, never()).useSmsCode(any());
|
||||
// 仍正常自动注册 + 发真 token
|
||||
verify(playerMapper).insert(any(PlayerDO.class));
|
||||
assertEquals(2048L, resp.getUserId());
|
||||
assertEquals("happy-token", resp.getAccessToken());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("免短信登录:开关开 但 码≠固定测试码 → 仍走真实验证码校验(不无脑放行)")
|
||||
void testSmsLogin_mockEnabledButWrongCodeStillValidates() {
|
||||
ReflectionTestUtils.setField(passportService, "smsMockEnabled", true);
|
||||
SmsLoginReqVO reqVO = new SmsLoginReqVO();
|
||||
reqVO.setMobile("13800000000");
|
||||
reqVO.setCode("1234"); // 非固定码
|
||||
PlayerDO exist = player(2048L, 0, 0);
|
||||
when(playerMapper.selectByMobile("13800000000")).thenReturn(exist);
|
||||
stubToken(2048L);
|
||||
|
||||
passportService.smsLogin(reqVO, "1.2.3.4");
|
||||
|
||||
// 码不等于固定测试码:仍调真实校验(开关只对固定码放行,避免任意码登录)
|
||||
verify(smsCodeService).useSmsCode(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("免短信登录:开关关(生产缺省)+ 固定码 8888 → 不放行,照常走真实校验(生产不受影响)")
|
||||
void testSmsLogin_mockDisabledFixedCodeStillValidates() {
|
||||
// smsMockEnabled 默认 false(setUp 已置),模拟生产 profile
|
||||
SmsLoginReqVO reqVO = new SmsLoginReqVO();
|
||||
reqVO.setMobile("13800000000");
|
||||
reqVO.setCode("8888"); // 即便是固定码,开关关也不放行
|
||||
PlayerDO exist = player(2048L, 0, 0);
|
||||
when(playerMapper.selectByMobile("13800000000")).thenReturn(exist);
|
||||
stubToken(2048L);
|
||||
|
||||
passportService.smsLogin(reqVO, "1.2.3.4");
|
||||
|
||||
// 生产铁律:开关关 → 真实验证码校验不可绕过
|
||||
verify(smsCodeService).useSmsCode(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("免短信发码:开关关(生产缺省)→ 照常走真实发码(生产不受影响)")
|
||||
void testSendSmsCode_mockDisabledUsesRealSend() {
|
||||
// smsMockEnabled 默认 false(setUp 已置)
|
||||
when(smsIpCounterRedisDAO.incrHourAndGet("1.2.3.4")).thenReturn(1L);
|
||||
when(smsIpCounterRedisDAO.incrDayAndGet("1.2.3.4")).thenReturn(1L);
|
||||
|
||||
passportService.sendSmsCode("13800000000", "1.2.3.4");
|
||||
|
||||
// 生产铁律:开关关 → 真实发码照常执行
|
||||
verify(smsCodeService).sendSmsCode(any());
|
||||
}
|
||||
|
||||
// ============================== validateCreator 三分支(含 mock 豁免) ==============================
|
||||
|
||||
@Test
|
||||
|
||||
@ -190,3 +190,11 @@ wanxiang:
|
||||
invite-register-enabled: true # 邀请码注册旁路开关(§5.4):报备前 true,报备后置 false 退役旁路
|
||||
sms-ip-limit-per-hour: 10 # 发码 IP 每小时上限(R6 §7.2,推断初值,与运营确认后调整)
|
||||
sms-ip-limit-per-day: 30 # 发码 IP 每日上限(R6 §7.2,日桶兜底防单 IP 刷量)
|
||||
# ⚠⚠⚠ 内测「免真实短信验证」开关 —— 仅 staging/内测解锁登录用,绝不可进生产 ⚠⚠⚠
|
||||
# 背景:staging 未接真实短信网关,发码会失败且随机码外人无从得知,创始人人测/e2e UI 走查无法登录。
|
||||
# 开启后:① 发码不依赖真实网关(mock 受理成功)② sms-login 接受固定测试码 sms-mock-code(默认 8888)
|
||||
# 即登录/自动注册成功(任意合法手机号皆可)。门控在后端服务层 PassportServiceImpl(信任边界=后端,不靠前端)。
|
||||
# 生产 profile 不配此项 → 后端 @Value 缺省 false → 走真实短信发码+校验,本开关绝不削弱生产鉴权。
|
||||
# ⚠ 公网上线前必须置 false / 删除本项(=实名/短信网关日历闸门):开启=任何人凭 8888 登录任意手机号。
|
||||
sms-mock-enabled: true # ⚠ 仅 staging 内测:免真实短信验证(缺省 false,生产绝不开启)
|
||||
sms-mock-code: "8888" # ⚠ 仅 staging 内测:免短信验证的固定测试码(配合 sms-mock-enabled)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user