Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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; // 비활성 기준 만료
Expand All @@ -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) {
Expand All @@ -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);
Expand All @@ -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());
Expand All @@ -151,7 +172,8 @@ public AuthResDTO.SmsVerifyResponse verifySms(AuthReqDTO.SmsVerifyRequest reques
/**
* Verification Token 검증 (범용)
* SMS 인증 완료 후 발급된 토큰이 유효한지 검증합니다.
* 다른 도메인(회원가입, 비밀번호 찾기, 휴대폰 번호 변경 등)에서 재사용 가능합니다.
* 토큰은 소멸되지 않으며, TTL(10분) 동안 회원가입·로그인·비밀번호 재설정 등
* 여러 동작에서 반복 재사용 가능합니다. (매번 SMS 재인증을 요구하지 않기 위한 의도된 설계)
*
* @param verificationToken SMS 인증 완료 후 발급받은 토큰
* @param phoneNumber 검증할 휴대폰 번호
Expand Down Expand Up @@ -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());

// 사용자 가져오기
Expand Down
21 changes: 21 additions & 0 deletions src/main/java/com/example/scoi/global/config/RedisConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -19,4 +21,23 @@ public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory redisC
redisTemplate.setHashValueSerializer(new StringRedisSerializer());
return redisTemplate;
}

/**
* INCR과 TTL 설정을 한 번의 왕복으로 묶는 스크립트.
* 최초 생성(값이 1)일 때만 만료를 걸어 카운터 수명을 첫 증가 시점에 고정한다.
* ARGV[1]은 밀리초 단위 TTL.
*/
@Bean
public RedisScript<Long> incrementWithTtlScript() {
DefaultRedisScript<Long> 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;
}
}
21 changes: 21 additions & 0 deletions src/main/java/com/example/scoi/global/redis/RedisUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -19,6 +21,7 @@
public class RedisUtil {

private final RedisTemplate<String, String> redisTemplate;
private final RedisScript<Long> incrementWithTtlScript;

/**
* Redis에 데이터 저장 (TTL 포함)
Expand Down Expand Up @@ -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;
}
Comment on lines +65 to +75

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# RedisTemplate<String,String> 빈 및 value serializer 설정 확인
rg -nP -C3 'RedisTemplate<\s*String\s*,\s*String\s*>|setValueSerializer|StringRedisSerializer|GenericJackson2JsonRedisSerializer' --type java

Repository: UMCSCOI/Backend

Length of output: 153


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== Redis-related Java files ==\n'
git ls-files 'src/main/java/**/*.java' | rg 'Redis|redis' || true

printf '\n== RedisUtil outline ==\n'
fd -a 'RedisUtil.java' src/main/java || true

printf '\n== Redis config/search ==\n'
rg -n -C 3 'RedisTemplate|RedisConnectionFactory|StringRedisSerializer|GenericJackson2JsonRedisSerializer|opsForValue\(\)\.increment|expire\(' src/main/java || true

Repository: UMCSCOI/Backend

Length of output: 6670


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== RedisUtil.java relevant lines ==\n'
sed -n '1,120p' src/main/java/com/example/scoi/global/redis/RedisUtil.java | cat -n

printf '\n== increment() call sites ==\n'
rg -n -C 2 '\.increment\(' src/main/java || true

Repository: UMCSCOI/Backend

Length of output: 4603


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== AuthService increment usage ==\n'
sed -n '110,160p' src/main/java/com/example/scoi/domain/auth/service/AuthService.java | cat -n

Repository: UMCSCOI/Backend

Length of output: 2794


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== SMS prefixes and send flow ==\n'
rg -n -C 3 'SMS_VERIFY_FAIL_PREFIX|cooldownKey|failKey|verificationCode|SMS_EXPIRATION_MINUTES|sendSms\(' src/main/java/com/example/scoi/domain/auth/service/AuthService.java || true

Repository: UMCSCOI/Backend

Length of output: 4116


INCR 뒤 TTL을 원자적으로 보장하세요. StringRedisSerializer가 사용되어 직렬화 문제는 없지만, count == 1L일 때만 expire를 별도 호출하면 중간 실패 시 실패 카운터가 TTL 없이 남을 수 있습니다. expire 실패를 처리하거나 Lua/트랜잭션으로 묶어 주세요.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/example/scoi/global/redis/RedisUtil.java` around lines 61 -
70, The Redis increment logic in RedisUtil.increment currently sets the TTL with
a separate expire call after INCR, so a failure between those steps can leave
the counter without expiration. Update increment to make the
increment-and-expire flow atomic, either by wrapping the operations in a Lua
script or a transaction, or by explicitly handling expire failures and
retrying/raising as appropriate. Keep the fix centered on RedisUtil.increment
and preserve the existing validateInput and logging behavior.


/**
* Redis에서 데이터 삭제
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Loading
Loading