diff --git a/src/main/java/com/example/scoi/domain/auth/exception/code/AuthErrorCode.java b/src/main/java/com/example/scoi/domain/auth/exception/code/AuthErrorCode.java index a8286c4..6b62c13 100644 --- a/src/main/java/com/example/scoi/domain/auth/exception/code/AuthErrorCode.java +++ b/src/main/java/com/example/scoi/domain/auth/exception/code/AuthErrorCode.java @@ -25,6 +25,9 @@ public enum AuthErrorCode implements BaseErrorCode { SMS_COOLDOWN(HttpStatus.TOO_MANY_REQUESTS, "AUTH429_1", "잠시 후 다시 시도해주세요. (1분 후 재요청 가능)"), + VERIFICATION_ATTEMPTS_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, + "AUTH429_2", + "인증 시도 횟수를 초과했습니다. 인증번호를 다시 요청해주세요."), // 회원가입/로그인 관련 ALREADY_REGISTERED_PHONE(HttpStatus.CONFLICT, diff --git a/src/main/java/com/example/scoi/domain/auth/service/AuthService.java b/src/main/java/com/example/scoi/domain/auth/service/AuthService.java index fe1f329..2043ca0 100644 --- a/src/main/java/com/example/scoi/domain/auth/service/AuthService.java +++ b/src/main/java/com/example/scoi/domain/auth/service/AuthService.java @@ -65,9 +65,11 @@ public class AuthService { private static final String BLACKLIST_PREFIX = "blacklist:"; private static final String SMS_COOLDOWN_PREFIX = "sms:cooldown:"; private static final String SMS_REQUIRED_PREFIX = "sms_required:"; + private static final String SMS_VERIFY_FAIL_PREFIX = "sms:verify:fail:"; // 상수 private static final int SMS_CODE_LENGTH = 6; + private static final int MAX_VERIFY_ATTEMPTS = 5; private static final long SMS_EXPIRATION_MINUTES = 5; private static final long VERIFICATION_EXPIRATION_MINUTES = 10; private static final long REFRESH_TOKEN_SLIDING_DAYS = 14; // 비활성 기준 만료 @@ -85,9 +87,10 @@ public AuthResDTO.SmsSendResponse sendSms(AuthReqDTO.SmsSendRequest request) { // 1. 인증번호 생성 (6자리) String verificationCode = String.format("%06d", ThreadLocalRandom.current().nextInt(1000000)); - // 2. Redis 저장 + // 2. Redis 저장 (새 코드에는 새 시도 예산을 부여) String redisKey = SMS_PREFIX + request.phoneNumber(); redisUtil.set(redisKey, verificationCode, SMS_EXPIRATION_MINUTES, TimeUnit.MINUTES); + redisUtil.delete(SMS_VERIFY_FAIL_PREFIX + request.phoneNumber()); // 3. CoolSMS 발송 if (smsEnabled) { @@ -99,7 +102,7 @@ public AuthResDTO.SmsSendResponse sendSms(AuthReqDTO.SmsSendRequest request) { ); CoolSmsDTO.SendRequest smsRequest = new CoolSmsDTO.SendRequest(message); coolSmsClient.sendMessage(smsRequest); - log.info("SMS 발송 성공: phoneNumber={}, code={}", request.phoneNumber(), verificationCode); + log.info("SMS 발송 성공: phoneNumber={}", request.phoneNumber()); } catch (Exception e) { log.error("SMS 발송 실패: {}", e.getMessage()); throw new AuthException(AuthErrorCode.SMS_SEND_FAILED); @@ -120,19 +123,37 @@ public AuthResDTO.SmsSendResponse sendSms(AuthReqDTO.SmsSendRequest request) { public AuthResDTO.SmsVerifyResponse verifySms(AuthReqDTO.SmsVerifyRequest request) { // 1. Redis 조회 String redisKey = SMS_PREFIX + request.phoneNumber(); + String failKey = SMS_VERIFY_FAIL_PREFIX + request.phoneNumber(); String storedCode = redisUtil.get(redisKey); if (storedCode == null) { throw new AuthException(AuthErrorCode.VERIFICATION_CODE_EXPIRED); } - // 2. 검증 + // 2. 검증 (불일치 시 실패 카운터 증가 → 한도 초과 시 코드 무효화) if (!storedCode.equals(request.verificationCode())) { - throw new AuthException(AuthErrorCode.INVALID_VERIFICATION_CODE); + long failCount = redisUtil.increment(failKey, SMS_EXPIRATION_MINUTES, TimeUnit.MINUTES); + + if (failCount >= MAX_VERIFY_ATTEMPTS) { + // 한도 초과: 코드와 카운터 삭제 → 새 코드 재요청 유도 + redisUtil.delete(redisKey); + redisUtil.delete(failKey); + log.warn("SMS 인증 시도 횟수 초과: phoneNumber={}, failCount={}", request.phoneNumber(), failCount); + throw new AuthException(AuthErrorCode.VERIFICATION_ATTEMPTS_EXCEEDED); + } + + long remainingAttempts = MAX_VERIFY_ATTEMPTS - failCount; + log.warn("SMS 인증번호 불일치: phoneNumber={}, failCount={}, remainingAttempts={}", + request.phoneNumber(), failCount, remainingAttempts); + throw new AuthException( + AuthErrorCode.INVALID_VERIFICATION_CODE, + Map.of("remainingAttempts", String.valueOf(remainingAttempts)) + ); } - // 3. SMS 코드 삭제 + // 3. SMS 코드 및 실패 카운터 삭제 (성공 시 초기화) redisUtil.delete(redisKey); + redisUtil.delete(failKey); // 4. Verification Token 발급 String verificationToken = jwtUtil.createVerificationToken(request.phoneNumber()); @@ -151,7 +172,8 @@ public AuthResDTO.SmsVerifyResponse verifySms(AuthReqDTO.SmsVerifyRequest reques /** * Verification Token 검증 (범용) * SMS 인증 완료 후 발급된 토큰이 유효한지 검증합니다. - * 다른 도메인(회원가입, 비밀번호 찾기, 휴대폰 번호 변경 등)에서 재사용 가능합니다. + * 토큰은 소멸되지 않으며, TTL(10분) 동안 회원가입·로그인·비밀번호 재설정 등 + * 여러 동작에서 반복 재사용 가능합니다. (매번 SMS 재인증을 요구하지 않기 위한 의도된 설계) * * @param verificationToken SMS 인증 완료 후 발급받은 토큰 * @param phoneNumber 검증할 휴대폰 번호 @@ -179,7 +201,7 @@ public String validateVerificationToken(String verificationToken, String phoneNu public Void resetPassword( AuthReqDTO.ResetPassword dto ) { - // Verification Token 검증 및 소멸 (SMS 인증 완료 확인) + // Verification Token 검증 (SMS 인증 완료 확인 / 토큰은 TTL 동안 재사용 가능) String phoneNumber = validateVerificationToken(dto.verificationToken(), dto.phoneNumber()); // 사용자 가져오기 diff --git a/src/main/java/com/example/scoi/global/config/RedisConfig.java b/src/main/java/com/example/scoi/global/config/RedisConfig.java index 90d3896..635202a 100644 --- a/src/main/java/com/example/scoi/global/config/RedisConfig.java +++ b/src/main/java/com/example/scoi/global/config/RedisConfig.java @@ -4,6 +4,8 @@ import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.data.redis.core.script.RedisScript; import org.springframework.data.redis.serializer.StringRedisSerializer; @Configuration @@ -19,4 +21,23 @@ public RedisTemplate redisTemplate(RedisConnectionFactory redisC redisTemplate.setHashValueSerializer(new StringRedisSerializer()); return redisTemplate; } + + /** + * INCR과 TTL 설정을 한 번의 왕복으로 묶는 스크립트. + * 최초 생성(값이 1)일 때만 만료를 걸어 카운터 수명을 첫 증가 시점에 고정한다. + * ARGV[1]은 밀리초 단위 TTL. + */ + @Bean + public RedisScript incrementWithTtlScript() { + DefaultRedisScript script = new DefaultRedisScript<>(); + script.setScriptText(""" + local count = redis.call('INCR', KEYS[1]) + if count == 1 then + redis.call('PEXPIRE', KEYS[1], ARGV[1]) + end + return count + """); + script.setResultType(Long.class); + return script; + } } diff --git a/src/main/java/com/example/scoi/global/redis/RedisUtil.java b/src/main/java/com/example/scoi/global/redis/RedisUtil.java index 85b46a2..f70fd8f 100644 --- a/src/main/java/com/example/scoi/global/redis/RedisUtil.java +++ b/src/main/java/com/example/scoi/global/redis/RedisUtil.java @@ -3,9 +3,11 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.script.RedisScript; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; +import java.util.List; import java.util.concurrent.TimeUnit; /** @@ -19,6 +21,7 @@ public class RedisUtil { private final RedisTemplate redisTemplate; + private final RedisScript incrementWithTtlScript; /** * Redis에 데이터 저장 (TTL 포함) @@ -53,6 +56,24 @@ public String get(String key) { return redisTemplate.opsForValue().get(key); } + /** + * Redis 값을 1 증가시킵니다. + * 증가와 TTL 설정이 한 번의 스크립트로 실행되므로, 중간 실패로 TTL 없는 키가 남지 않습니다. + * 최초 생성(값이 1) 시에만 TTL을 설정하여 카운터 수명을 고정합니다. + * @return 증가 후의 값 + */ + public long increment(String key, long timeout, TimeUnit unit) { + validateInput(key); + + Long count = redisTemplate.execute( + incrementWithTtlScript, + List.of(key), + String.valueOf(unit.toMillis(timeout)) + ); + log.debug("Redis 증가: key={}, count={}", key, count); + return count == null ? 0L : count; + } + /** * Redis에서 데이터 삭제 */ diff --git a/src/test/java/com/example/scoi/domain/auth/service/AuthServiceTest.java b/src/test/java/com/example/scoi/domain/auth/service/AuthServiceTest.java new file mode 100644 index 0000000..b796cbc --- /dev/null +++ b/src/test/java/com/example/scoi/domain/auth/service/AuthServiceTest.java @@ -0,0 +1,167 @@ +package com.example.scoi.domain.auth.service; + +import com.example.scoi.domain.auth.dto.AuthReqDTO; +import com.example.scoi.domain.auth.dto.AuthResDTO; +import com.example.scoi.domain.auth.exception.AuthException; +import com.example.scoi.domain.auth.exception.code.AuthErrorCode; +import com.example.scoi.domain.member.repository.MemberRepository; +import com.example.scoi.global.redis.RedisUtil; +import com.example.scoi.global.security.jwt.JwtUtil; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +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.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * SMS 인증 무차별 대입 방어 로직 단위 테스트. + * Spring 컨텍스트 없이 Mockito로 협력 객체를 스텁하여 분기별 동작을 검증한다. + */ +@ExtendWith(MockitoExtension.class) +class AuthServiceTest { + + @Mock + private MemberRepository memberRepository; + @Mock + private JwtUtil jwtUtil; + @Mock + private RedisUtil redisUtil; + + @InjectMocks + private AuthService authService; + + private static final String PHONE = "01012345678"; + private static final String CODE = "123456"; + private static final String SMS_KEY = "sms:" + PHONE; + private static final String FAIL_KEY = "sms:verify:fail:" + PHONE; + private static final String COOLDOWN_KEY = "sms:cooldown:" + PHONE; + + private AuthReqDTO.SmsVerifyRequest request(String verificationCode) { + return new AuthReqDTO.SmsVerifyRequest(PHONE, verificationCode); + } + + @Nested + @DisplayName("verifySms - SMS 인증번호 검증") + class VerifySms { + + @Test + @DisplayName("성공: 코드 일치 시 토큰 발급 및 코드·실패카운터 삭제") + void success() { + // given + when(redisUtil.get(SMS_KEY)).thenReturn(CODE); + when(jwtUtil.createVerificationToken(PHONE)).thenReturn("verification-token"); + when(memberRepository.existsByPhoneNumber(PHONE)).thenReturn(true); + + // when + AuthResDTO.SmsVerifyResponse response = authService.verifySms(request(CODE)); + + // then + assertThat(response.verificationToken()).isEqualTo("verification-token"); + assertThat(response.isExistingMember()).isTrue(); + verify(redisUtil).delete(SMS_KEY); // 코드 삭제 + verify(redisUtil).delete(FAIL_KEY); // 실패 카운터 초기화 + } + + @Test + @DisplayName("실패: 저장된 코드가 없으면 VERIFICATION_CODE_EXPIRED, 실패 카운터 증가 안 함") + void expired() { + // given + when(redisUtil.get(SMS_KEY)).thenReturn(null); + + // when & then + assertThatThrownBy(() -> authService.verifySms(request(CODE))) + .isInstanceOf(AuthException.class) + .extracting(e -> ((AuthException) e).getCode()) + .isEqualTo(AuthErrorCode.VERIFICATION_CODE_EXPIRED); + + verify(redisUtil, never()).increment(any(), anyLong(), any()); + } + + @Test + @DisplayName("실패: 코드 불일치 - 한도 미만이면 INVALID_VERIFICATION_CODE + 남은 횟수 반환") + void mismatchUnderLimit() { + // given: 실패 카운터가 2 (한도 5 미만) + when(redisUtil.get(SMS_KEY)).thenReturn(CODE); + when(redisUtil.increment(eq(FAIL_KEY), eq(5L), eq(TimeUnit.MINUTES))).thenReturn(2L); + + // when & then + assertThatThrownBy(() -> authService.verifySms(request("000000"))) + .isInstanceOf(AuthException.class) + .satisfies(e -> { + AuthException ex = (AuthException) e; + assertThat(ex.getCode()).isEqualTo(AuthErrorCode.INVALID_VERIFICATION_CODE); + assertThat(ex.getBind()).containsEntry("remainingAttempts", "3"); // 5 - 2 + }); + + // 한도 미만이므로 코드·카운터 삭제하지 않음 + verify(redisUtil, never()).delete(SMS_KEY); + verify(redisUtil, never()).delete(FAIL_KEY); + } + + @Test + @DisplayName("실패: 코드 불일치 - 한도 도달 시 VERIFICATION_ATTEMPTS_EXCEEDED + 코드·카운터 삭제") + void mismatchLimitExceeded() { + // given: 실패 카운터가 5 (한도 도달) + when(redisUtil.get(SMS_KEY)).thenReturn(CODE); + when(redisUtil.increment(eq(FAIL_KEY), eq(5L), eq(TimeUnit.MINUTES))).thenReturn(5L); + + // when & then + assertThatThrownBy(() -> authService.verifySms(request("000000"))) + .isInstanceOf(AuthException.class) + .extracting(e -> ((AuthException) e).getCode()) + .isEqualTo(AuthErrorCode.VERIFICATION_ATTEMPTS_EXCEEDED); + + // 코드 무효화: 코드·카운터 모두 삭제 + verify(redisUtil).delete(SMS_KEY); + verify(redisUtil).delete(FAIL_KEY); + } + } + + @Nested + @DisplayName("sendSms - SMS 인증번호 발송") + class SendSms { + + @Test + @DisplayName("새 코드 발급 시 이전 실패 카운터를 초기화한다") + void resetsFailCounter() { + // given: 쿨다운 없음 (smsEnabled 기본값 false → 실제 발송 건너뜀) + when(redisUtil.exists(COOLDOWN_KEY)).thenReturn(false); + + // when + authService.sendSms(new AuthReqDTO.SmsSendRequest(PHONE)); + + // then: 새 코드에는 새 시도 예산이 주어져야 한다 + verify(redisUtil).set(eq(SMS_KEY), any(), eq(5L), eq(TimeUnit.MINUTES)); + verify(redisUtil).delete(FAIL_KEY); + } + + @Test + @DisplayName("쿨다운 중이면 SMS_COOLDOWN, 실패 카운터를 건드리지 않는다") + void cooldown() { + // given + when(redisUtil.exists(COOLDOWN_KEY)).thenReturn(true); + + // when & then + assertThatThrownBy(() -> authService.sendSms(new AuthReqDTO.SmsSendRequest(PHONE))) + .isInstanceOf(AuthException.class) + .extracting(e -> ((AuthException) e).getCode()) + .isEqualTo(AuthErrorCode.SMS_COOLDOWN); + + verify(redisUtil, never()).delete(FAIL_KEY); + } + } +} diff --git a/src/test/java/com/example/scoi/global/redis/RedisUtilIntegrationTest.java b/src/test/java/com/example/scoi/global/redis/RedisUtilIntegrationTest.java new file mode 100644 index 0000000..1d4f13f --- /dev/null +++ b/src/test/java/com/example/scoi/global/redis/RedisUtilIntegrationTest.java @@ -0,0 +1,98 @@ +package com.example.scoi.global.redis; + +import com.example.scoi.global.config.RedisConfig; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIf; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; + +import java.net.InetSocketAddress; +import java.net.Socket; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * {@link RedisUtil#increment} 이 실제 Redis에서 INCR과 TTL 설정을 한 번의 스크립트로 수행하는지 검증한다. + * 단위 테스트는 RedisUtil을 모킹하므로 Lua 스크립트 자체는 여기서만 실증된다. + * Redis가 떠 있지 않으면 테스트 전체를 건너뛴다. + */ +@EnabledIf("redisAvailable") +class RedisUtilIntegrationTest { + + private static final String HOST = "localhost"; + private static final int PORT = 6379; + private static final String KEY = "test:redisutil:increment"; + + private static LettuceConnectionFactory connectionFactory; + private static RedisTemplate redisTemplate; + private static RedisUtil redisUtil; + + static boolean redisAvailable() { + try (Socket socket = new Socket()) { + socket.connect(new InetSocketAddress(HOST, PORT), 500); + return true; + } catch (Exception e) { + return false; + } + } + + @BeforeAll + static void setUp() { + connectionFactory = new LettuceConnectionFactory(HOST, PORT); + connectionFactory.afterPropertiesSet(); + + RedisConfig config = new RedisConfig(); + redisTemplate = config.redisTemplate(connectionFactory); + redisTemplate.afterPropertiesSet(); + + redisUtil = new RedisUtil(redisTemplate, config.incrementWithTtlScript()); + } + + @AfterAll + static void tearDown() { + connectionFactory.destroy(); + } + + @AfterEach + void cleanUp() { + redisTemplate.delete(KEY); + } + + @Test + @DisplayName("최초 증가: 1을 반환하고 TTL이 설정된다") + void firstIncrementSetsTtl() { + long count = redisUtil.increment(KEY, 5, TimeUnit.MINUTES); + + assertThat(count).isEqualTo(1L); + assertThat(redisTemplate.getExpire(KEY, TimeUnit.SECONDS)) + .isPositive() + .isLessThanOrEqualTo(300L); + } + + @Test + @DisplayName("재증가: 값은 늘어나지만 TTL은 초기화되지 않는다 (고정 창)") + void subsequentIncrementDoesNotResetTtl() { + redisUtil.increment(KEY, 5, TimeUnit.MINUTES); + // 첫 증가가 건 TTL을 짧게 덮어써 두면, 재증가가 TTL을 다시 5분으로 늘리는지 확인할 수 있다 + redisTemplate.expire(KEY, 30, TimeUnit.SECONDS); + + long count = redisUtil.increment(KEY, 5, TimeUnit.MINUTES); + + assertThat(count).isEqualTo(2L); + assertThat(redisTemplate.getExpire(KEY, TimeUnit.SECONDS)).isLessThanOrEqualTo(30L); + } + + @Test + @DisplayName("저장된 값은 정수 문자열이라 get()으로 읽을 수 있다") + void valueIsReadableAsString() { + redisUtil.increment(KEY, 5, TimeUnit.MINUTES); + redisUtil.increment(KEY, 5, TimeUnit.MINUTES); + + assertThat(redisUtil.get(KEY)).isEqualTo("2"); + } +}