15 changed files with 412 additions and 19 deletions
@ -0,0 +1,23 @@ |
|||||
|
package com.mydreamjob.auth; |
||||
|
|
||||
|
import lombok.RequiredArgsConstructor; |
||||
|
import org.springframework.stereotype.Service; |
||||
|
import org.springframework.transaction.annotation.Propagation; |
||||
|
import org.springframework.transaction.annotation.Transactional; |
||||
|
|
||||
|
import java.time.LocalDateTime; |
||||
|
|
||||
|
/** |
||||
|
* Persist an invalid-code attempt even when the surrounding login or registration rolls back. |
||||
|
* A conditional database update prevents concurrent guesses from losing increments. |
||||
|
*/ |
||||
|
@Service |
||||
|
@RequiredArgsConstructor |
||||
|
public class SmsFailedAttemptRecorder { |
||||
|
private final SmsVerificationCodeRepository codes; |
||||
|
|
||||
|
@Transactional(propagation = Propagation.REQUIRES_NEW) |
||||
|
public void record(Long codeId, LocalDateTime now) { |
||||
|
codes.incrementFailedAttemptsIfValid(codeId, now); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,112 @@ |
|||||
|
package com.mydreamjob.auth; |
||||
|
|
||||
|
import com.mydreamjob.common.exception.BusinessException; |
||||
|
import com.mydreamjob.common.exception.ErrorCode; |
||||
|
import org.junit.jupiter.api.AfterEach; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.springframework.beans.factory.annotation.Autowired; |
||||
|
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.context.DynamicPropertyRegistry; |
||||
|
import org.springframework.test.context.DynamicPropertySource; |
||||
|
import org.testcontainers.containers.MySQLContainer; |
||||
|
import org.testcontainers.junit.jupiter.Container; |
||||
|
import org.testcontainers.junit.jupiter.Testcontainers; |
||||
|
|
||||
|
import java.time.LocalDateTime; |
||||
|
import java.util.ArrayList; |
||||
|
import java.util.List; |
||||
|
import java.util.concurrent.CountDownLatch; |
||||
|
import java.util.concurrent.ExecutorService; |
||||
|
import java.util.concurrent.Executors; |
||||
|
import java.util.concurrent.Future; |
||||
|
import java.util.concurrent.TimeUnit; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy; |
||||
|
import static org.mockito.ArgumentMatchers.anyString; |
||||
|
import static org.mockito.Mockito.mock; |
||||
|
import static org.mockito.Mockito.when; |
||||
|
|
||||
|
@Testcontainers(disabledWithoutDocker = false) |
||||
|
@SpringBootTest |
||||
|
@ActiveProfiles("mysql-it") |
||||
|
class SmsFailedAttemptMySqlConcurrencyTest { |
||||
|
private static final String PHONE = "19900009683"; |
||||
|
private static final String PURPOSE = "company-register"; |
||||
|
|
||||
|
@Container |
||||
|
static final MySQLContainer<?> MYSQL = new MySQLContainer<>("mysql:8.4") |
||||
|
.withDatabaseName("sms_attempts_p0") |
||||
|
.withUsername("sms_attempts_test") |
||||
|
.withPassword("sms-attempts-test"); |
||||
|
|
||||
|
@DynamicPropertySource |
||||
|
static void mysqlProperties(DynamicPropertyRegistry registry) { |
||||
|
registry.add("spring.datasource.url", MYSQL::getJdbcUrl); |
||||
|
registry.add("spring.datasource.username", MYSQL::getUsername); |
||||
|
registry.add("spring.datasource.password", MYSQL::getPassword); |
||||
|
} |
||||
|
|
||||
|
@Autowired private SmsVerificationService verificationService; |
||||
|
@Autowired private SmsVerificationCodeRepository codes; |
||||
|
@MockBean private RedisTemplate<String, Object> redis; |
||||
|
|
||||
|
@AfterEach |
||||
|
void cleanUp() { |
||||
|
codes.deleteAll(codes.findByPhoneAndPurposeOrderByCreatedAtDesc(PHONE, PURPOSE)); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@SuppressWarnings("unchecked") |
||||
|
void concurrentWrongGuessesCannotLoseIncrementsOrExceedConfiguredLimit() throws Exception { |
||||
|
codes.deleteAll(codes.findByPhoneAndPurposeOrderByCreatedAtDesc(PHONE, PURPOSE)); |
||||
|
ValueOperations<String, Object> values = mock(ValueOperations.class); |
||||
|
when(redis.hasKey(anyString())).thenReturn(false); |
||||
|
when(redis.opsForValue()).thenReturn(values); |
||||
|
Long codeId = codes.saveAndFlush(SmsVerificationCode.builder() |
||||
|
.phone(PHONE).purpose(PURPOSE).code("123456") |
||||
|
.attempts(0).maxAttempts(5).used(false) |
||||
|
.expiredAt(LocalDateTime.now().plusMinutes(5)).build()).getId(); |
||||
|
|
||||
|
int requests = 8; |
||||
|
ExecutorService pool = Executors.newFixedThreadPool(requests); |
||||
|
try { |
||||
|
CountDownLatch ready = new CountDownLatch(requests); |
||||
|
CountDownLatch start = new CountDownLatch(1); |
||||
|
List<Future<Integer>> futures = new ArrayList<>(); |
||||
|
for (int i = 0; i < requests; i++) { |
||||
|
futures.add(pool.submit(() -> { |
||||
|
ready.countDown(); |
||||
|
assertThat(start.await(10, TimeUnit.SECONDS)).isTrue(); |
||||
|
try { |
||||
|
verificationService.verifyCode(PHONE, "000000", PURPOSE); |
||||
|
return -1; |
||||
|
} catch (BusinessException error) { |
||||
|
return error.getErrorCodeInt(); |
||||
|
} |
||||
|
})); |
||||
|
} |
||||
|
assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue(); |
||||
|
start.countDown(); |
||||
|
for (Future<Integer> future : futures) { |
||||
|
assertThat(future.get(30, TimeUnit.SECONDS)) |
||||
|
.isIn(ErrorCode.SMS_CODE_INVALID.getCode(), |
||||
|
ErrorCode.SMS_CODE_ATTEMPTS_EXCEEDED.getCode()); |
||||
|
} |
||||
|
} finally { |
||||
|
pool.shutdownNow(); |
||||
|
} |
||||
|
|
||||
|
SmsVerificationCode persisted = codes.findById(codeId).orElseThrow(); |
||||
|
assertThat(persisted.getAttempts()).isEqualTo(5); |
||||
|
assertThat(persisted.getUsed()).isFalse(); |
||||
|
assertThatThrownBy(() -> verificationService.verifyCode(PHONE, "123456", PURPOSE)) |
||||
|
.isInstanceOf(BusinessException.class) |
||||
|
.satisfies(error -> assertThat(((BusinessException) error).getErrorCodeInt()) |
||||
|
.isEqualTo(ErrorCode.SMS_CODE_ATTEMPTS_EXCEEDED.getCode())); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,82 @@ |
|||||
|
package com.mydreamjob.auth; |
||||
|
|
||||
|
import com.mydreamjob.common.exception.BusinessException; |
||||
|
import com.mydreamjob.common.exception.ErrorCode; |
||||
|
import org.junit.jupiter.api.AfterEach; |
||||
|
import org.junit.jupiter.api.BeforeEach; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.springframework.beans.factory.annotation.Autowired; |
||||
|
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.transaction.PlatformTransactionManager; |
||||
|
import org.springframework.transaction.support.TransactionTemplate; |
||||
|
|
||||
|
import java.time.LocalDateTime; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy; |
||||
|
import static org.mockito.ArgumentMatchers.anyString; |
||||
|
import static org.mockito.Mockito.mock; |
||||
|
import static org.mockito.Mockito.when; |
||||
|
|
||||
|
/** Exercises the real transactional proxy and database, without a test-level transaction. */ |
||||
|
@SpringBootTest |
||||
|
@ActiveProfiles("integration-test") |
||||
|
class SmsFailedAttemptPersistenceIntegrationTest { |
||||
|
private static final String PHONE = "19900009681"; |
||||
|
private static final String PURPOSE = "company-register"; |
||||
|
|
||||
|
@Autowired private SmsVerificationService verificationService; |
||||
|
@Autowired private SmsVerificationCodeRepository codes; |
||||
|
@Autowired private PlatformTransactionManager transactionManager; |
||||
|
@MockBean private RedisTemplate<String, Object> redis; |
||||
|
|
||||
|
private Long codeId; |
||||
|
|
||||
|
@BeforeEach |
||||
|
@SuppressWarnings("unchecked") |
||||
|
void setUp() { |
||||
|
codes.deleteAll(codes.findByPhoneAndPurposeOrderByCreatedAtDesc(PHONE, PURPOSE)); |
||||
|
ValueOperations<String, Object> values = mock(ValueOperations.class); |
||||
|
when(redis.hasKey(anyString())).thenReturn(false); |
||||
|
when(redis.opsForValue()).thenReturn(values); |
||||
|
codeId = codes.saveAndFlush(SmsVerificationCode.builder() |
||||
|
.phone(PHONE).purpose(PURPOSE).code("123456") |
||||
|
.attempts(0).maxAttempts(5).used(false) |
||||
|
.expiredAt(LocalDateTime.now().plusMinutes(5)).build()).getId(); |
||||
|
} |
||||
|
|
||||
|
@AfterEach |
||||
|
void cleanUp() { |
||||
|
codes.deleteAll(codes.findByPhoneAndPurposeOrderByCreatedAtDesc(PHONE, PURPOSE)); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void wrongAttemptsPersistDespiteBusinessExceptionAndEventuallyBlockCorrectCode() { |
||||
|
for (int attempt = 1; attempt <= 5; attempt++) { |
||||
|
assertThatThrownBy(() -> verificationService.verifyCode(PHONE, "000000", PURPOSE)) |
||||
|
.isInstanceOf(BusinessException.class) |
||||
|
.satisfies(error -> assertThat(((BusinessException) error).getErrorCodeInt()) |
||||
|
.isEqualTo(ErrorCode.SMS_CODE_INVALID.getCode())); |
||||
|
assertThat(codes.findById(codeId).orElseThrow().getAttempts()).isEqualTo(attempt); |
||||
|
} |
||||
|
|
||||
|
assertThatThrownBy(() -> verificationService.verifyCode(PHONE, "123456", PURPOSE)) |
||||
|
.isInstanceOf(BusinessException.class) |
||||
|
.satisfies(error -> assertThat(((BusinessException) error).getErrorCodeInt()) |
||||
|
.isEqualTo(ErrorCode.SMS_CODE_ATTEMPTS_EXCEEDED.getCode())); |
||||
|
assertThat(codes.findById(codeId).orElseThrow().getUsed()).isFalse(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void wrongAttemptSurvivesRollbackOfAnOuterRegistrationLikeTransaction() { |
||||
|
TransactionTemplate outer = new TransactionTemplate(transactionManager); |
||||
|
assertThatThrownBy(() -> outer.execute(status -> |
||||
|
verificationService.verifyCode(PHONE, "000000", PURPOSE))) |
||||
|
.isInstanceOf(BusinessException.class); |
||||
|
assertThat(codes.findById(codeId).orElseThrow().getAttempts()).isEqualTo(1); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,43 @@ |
|||||
|
package com.mydreamjob.auth; |
||||
|
|
||||
|
import com.mydreamjob.audit.AuditService; |
||||
|
import com.mydreamjob.auth.sms.SmsCodeSender; |
||||
|
import com.mydreamjob.common.exception.BusinessException; |
||||
|
import com.mydreamjob.common.exception.ErrorCode; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.springframework.data.redis.core.RedisTemplate; |
||||
|
import org.springframework.data.redis.core.ValueOperations; |
||||
|
|
||||
|
import java.util.concurrent.TimeUnit; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy; |
||||
|
import static org.mockito.ArgumentMatchers.any; |
||||
|
import static org.mockito.ArgumentMatchers.eq; |
||||
|
import static org.mockito.Mockito.mock; |
||||
|
import static org.mockito.Mockito.never; |
||||
|
import static org.mockito.Mockito.verify; |
||||
|
import static org.mockito.Mockito.when; |
||||
|
|
||||
|
class SmsSendRateLimitAtomicityTest { |
||||
|
@Test |
||||
|
@SuppressWarnings("unchecked") |
||||
|
void losingAtomicRedisReservationNeverPersistsOrSendsAnotherCode() { |
||||
|
SmsVerificationCodeRepository codes = mock(SmsVerificationCodeRepository.class); |
||||
|
RedisTemplate<String, Object> redis = mock(RedisTemplate.class); |
||||
|
ValueOperations<String, Object> values = mock(ValueOperations.class); |
||||
|
SmsCodeSender sender = mock(SmsCodeSender.class); |
||||
|
SmsVerificationService service = new SmsVerificationService(codes, redis, |
||||
|
mock(AuditService.class), sender, mock(SmsFailedAttemptRecorder.class)); |
||||
|
when(redis.opsForValue()).thenReturn(values); |
||||
|
when(values.setIfAbsent("sms:rate:19900009682", "1", 60L, TimeUnit.SECONDS)) |
||||
|
.thenReturn(false); |
||||
|
|
||||
|
assertThatThrownBy(() -> service.sendCode("19900009682", "login", null, null, null)) |
||||
|
.isInstanceOf(BusinessException.class) |
||||
|
.satisfies(error -> assertThat(((BusinessException) error).getErrorCodeInt()) |
||||
|
.isEqualTo(ErrorCode.SMS_RATE_LIMITED.getCode())); |
||||
|
verify(codes, never()).save(any()); |
||||
|
verify(sender, never()).sendVerificationCode(any(), any(), any()); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,109 @@ |
|||||
|
package com.mydreamjob.auth; |
||||
|
|
||||
|
import com.mydreamjob.audit.AuditService; |
||||
|
import com.mydreamjob.auth.sms.SmsCodeSender; |
||||
|
import com.mydreamjob.common.exception.BusinessException; |
||||
|
import com.mydreamjob.common.exception.ErrorCode; |
||||
|
import org.junit.jupiter.api.AfterEach; |
||||
|
import org.junit.jupiter.api.BeforeEach; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; |
||||
|
import org.springframework.data.redis.core.RedisTemplate; |
||||
|
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; |
||||
|
import org.springframework.data.redis.serializer.StringRedisSerializer; |
||||
|
import org.springframework.test.util.ReflectionTestUtils; |
||||
|
import org.testcontainers.containers.GenericContainer; |
||||
|
import org.testcontainers.junit.jupiter.Container; |
||||
|
import org.testcontainers.junit.jupiter.Testcontainers; |
||||
|
import org.testcontainers.utility.DockerImageName; |
||||
|
|
||||
|
import java.util.ArrayList; |
||||
|
import java.util.List; |
||||
|
import java.util.concurrent.CountDownLatch; |
||||
|
import java.util.concurrent.ExecutorService; |
||||
|
import java.util.concurrent.Executors; |
||||
|
import java.util.concurrent.Future; |
||||
|
import java.util.concurrent.TimeUnit; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
import static org.mockito.Mockito.mock; |
||||
|
import static org.mockito.Mockito.times; |
||||
|
import static org.mockito.Mockito.verify; |
||||
|
|
||||
|
@Testcontainers(disabledWithoutDocker = false) |
||||
|
class SmsSendRateLimitRedisIntegrationTest { |
||||
|
private static final String PHONE = "19900009684"; |
||||
|
private static final String RATE_KEY = "sms:rate:" + PHONE; |
||||
|
|
||||
|
@Container |
||||
|
static final GenericContainer<?> REDIS = new GenericContainer<>(DockerImageName.parse("redis:7-alpine")) |
||||
|
.withExposedPorts(6379); |
||||
|
|
||||
|
private LettuceConnectionFactory connectionFactory; |
||||
|
private RedisTemplate<String, Object> redis; |
||||
|
|
||||
|
@BeforeEach |
||||
|
void setUp() { |
||||
|
connectionFactory = new LettuceConnectionFactory(REDIS.getHost(), REDIS.getMappedPort(6379)); |
||||
|
connectionFactory.afterPropertiesSet(); |
||||
|
connectionFactory.start(); |
||||
|
redis = new RedisTemplate<>(); |
||||
|
redis.setConnectionFactory(connectionFactory); |
||||
|
redis.setKeySerializer(new StringRedisSerializer()); |
||||
|
redis.setValueSerializer(new GenericJackson2JsonRedisSerializer()); |
||||
|
redis.afterPropertiesSet(); |
||||
|
} |
||||
|
|
||||
|
@AfterEach |
||||
|
void cleanUp() { |
||||
|
redis.delete(RATE_KEY); |
||||
|
connectionFactory.destroy(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void concurrentSendRequestsReserveExactlyOneSixtySecondSlot() throws Exception { |
||||
|
SmsVerificationCodeRepository codes = mock(SmsVerificationCodeRepository.class); |
||||
|
SmsCodeSender sender = mock(SmsCodeSender.class); |
||||
|
SmsVerificationService service = new SmsVerificationService(codes, redis, |
||||
|
mock(AuditService.class), sender, mock(SmsFailedAttemptRecorder.class)); |
||||
|
ReflectionTestUtils.setField(service, "codeLength", 6); |
||||
|
ReflectionTestUtils.setField(service, "expireSeconds", 300); |
||||
|
ReflectionTestUtils.setField(service, "maxAttempts", 5); |
||||
|
|
||||
|
int requests = 12; |
||||
|
ExecutorService pool = Executors.newFixedThreadPool(requests); |
||||
|
try { |
||||
|
CountDownLatch ready = new CountDownLatch(requests); |
||||
|
CountDownLatch start = new CountDownLatch(1); |
||||
|
List<Future<Integer>> futures = new ArrayList<>(); |
||||
|
for (int i = 0; i < requests; i++) { |
||||
|
futures.add(pool.submit(() -> { |
||||
|
ready.countDown(); |
||||
|
assertThat(start.await(10, TimeUnit.SECONDS)).isTrue(); |
||||
|
try { |
||||
|
service.sendCode(PHONE, "login", null, null, null); |
||||
|
return 0; |
||||
|
} catch (BusinessException error) { |
||||
|
return error.getErrorCodeInt(); |
||||
|
} |
||||
|
})); |
||||
|
} |
||||
|
assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue(); |
||||
|
start.countDown(); |
||||
|
List<Integer> results = new ArrayList<>(); |
||||
|
for (Future<Integer> future : futures) { |
||||
|
results.add(future.get(30, TimeUnit.SECONDS)); |
||||
|
} |
||||
|
assertThat(results.stream().filter(code -> code == 0).count()).isEqualTo(1); |
||||
|
assertThat(results.stream().filter(code -> code == ErrorCode.SMS_RATE_LIMITED.getCode()).count()) |
||||
|
.isEqualTo(requests - 1); |
||||
|
} finally { |
||||
|
pool.shutdownNow(); |
||||
|
} |
||||
|
|
||||
|
verify(codes, times(1)).save(org.mockito.ArgumentMatchers.any(SmsVerificationCode.class)); |
||||
|
verify(sender, times(1)).sendVerificationCode(org.mockito.ArgumentMatchers.eq(PHONE), |
||||
|
org.mockito.ArgumentMatchers.eq("login"), org.mockito.ArgumentMatchers.anyString()); |
||||
|
assertThat(redis.getExpire(RATE_KEY, TimeUnit.SECONDS)).isBetween(1L, 60L); |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue