Skip to content

Commit c8fd347

Browse files
authored
refactor: preRegister 봇 차단 및 보안 적용
1 parent 186a4cc commit c8fd347

36 files changed

Lines changed: 5521 additions & 108 deletions

backend/build.gradle.kts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,13 @@ dependencies {
9393

9494
// resilience4j
9595
implementation("org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j")
96+
97+
// Bucket4j (Rate Limiting)
98+
implementation("com.bucket4j:bucket4j-core:8.10.1")
99+
implementation("com.bucket4j:bucket4j-redis:8.10.1")
100+
101+
// Apache Commons Net (IP 대역 매칭)
102+
implementation("commons-net:commons-net:3.11.1")
96103
}
97104

98105
tasks.withType<Test> {

backend/src/main/java/com/back/BackendApplication.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
import org.springframework.boot.SpringApplication;
44
import org.springframework.boot.autoconfigure.SpringBootApplication;
5+
import org.springframework.scheduling.annotation.EnableScheduling;
56

7+
@EnableScheduling
68
@SpringBootApplication
79
public class BackendApplication {
810

backend/src/main/java/com/back/api/preregister/controller/PreRegisterApi.java

Lines changed: 7 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -20,31 +20,11 @@
2020
@Tag(name = "PreRegister API", description = "사전등록 API")
2121
public interface PreRegisterApi {
2222

23-
// 인증 있는 사전 등록 -> v2에서 사용 예정
24-
// @Operation(
25-
// summary = "사전등록",
26-
// description = "이벤트에 사전등록합니다. 휴대폰 번호, 생년월일을 통한 본인 인증이 필요하며, 약관 동의가 필수입니다. 이름은 JWT 토큰으로 인증된 사용자 정보에서 자동으로 가져옵니다.",
27-
// security = @SecurityRequirement(name = "bearerAuth")
28-
// )
29-
// @ApiErrorCode({
30-
// "NOT_FOUND_EVENT",
31-
// "NOT_FOUND_USER",
32-
// "ALREADY_PRE_REGISTERED",
33-
// "INVALID_PRE_REGISTRATION_PERIOD",
34-
// "INVALID_USER_INFO",
35-
// "TERMS_NOT_AGREED",
36-
// "PRIVACY_NOT_AGREED",
37-
// "UNAUTHORIZED"
38-
// })
39-
// ApiResponse<PreRegisterResponse> register(
40-
// @Parameter(description = "이벤트 ID", example = "1")
41-
// @PathVariable Long eventId,
42-
// @Valid @RequestBody PreRegisterCreateRequest request
43-
// );
44-
4523
@Operation(
4624
summary = "사전등록",
47-
description = "이벤트에 사전등록합니다. (인증 제외). reCAPTCHA v3 토큰을 헤더(X-Recaptcha-Token)로 전달해야 합니다.",
25+
description = "이벤트 사전등록 API입니다. 휴대폰 번호와 생년월일을 통한 본인 인증 및 "
26+
+ "약관 동의가 필요합니다. reCAPTCHA v3 토큰은 "
27+
+ "헤더(X-Recaptcha-Token)로 전달해야 합니다.",
4828
security = @SecurityRequirement(name = "bearerAuth")
4929
)
5030
@ApiErrorCode({
@@ -65,7 +45,10 @@ ApiResponse<PreRegisterResponse> register(
6545
@PathVariable Long eventId,
6646
@Parameter(description = "reCAPTCHA v3 토큰", example = "03AGdBq24...")
6747
@RequestHeader(value = "X-Recaptcha-Token", required = false) String recaptchaToken,
68-
@Valid @RequestBody PreRegisterCreateRequest request
48+
@Parameter(description = "디바이스 ID (Fingerprint)", example = "abcdef1234567890")
49+
@RequestHeader(value = "X-Device-Id", required = false) String deviceId,
50+
@Valid @RequestBody PreRegisterCreateRequest request,
51+
jakarta.servlet.http.HttpServletRequest httpRequest
6952
);
7053

7154
@Operation(

backend/src/main/java/com/back/api/preregister/controller/PreRegisterController.java

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,19 @@ public class PreRegisterController implements PreRegisterApi {
3535
public ApiResponse<PreRegisterResponse> register(
3636
@PathVariable Long eventId,
3737
@RequestHeader(value = "X-Recaptcha-Token", required = false) String recaptchaToken,
38-
@Valid @RequestBody PreRegisterCreateRequest request) {
38+
@RequestHeader(value = "X-Device-Id", required = false) String deviceId,
39+
@Valid @RequestBody PreRegisterCreateRequest request,
40+
jakarta.servlet.http.HttpServletRequest httpRequest) {
3941
reCaptchaService.verifyToken(recaptchaToken, null);
4042
Long userId = httpRequestContext.getUserId();
41-
PreRegisterResponse response = preRegisterService.register(eventId, userId, request);
43+
44+
// Fingerprint 기록을 위해 visitorId 전달
45+
String visitorId = (String)httpRequest.getAttribute("VISITOR_ID");
46+
if (visitorId == null) {
47+
visitorId = deviceId;
48+
}
49+
50+
PreRegisterResponse response = preRegisterService.register(eventId, userId, request, visitorId);
4251
return ApiResponse.created("사전등록이 완료되었습니다.", response);
4352
}
4453

backend/src/main/java/com/back/api/preregister/service/PreRegisterService.java

Lines changed: 88 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import java.util.List;
55
import java.util.Optional;
66

7+
import org.springframework.beans.factory.annotation.Autowired;
78
import org.springframework.context.ApplicationEventPublisher;
89
import org.springframework.data.redis.core.StringRedisTemplate;
910
import org.springframework.stereotype.Service;
@@ -24,13 +25,12 @@
2425
import com.back.global.error.code.EventErrorCode;
2526
import com.back.global.error.code.PreRegisterErrorCode;
2627
import com.back.global.error.exception.ErrorException;
28+
import com.back.global.security.service.FingerprintService;
2729

28-
import lombok.RequiredArgsConstructor;
2930
import lombok.extern.slf4j.Slf4j;
3031

3132
@Slf4j
3233
@Service
33-
@RequiredArgsConstructor
3434
@Transactional(readOnly = true)
3535
public class PreRegisterService {
3636

@@ -40,68 +40,113 @@ public class PreRegisterService {
4040
private final ApplicationEventPublisher eventPublisher;
4141
private final StringRedisTemplate redisTemplate;
4242
private final S3PresignedService s3PresignedService;
43+
private FingerprintService fingerprintService;
44+
45+
public PreRegisterService(
46+
PreRegisterRepository preRegisterRepository,
47+
EventRepository eventRepository,
48+
UserRepository userRepository,
49+
ApplicationEventPublisher eventPublisher,
50+
StringRedisTemplate redisTemplate,
51+
S3PresignedService s3PresignedService) {
52+
this.preRegisterRepository = preRegisterRepository;
53+
this.eventRepository = eventRepository;
54+
this.userRepository = userRepository;
55+
this.eventPublisher = eventPublisher;
56+
this.redisTemplate = redisTemplate;
57+
this.s3PresignedService = s3PresignedService;
58+
}
59+
60+
@Autowired(required = false)
61+
public void setFingerprintService(FingerprintService fingerprintService) {
62+
this.fingerprintService = fingerprintService;
63+
}
4364

4465
private static final String SMS_VERIFIED_PREFIX = "SMS_VERIFIED:";
4566

4667
@Transactional
47-
public PreRegisterResponse register(Long eventId, Long userId, PreRegisterCreateRequest request) {
68+
public PreRegisterResponse register(Long eventId, Long userId, PreRegisterCreateRequest request, String visitorId) {
4869
Event event = findEventById(eventId);
4970
User user = findUserById(userId);
5071

51-
// 사전등록 기간 검증
52-
validatePreRegistrationPeriod(event);
72+
try {
73+
// 사전등록 기간 검증
74+
validatePreRegistrationPeriod(event);
5375

54-
// SMS 인증 완료 여부 검증 (플래그 삭제하지 않고 검증만)
55-
validateSmsVerificationWithoutDelete(request.phoneNumber());
76+
// SMS 인증 완료 여부 검증 (플래그 삭제하지 않고 검증만)
77+
validateSmsVerificationWithoutDelete(request.phoneNumber());
5678

57-
// 본인 인증 정보 검증 (회원가입 정보와 대조)
58-
validateUserInfo(user, request);
79+
// 본인 인증 정보 검증 (회원가입 정보와 대조)
80+
validateUserInfo(user, request);
5981

60-
// 약관 동의 검증
61-
validateAgreements(request);
82+
// 약관 동의 검증
83+
validateAgreements(request);
6284

63-
// 기존 사전등록 확인 (CANCELED 상태면 재활용)
64-
Optional<PreRegister> existingPreRegister = preRegisterRepository.findByEvent_IdAndUser_Id(eventId, userId);
85+
// 기존 사전등록 확인 (CANCELED 상태면 재활용)
86+
Optional<PreRegister> existingPreRegister = preRegisterRepository.findByEvent_IdAndUser_Id(eventId, userId);
6587

66-
if (existingPreRegister.isPresent()) {
67-
PreRegister preRegister = existingPreRegister.get();
88+
if (existingPreRegister.isPresent()) {
89+
PreRegister preRegister = existingPreRegister.get();
6890

69-
// REGISTERED 상태면 중복 등록 예외
70-
if (preRegister.isRegistered()) {
71-
throw new ErrorException(PreRegisterErrorCode.ALREADY_PRE_REGISTERED);
72-
}
91+
// REGISTERED 상태면 중복 등록 예외
92+
if (preRegister.isRegistered()) {
93+
throw new ErrorException(PreRegisterErrorCode.ALREADY_PRE_REGISTERED);
94+
}
7395

74-
// CANCELED 상태면 재등록 (상태만 변경)
75-
preRegister.reRegister();
96+
// CANCELED 상태면 재등록 (상태만 변경)
97+
preRegister.reRegister();
7698

77-
// 모든 검증 통과 후 SMS 인증 플래그 삭제
78-
deleteSmsVerificationFlag(request.phoneNumber());
99+
// 모든 검증 통과 후 SMS 인증 플래그 삭제
100+
deleteSmsVerificationFlag(request.phoneNumber());
79101

80-
return PreRegisterResponse.from(preRegister);
81-
}
102+
// Fingerprint 성공 기록
103+
if (fingerprintService != null && visitorId != null) {
104+
fingerprintService.recordAttempt(visitorId, true);
105+
}
82106

83-
// 새로운 사전등록 생성
84-
PreRegister preRegister = PreRegister.builder()
85-
.event(event)
86-
.user(user)
87-
.preRegisterAgreeTerms(request.agreeTerms())
88-
.preRegisterAgreePrivacy(request.agreePrivacy())
89-
.build();
107+
return PreRegisterResponse.from(preRegister);
108+
}
90109

91-
PreRegister savedPreRegister = preRegisterRepository.save(preRegister);
110+
// 새로운 사전등록 생성
111+
PreRegister preRegister = PreRegister.builder()
112+
.event(event)
113+
.user(user)
114+
.preRegisterAgreeTerms(request.agreeTerms())
115+
.preRegisterAgreePrivacy(request.agreePrivacy())
116+
.build();
92117

93-
// 모든 검증 통과 후 SMS 인증 플래그 삭제
94-
deleteSmsVerificationFlag(request.phoneNumber());
118+
PreRegister savedPreRegister = preRegisterRepository.save(preRegister);
95119

96-
eventPublisher.publishEvent(
97-
new PreRegisterDoneMessage(
98-
userId,
99-
savedPreRegister.getId(),
100-
event.getTitle()
101-
)
102-
);
120+
// 모든 검증 통과 후 SMS 인증 플래그 삭제
121+
deleteSmsVerificationFlag(request.phoneNumber());
122+
123+
eventPublisher.publishEvent(
124+
new PreRegisterDoneMessage(
125+
userId,
126+
savedPreRegister.getId(),
127+
event.getTitle()
128+
)
129+
);
130+
131+
// Fingerprint 성공 기록
132+
if (fingerprintService != null && visitorId != null) {
133+
fingerprintService.recordAttempt(visitorId, true);
134+
}
103135

104-
return PreRegisterResponse.from(savedPreRegister);
136+
return PreRegisterResponse.from(savedPreRegister);
137+
} catch (ErrorException e) {
138+
// Fingerprint 실패 기록 (검증 실패)
139+
if (fingerprintService != null && visitorId != null) {
140+
fingerprintService.recordAttempt(visitorId, false);
141+
}
142+
throw e;
143+
} catch (Exception e) {
144+
// Fingerprint 실패 기록 (시스템 에러)
145+
if (fingerprintService != null && visitorId != null) {
146+
fingerprintService.recordAttempt(visitorId, false);
147+
}
148+
throw e;
149+
}
105150
}
106151

107152
@Transactional
@@ -217,4 +262,3 @@ private void deleteSmsVerificationFlag(String phoneNumber) {
217262
redisTemplate.delete(verifiedKey);
218263
}
219264
}
220-
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package com.back.global.config;
2+
3+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
4+
import org.springframework.context.annotation.Bean;
5+
import org.springframework.context.annotation.Configuration;
6+
import org.springframework.context.annotation.Profile;
7+
import org.springframework.data.redis.connection.RedisConnectionFactory;
8+
import org.springframework.data.redis.connection.RedisPassword;
9+
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
10+
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
11+
12+
import io.github.bucket4j.distributed.ExpirationAfterWriteStrategy;
13+
import io.github.bucket4j.redis.lettuce.cas.LettuceBasedProxyManager;
14+
import io.lettuce.core.RedisClient;
15+
import io.lettuce.core.api.StatefulRedisConnection;
16+
import io.lettuce.core.codec.ByteArrayCodec;
17+
import io.lettuce.core.codec.RedisCodec;
18+
import io.lettuce.core.codec.StringCodec;
19+
import jakarta.annotation.PreDestroy;
20+
21+
/**
22+
* Bucket4j Redis 설정
23+
*
24+
* Bucket4j를 Redis와 연동하여 분산 환경에서 Rate Limiting 구현
25+
*/
26+
@Configuration
27+
@Profile("!test")
28+
@ConditionalOnProperty(name = "security.bot-protection.rate-limit.enabled", havingValue = "true", matchIfMissing = false)
29+
public class Bucket4jConfig {
30+
31+
private RedisClient redisClient;
32+
private StatefulRedisConnection<String, byte[]> connection;
33+
34+
/**
35+
* Lettuce 기반 Bucket4j ProxyManager 생성
36+
*
37+
* @param redisConnectionFactory Redis Connection Factory
38+
* @return LettuceBasedProxyManager
39+
*/
40+
@Bean
41+
public LettuceBasedProxyManager<String> lettuceBasedProxyManager(
42+
RedisConnectionFactory redisConnectionFactory
43+
) {
44+
LettuceConnectionFactory lettuceFactory = (LettuceConnectionFactory)redisConnectionFactory;
45+
RedisStandaloneConfiguration standaloneConfig = lettuceFactory.getStandaloneConfiguration();
46+
47+
// Redis URI 생성 (비밀번호 포함)
48+
String redisUri;
49+
RedisPassword password = standaloneConfig.getPassword();
50+
if (password.isPresent()) {
51+
// redis://:{password}@{host}:{port} 형식
52+
redisUri = String.format("redis://:%s@%s:%d",
53+
new String(password.get()),
54+
standaloneConfig.getHostName(),
55+
standaloneConfig.getPort()
56+
);
57+
} else {
58+
// redis://{host}:{port} 형식
59+
redisUri = String.format("redis://%s:%d",
60+
standaloneConfig.getHostName(),
61+
standaloneConfig.getPort()
62+
);
63+
}
64+
65+
// Redis Client 생성
66+
redisClient = RedisClient.create(redisUri);
67+
68+
// Redis Connection 생성
69+
connection = redisClient.connect(
70+
RedisCodec.of(StringCodec.UTF8, ByteArrayCodec.INSTANCE)
71+
);
72+
73+
// ProxyManager 빌더 설정
74+
return LettuceBasedProxyManager.builderFor(connection)
75+
.withExpirationStrategy(
76+
ExpirationAfterWriteStrategy.basedOnTimeForRefillingBucketUpToMax(
77+
java.time.Duration.ofMinutes(10) // Bucket 만료 시간
78+
)
79+
)
80+
.build();
81+
}
82+
83+
/**
84+
* Bean 소멸 시 Redis 리소스 정리
85+
*/
86+
@PreDestroy
87+
public void cleanup() {
88+
if (connection != null) {
89+
connection.close();
90+
}
91+
if (redisClient != null) {
92+
redisClient.shutdown();
93+
}
94+
}
95+
}

0 commit comments

Comments
 (0)