12 changed files with 328 additions and 23 deletions
@ -0,0 +1,57 @@ |
|||
package com.mydreamjob.auth.sms; |
|||
|
|||
import com.aliyun.dysmsapi20170525.Client; |
|||
import com.aliyun.dysmsapi20170525.models.SendSmsRequest; |
|||
import com.aliyun.dysmsapi20170525.models.SendSmsResponse; |
|||
import com.aliyun.teautil.models.RuntimeOptions; |
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import org.slf4j.Logger; |
|||
import org.slf4j.LoggerFactory; |
|||
import java.util.Set; |
|||
|
|||
/** SDK 返回 OK 只代表网关受理,不代表手机已收到;禁止自动重试非幂等发送。 */ |
|||
public final class AliyunSmsCodeSender implements SmsCodeSender { |
|||
private static final Logger LOG = LoggerFactory.getLogger(AliyunSmsCodeSender.class); |
|||
private static final Set<String> PURPOSES = Set.of("login", "password-set", "password-reset", "company-register"); |
|||
private final Client client; |
|||
private final String signName; |
|||
private final String templateCode; |
|||
private final int connectTimeoutMs; |
|||
private final int readTimeoutMs; |
|||
|
|||
public AliyunSmsCodeSender(Client client, AliyunSmsProperties properties) { |
|||
properties.validateForSending(); |
|||
this.client = client; |
|||
this.signName = properties.getSignName(); |
|||
this.templateCode = properties.getTemplateCode(); |
|||
this.connectTimeoutMs = properties.getConnectTimeoutMs(); |
|||
this.readTimeoutMs = properties.getReadTimeoutMs(); |
|||
} |
|||
|
|||
@Override |
|||
public void sendVerificationCode(String phone, String purpose, String code) { |
|||
if (phone == null || !phone.matches("[0-9]{11}") || purpose == null || !PURPOSES.contains(purpose) |
|||
|| code == null || !code.matches("[0-9]{6}")) { |
|||
throw new BusinessException(ErrorCode.BAD_REQUEST, "invalid SMS request"); |
|||
} |
|||
SendSmsRequest request = new SendSmsRequest().setPhoneNumbers(phone).setSignName(signName) |
|||
.setTemplateCode(templateCode).setTemplateParam("{\"code\":\"" + code + "\"}"); |
|||
RuntimeOptions runtime = new RuntimeOptions().setAutoretry(false) |
|||
.setConnectTimeout(connectTimeoutMs).setReadTimeout(readTimeoutMs); |
|||
SendSmsResponse response; |
|||
try { |
|||
response = client.sendSmsWithOptions(request, runtime); |
|||
} catch (Exception ignored) { |
|||
if (ignored instanceof InterruptedException) Thread.currentThread().interrupt(); |
|||
// SDK 异常可能包含请求绑定值,既不透传原因也不输出堆栈。
|
|||
LOG.warn("Aliyun SMS gateway call failed"); |
|||
throw new BusinessException(ErrorCode.SMS_SEND_FAILED); |
|||
} |
|||
if (response == null || response.getBody() == null || response.getStatusCode() == null |
|||
|| response.getStatusCode() != 200 || !"OK".equals(response.getBody().getCode())) { |
|||
LOG.warn("Aliyun SMS gateway did not accept request"); |
|||
throw new BusinessException(ErrorCode.SMS_SEND_FAILED); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,35 @@ |
|||
package com.mydreamjob.auth.sms; |
|||
|
|||
import lombok.Getter; |
|||
import lombok.Setter; |
|||
import org.springframework.boot.context.properties.ConfigurationProperties; |
|||
|
|||
/** 不生成 toString,避免配置对象输出凭据。只有显式 true 才启用。 */ |
|||
@Getter |
|||
@Setter |
|||
@ConfigurationProperties(prefix = "app.sms.aliyun") |
|||
public class AliyunSmsProperties { |
|||
private String enabled = "false"; |
|||
private String accessKeyId; |
|||
private String accessKeySecret; |
|||
private String signName; |
|||
private String templateCode; |
|||
private int connectTimeoutMs = 3000; |
|||
private int readTimeoutMs = 5000; |
|||
|
|||
public boolean explicitlyEnabled() { |
|||
return enabled != null && "true".equalsIgnoreCase(enabled.trim()); |
|||
} |
|||
|
|||
public void validateForSending() { |
|||
if (blank(accessKeyId) || blank(accessKeySecret) || blank(signName) || blank(templateCode) |
|||
|| !templateCode.matches("SMS_[A-Za-z0-9_]+") |
|||
|| connectTimeoutMs < 1000 || connectTimeoutMs > 30000 |
|||
|| readTimeoutMs < 1000 || readTimeoutMs > 30000) { |
|||
// 不使用包含 rejected value 的校验异常,避免绑定密钥进入启动日志。
|
|||
throw new IllegalStateException("SMS configuration incomplete or invalid"); |
|||
} |
|||
} |
|||
|
|||
private static boolean blank(String value) { return value == null || value.isBlank(); } |
|||
} |
|||
@ -0,0 +1,83 @@ |
|||
package com.mydreamjob.auth.sms; |
|||
|
|||
import com.aliyun.dysmsapi20170525.Client; |
|||
import com.aliyun.dysmsapi20170525.models.*; |
|||
import com.aliyun.teautil.models.RuntimeOptions; |
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.mockito.ArgumentCaptor; |
|||
import static org.assertj.core.api.Assertions.*; |
|||
import static org.mockito.ArgumentMatchers.any; |
|||
import static org.mockito.Mockito.*; |
|||
|
|||
class AliyunSmsCodeSenderTest { |
|||
private Client client; |
|||
private AliyunSmsCodeSender sender; |
|||
private static final String PHONE="19900009890", CODE="652901"; |
|||
@BeforeEach void setup() { |
|||
client=mock(Client.class); |
|||
var config=new AliyunSmsProperties(); |
|||
config.setAccessKeyId("test-key-not-real"); config.setAccessKeySecret("test-secret-not-real"); |
|||
config.setSignName("测试签名"); config.setTemplateCode("SMS_TEST_ONLY"); |
|||
sender=new AliyunSmsCodeSender(client,config); |
|||
} |
|||
private SendSmsResponse response(int http,String code) { |
|||
return new SendSmsResponse().setStatusCode(http).setBody(new SendSmsResponseBody().setCode(code)); |
|||
} |
|||
@Test void acceptedRequestUsesApprovedSignAndTemplateAndNeverRetries() throws Exception { |
|||
when(client.sendSmsWithOptions(any(),any())).thenReturn(response(200,"OK")); |
|||
sender.sendVerificationCode(PHONE,"login",CODE); |
|||
var req=ArgumentCaptor.forClass(SendSmsRequest.class); |
|||
var runtime=ArgumentCaptor.forClass(RuntimeOptions.class); |
|||
verify(client,times(1)).sendSmsWithOptions(req.capture(),runtime.capture()); |
|||
assertThat(req.getValue().getPhoneNumbers()).isEqualTo(PHONE); |
|||
assertThat(req.getValue().getSignName()).isEqualTo("测试签名"); |
|||
assertThat(req.getValue().getTemplateCode()).isEqualTo("SMS_TEST_ONLY"); |
|||
assertThat(req.getValue().getTemplateParam()).isEqualTo("{\"code\":\"652901\"}"); |
|||
assertThat(runtime.getValue().getAutoretry()).isFalse(); |
|||
assertThat(runtime.getValue().getConnectTimeout()).isEqualTo(3000); |
|||
assertThat(runtime.getValue().getReadTimeout()).isEqualTo(5000); |
|||
} |
|||
@Test void eachExistingPurposeCanSend() throws Exception { |
|||
when(client.sendSmsWithOptions(any(),any())).thenReturn(response(200,"OK")); |
|||
for(String purpose:new String[]{"login","password-set","password-reset","company-register"}) |
|||
sender.sendVerificationCode(PHONE,purpose,CODE); |
|||
verify(client,times(4)).sendSmsWithOptions(any(),any()); |
|||
} |
|||
@Test void providerBusinessRejectionNeverReturnsSuccess() throws Exception { |
|||
when(client.sendSmsWithOptions(any(),any())).thenReturn(response(200,"isv.BUSINESS_LIMIT_CONTROL")); |
|||
assertSafeFailure(); |
|||
} |
|||
@Test void httpFailureEvenWithOkIsRejected() throws Exception { |
|||
when(client.sendSmsWithOptions(any(),any())).thenReturn(response(500,"OK")); assertSafeFailure(); |
|||
} |
|||
@Test void emptyResponseIsRejected() throws Exception { |
|||
when(client.sendSmsWithOptions(any(),any())).thenReturn(null); assertSafeFailure(); |
|||
} |
|||
@Test void emptyBodyIsRejected() throws Exception { |
|||
when(client.sendSmsWithOptions(any(),any())).thenReturn(new SendSmsResponse().setStatusCode(200)); assertSafeFailure(); |
|||
} |
|||
@Test void missingStatusIsRejected() throws Exception { |
|||
when(client.sendSmsWithOptions(any(),any())).thenReturn(new SendSmsResponse().setBody(new SendSmsResponseBody().setCode("OK"))); assertSafeFailure(); |
|||
} |
|||
@Test void exceptionContainingSecretsIsDiscardedAndNotRetried() throws Exception { |
|||
when(client.sendSmsWithOptions(any(),any())).thenThrow(new java.net.SocketTimeoutException(PHONE+CODE+"test-secret-not-real")); |
|||
assertSafeFailure(); verify(client,times(1)).sendSmsWithOptions(any(),any()); |
|||
} |
|||
@Test void invalidInputsNeverCallProvider() { |
|||
for(String[] args:new String[][]{{null,"login",CODE},{"bad","login",CODE},{PHONE,null,CODE}, |
|||
{PHONE,"anything",CODE},{PHONE,"login",null},{PHONE,"login","1234"},{PHONE,"login","\"malicious"}}) { |
|||
assertThatThrownBy(()->sender.sendVerificationCode(args[0],args[1],args[2])).isInstanceOf(BusinessException.class); |
|||
} |
|||
verifyNoInteractions(client); |
|||
} |
|||
private void assertSafeFailure() { |
|||
assertThatThrownBy(()->sender.sendVerificationCode(PHONE,"login",CODE)).isInstanceOf(BusinessException.class) |
|||
.satisfies(error->{ |
|||
assertThat(((BusinessException)error).getErrorCodeInt()).isEqualTo(ErrorCode.SMS_SEND_FAILED.getCode()); |
|||
assertThat(error).hasNoCause().hasMessage("sms send failed"); |
|||
}); |
|||
} |
|||
} |
|||
@ -0,0 +1,42 @@ |
|||
package com.mydreamjob.auth.sms; |
|||
|
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.boot.test.context.runner.ApplicationContextRunner; |
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
|
|||
class AliyunSmsConfigurationTest { |
|||
private final ApplicationContextRunner runner = new ApplicationContextRunner() |
|||
.withUserConfiguration(SmsSenderConfiguration.class); |
|||
private ApplicationContextRunner configured() { |
|||
return runner.withPropertyValues("app.sms.aliyun.enabled=true", |
|||
"app.sms.aliyun.access-key-id=test-key-not-real", |
|||
"app.sms.aliyun.access-key-secret=test-secret-not-real", |
|||
"app.sms.aliyun.sign-name=测试签名", "app.sms.aliyun.template-code=SMS_TEST_ONLY"); |
|||
} |
|||
@Test void explicitlyConfiguredAliyunCreatesRealSenderWithoutNetworkCall() { |
|||
configured().run(context -> { |
|||
assertThat(context).hasNotFailed().hasSingleBean(SmsCodeSender.class); |
|||
assertThat(context.getBean(SmsCodeSender.class).getClass().getSimpleName()).isEqualTo("AliyunSmsCodeSender"); |
|||
}); |
|||
} |
|||
@Test void enabledWithMissingCredentialsFailsStartupSafely() { |
|||
runner.withPropertyValues("app.sms.aliyun.enabled=true").run(context -> { |
|||
assertThat(context).hasFailed(); |
|||
assertThat(context.getStartupFailure()).hasStackTraceContaining("SMS configuration incomplete"); |
|||
}); |
|||
} |
|||
@Test void mockAndRealSenderCannotBeEnabledTogether() { |
|||
configured().withPropertyValues("app.sms.mock.enabled=true").run(context -> assertThat(context).hasFailed()); |
|||
} |
|||
@Test void nonTrueValuesNeverEnableAliyun() { |
|||
for (String value : new String[]{"false", "", "yes", "1", "on"}) { |
|||
runner.withPropertyValues("app.sms.aliyun.enabled="+value).run(context -> { |
|||
assertThat(context).hasNotFailed().hasSingleBean(SmsCodeSender.class); |
|||
assertThat(context.getBean(SmsCodeSender.class)).isInstanceOf(UnavailableSmsCodeSender.class); |
|||
}); |
|||
} |
|||
} |
|||
@Test void defaultRemainsUnavailable() { |
|||
runner.run(context -> assertThat(context.getBean(SmsCodeSender.class)).isInstanceOf(UnavailableSmsCodeSender.class)); |
|||
} |
|||
} |
|||
@ -0,0 +1,65 @@ |
|||
package com.mydreamjob.auth.sms; |
|||
|
|||
import com.mydreamjob.auth.SmsVerificationCodeRepository; |
|||
import com.mydreamjob.audit.AuditLogRepository; |
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.AfterEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; |
|||
import org.springframework.boot.test.context.SpringBootTest; |
|||
import org.springframework.boot.test.mock.mockito.MockBean; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.data.redis.core.ValueOperations; |
|||
import org.springframework.test.context.ActiveProfiles; |
|||
import org.springframework.test.web.servlet.MockMvc; |
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.mockito.ArgumentMatchers.*; |
|||
import static org.mockito.Mockito.*; |
|||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; |
|||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; |
|||
import java.util.concurrent.TimeUnit; |
|||
|
|||
/** 真实 Spring 事务与 H2;外部发送器为测试替身,不冒充真实阿里云验收。 */ |
|||
@SpringBootTest(properties="spring.datasource.url=jdbc:h2:mem:aliyunsmsrollback;MODE=MySQL;DB_CLOSE_DELAY=-1") |
|||
@AutoConfigureMockMvc |
|||
@ActiveProfiles("integration-test") |
|||
class AliyunSmsHttpRollbackTest { |
|||
private static final String PHONE="19900009891"; |
|||
@MockBean SmsCodeSender sender; |
|||
@MockBean RedisTemplate<String,Object> redis; |
|||
@Autowired MockMvc mvc; |
|||
@Autowired SmsVerificationCodeRepository codes; |
|||
@Autowired AuditLogRepository audits; |
|||
@BeforeEach @SuppressWarnings("unchecked") void setup() { |
|||
assertThat(codes.findByPhoneAndPurposeOrderByCreatedAtDesc(PHONE,"login")).isEmpty(); |
|||
var ops=mock(ValueOperations.class); |
|||
when(ops.setIfAbsent(anyString(),eq("1"),eq(60L),eq(TimeUnit.SECONDS))).thenReturn(true); |
|||
when(redis.opsForValue()).thenReturn(ops); |
|||
when(redis.hasKey(anyString())).thenReturn(false); |
|||
} |
|||
@AfterEach void cleanup() { |
|||
codes.deleteAll(codes.findByPhoneAndPurposeOrderByCreatedAtDesc(PHONE,"login")); |
|||
} |
|||
@Test void rejectionRollsBackCodeAndSuccessAuditAndPreservesBusinessCode() throws Exception { |
|||
long before=audits.count(); |
|||
doThrow(new BusinessException(ErrorCode.SMS_SEND_FAILED)).when(sender).sendVerificationCode(eq(PHONE),eq("login"),anyString()); |
|||
String body=mvc.perform(post("/auth/sms/send").param("phone",PHONE).param("purpose","login")) |
|||
.andExpect(status().isBadRequest()).andExpect(jsonPath("$.code").value(3006)) |
|||
.andExpect(jsonPath("$.message").value("sms send failed")).andReturn().getResponse().getContentAsString(); |
|||
assertThat(body).doesNotContain(PHONE,"test-secret", "Exception", "at com."); |
|||
assertThat(codes.findByPhoneAndPurposeOrderByCreatedAtDesc(PHONE,"login")).isEmpty(); |
|||
assertThat(audits.count()).isEqualTo(before); |
|||
verify(sender,times(1)).sendVerificationCode(eq(PHONE),eq("login"),anyString()); |
|||
} |
|||
@Test void acceptanceDoesNotPretendToConfirmDeliveryOrReturnCode() throws Exception { |
|||
var response=mvc.perform(post("/auth/sms/send").param("phone",PHONE).param("purpose","login")) |
|||
.andExpect(status().isOk()).andExpect(jsonPath("$.code").value(200)) |
|||
.andExpect(jsonPath("$.data.message").value("验证码发送请求已受理")) |
|||
.andReturn().getResponse().getContentAsString(); |
|||
assertThat(response).doesNotContain("Mock",PHONE,"smsCode","验证码已送达"); |
|||
assertThat(codes.findByPhoneAndPurposeOrderByCreatedAtDesc(PHONE,"login")).hasSize(1); |
|||
} |
|||
} |
|||
Loading…
Reference in new issue