diff --git a/blade-auth/src/main/java/org/springblade/auth/endpoint/Oauth2SmsEndpoint.java b/blade-auth/src/main/java/org/springblade/auth/endpoint/Oauth2SmsEndpoint.java index dfa3b898..0729dfe8 100644 --- a/blade-auth/src/main/java/org/springblade/auth/endpoint/Oauth2SmsEndpoint.java +++ b/blade-auth/src/main/java/org/springblade/auth/endpoint/Oauth2SmsEndpoint.java @@ -39,6 +39,7 @@ import org.springblade.core.tool.utils.SM2Util; import org.springblade.core.tool.utils.StringUtil; import org.springblade.resource.feign.ISmsClient; import org.springblade.resource.utils.SmsUtil; +import org.springblade.system.pojo.enums.MpUserIdentity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -87,13 +88,18 @@ public class Oauth2SmsEndpoint { if (StringUtil.isBlank(decryptedPhone)) { return R.fail(USER_PHONE_NOT_FOUND); } - // 校验手机是否已注册,防止恶意发送验证码 // buildArgs:带上 User-Type 等请求头,小程序传 User-Type=mp 时查 blade_mp_user OAuth2Request request = OAuth2Request.create().buildArgs(); request.setTenantId(tenantId); - OAuth2User oAuth2User = userService.loadByPhone(decryptedPhone, request); - if (oAuth2User == null) { - return R.fail(USER_PHONE_NOT_FOUND); + // 「手机号是否已注册」只对 Web 保留。小程序端必须允许未注册手机号收码, + // 否则新号走不到「验证码校验通过后自动建号」那一步(票 13)。 + // 该处是全链路唯一的防短信轰炸点,放开小程序端不等于放开 Web; + // 这里只做查询,发码路径不建号,账号仍只在登录时创建。 + if (!isMpLoginRequest(request)) { + OAuth2User oAuth2User = userService.loadByPhone(decryptedPhone, request); + if (oAuth2User == null) { + return R.fail(USER_PHONE_NOT_FOUND); + } } // 使用指定短信资源(默认 ali_reg) String smsResourceCode = Func.toStr(code, BladeAuthConstant.LOGIN_SMS_CODE); @@ -101,4 +107,11 @@ public class Oauth2SmsEndpoint { return result.isSuccess() ? R.data(result.getData(), SmsUtil.SEND_SUCCESS) : R.fail(SmsUtil.SEND_FAIL); } + /** + * 是否小程序请求:请求头 User-Type=mp,与登录链路同一判据 + */ + private boolean isMpLoginRequest(OAuth2Request request) { + return request != null && MpUserIdentity.DETAIL_TYPE.equalsIgnoreCase(Func.toStr(request.getUserType())); + } + } diff --git a/blade-auth/src/test/java/org/springblade/auth/endpoint/Oauth2SmsEndpointTest.java b/blade-auth/src/test/java/org/springblade/auth/endpoint/Oauth2SmsEndpointTest.java new file mode 100644 index 00000000..71f49fcf --- /dev/null +++ b/blade-auth/src/test/java/org/springblade/auth/endpoint/Oauth2SmsEndpointTest.java @@ -0,0 +1,158 @@ +package org.springblade.auth.endpoint; + +import org.bouncycastle.crypto.AsymmetricCipherKeyPair; +import org.bouncycastle.util.encoders.Hex; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springblade.core.oauth2.props.OAuth2Properties; +import org.springblade.core.oauth2.service.OAuth2User; +import org.springblade.core.oauth2.service.OAuth2UserService; +import org.springblade.core.tool.api.R; +import org.springblade.core.tool.utils.SM2Util; +import org.springblade.resource.feign.ISmsClient; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springblade.core.oauth2.constant.OAuth2TokenConstant.USER_PHONE_NOT_FOUND; + +/** + * 发码端点的端分流测试。 + *

+ * 守住票 13 的口径:小程序端(User-Type: mp)对未注册手机号也要发码, + * 否则新号走不到「验证码校验通过后自动建号」;Web 端保持拒绝。 + * 同时守住「发码不建号」:mp 分支根本不查账号。 + */ +class Oauth2SmsEndpointTest { + + private static final String TENANT_ID = "000000"; + private static final String PHONE = "13800000001"; + private static final String SMS_CODE = "ali_reg"; + + private static String publicKey; + private static String privateKey; + + private ISmsClient smsClient; + private OAuth2UserService userService; + private Oauth2SmsEndpoint endpoint; + + @BeforeAll + static void initSm2Keys() { + AsymmetricCipherKeyPair keyPair = SM2Util.generateKeyPair(); + publicKey = SM2Util.getPublicKeyString(keyPair); + privateKey = SM2Util.getPrivateKeyString(keyPair); + } + + @BeforeEach + void setUp() { + OAuth2Properties properties = new OAuth2Properties(); + properties.setPublicKey(publicKey); + properties.setPrivateKey(privateKey); + smsClient = mock(ISmsClient.class); + userService = mock(OAuth2UserService.class); + endpoint = new Oauth2SmsEndpoint(smsClient, userService, properties); + when(smsClient.sendValidate(anyString(), anyString(), anyString())).thenReturn(R.data("sms-id-1")); + } + + @AfterEach + void clearRequestContext() { + RequestContextHolder.resetRequestAttributes(); + } + + @Test + @DisplayName("小程序 + 未注册手机号:发码成功,且完全不查账号(发码不建号)") + void mpUnregisteredPhoneSendsCode() { + bindRequest("mp"); + when(userService.loadByPhone(anyString(), any())).thenReturn(null); + + R result = endpoint.sendValidate(TENANT_ID, encrypt(PHONE), SMS_CODE); + + assertTrue(result.isSuccess()); + verify(smsClient).sendValidate(TENANT_ID, SMS_CODE, PHONE); + verify(userService, never()).loadByPhone(anyString(), any()); + } + + @Test + @DisplayName("Web + 未注册手机号:仍返回手机号不存在,不发码") + void webUnregisteredPhoneIsRejected() { + bindRequest(null); + when(userService.loadByPhone(anyString(), any())).thenReturn(null); + + R result = endpoint.sendValidate(TENANT_ID, encrypt(PHONE), SMS_CODE); + + assertFalse(result.isSuccess()); + assertEquals(USER_PHONE_NOT_FOUND, result.getMsg()); + verify(smsClient, never()).sendValidate(anyString(), anyString(), anyString()); + } + + @Test + @DisplayName("Web + 已注册手机号:行为与改造前一致,正常发码") + void webRegisteredPhoneSendsCode() { + bindRequest(null); + when(userService.loadByPhone(anyString(), any())).thenReturn(mock(OAuth2User.class)); + + R result = endpoint.sendValidate(TENANT_ID, encrypt(PHONE), SMS_CODE); + + assertTrue(result.isSuccess()); + verify(userService).loadByPhone(anyString(), any()); + verify(smsClient).sendValidate(TENANT_ID, SMS_CODE, PHONE); + } + + @Test + @DisplayName("小程序 + 已注册手机号:跳过注册查询,依旧发码") + void mpRegisteredPhoneSendsCode() { + bindRequest("mp"); + + R result = endpoint.sendValidate(TENANT_ID, encrypt(PHONE), SMS_CODE); + + assertTrue(result.isSuccess()); + verify(userService, never()).loadByPhone(anyString(), any()); + verify(smsClient).sendValidate(TENANT_ID, SMS_CODE, PHONE); + } + + @Test + @DisplayName("密文无法解密:无论哪端都不发码") + void invalidCipherIsRejected() { + bindRequest("mp"); + + R result = endpoint.sendValidate(TENANT_ID, "deadbeef", SMS_CODE); + + assertFalse(result.isSuccess()); + assertEquals(USER_PHONE_NOT_FOUND, result.getMsg()); + verify(smsClient, never()).sendValidate(anyString(), anyString(), anyString()); + } + + /** + * 请求上下文决定 buildArgs() 读到什么头;Authorization 是 buildClientArgs 的前提 + */ + private void bindRequest(String userType) { + MockHttpServletRequest request = new MockHttpServletRequest(); + String credential = Base64.getEncoder() + .encodeToString("saber:saber_secret".getBytes(StandardCharsets.UTF_8)); + request.addHeader("Authorization", "Basic " + credential); + if (userType != null) { + request.addHeader("User-Type", userType); + } + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + } + + private String encrypt(String plainPhone) { + return Hex.toHexString(SM2Util.encrypt(plainPhone, publicKey)); + } + +}