Skip to content

Commit a0ac7fb

Browse files
authored
feat: preRegister reCAPTCHA v3 추가
1 parent 07b6bf2 commit a0ac7fb

17 files changed

Lines changed: 905 additions & 31 deletions

File tree

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import java.util.List;
44

55
import org.springframework.web.bind.annotation.PathVariable;
6+
import org.springframework.web.bind.annotation.RequestHeader;
67

78
import com.back.api.preregister.dto.response.PreRegisterResponse;
89
import com.back.global.config.swagger.ApiErrorCode;
@@ -40,7 +41,7 @@ public interface PreRegisterApi {
4041

4142
@Operation(
4243
summary = "사전등록",
43-
description = "이벤트에 사전등록합니다. (인증 제외)",
44+
description = "이벤트에 사전등록합니다. (인증 제외). reCAPTCHA v3 토큰을 헤더(X-Recaptcha-Token)로 전달해야 합니다.",
4445
security = @SecurityRequirement(name = "bearerAuth")
4546
)
4647
@ApiErrorCode({
@@ -51,11 +52,16 @@ public interface PreRegisterApi {
5152
"INVALID_USER_INFO",
5253
"TERMS_NOT_AGREED",
5354
"PRIVACY_NOT_AGREED",
55+
"RECAPTCHA_TOKEN_MISSING",
56+
"RECAPTCHA_VERIFICATION_FAILED",
57+
"RECAPTCHA_SCORE_TOO_LOW",
5458
"UNAUTHORIZED"
5559
})
5660
ApiResponse<PreRegisterResponse> register(
5761
@Parameter(description = "이벤트 ID", example = "1")
58-
@PathVariable Long eventId
62+
@PathVariable Long eventId,
63+
@Parameter(description = "reCAPTCHA v3 토큰", example = "03AGdBq24...")
64+
@RequestHeader(value = "X-Recaptcha-Token", required = false) String recaptchaToken
5965
);
6066

6167
@Operation(

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@
66
import org.springframework.web.bind.annotation.GetMapping;
77
import org.springframework.web.bind.annotation.PathVariable;
88
import org.springframework.web.bind.annotation.PostMapping;
9+
import org.springframework.web.bind.annotation.RequestHeader;
910
import org.springframework.web.bind.annotation.RequestMapping;
1011
import org.springframework.web.bind.annotation.RestController;
1112

1213
import com.back.api.preregister.dto.response.PreRegisterResponse;
1314
import com.back.api.preregister.service.PreRegisterService;
1415
import com.back.global.http.HttpRequestContext;
16+
import com.back.global.recaptcha.service.ReCaptchaService;
1517
import com.back.global.response.ApiResponse;
1618

1719
import lombok.RequiredArgsConstructor;
@@ -23,12 +25,15 @@ public class PreRegisterController implements PreRegisterApi {
2325

2426
private final PreRegisterService preRegisterService;
2527
private final HttpRequestContext httpRequestContext;
28+
private final ReCaptchaService reCaptchaService;
2629

2730
// 인증 있는 사전 등록 -> v2에서 사용 예정
2831
// @Override
2932
// @PostMapping
3033
// public ApiResponse<PreRegisterResponse> register(
3134
// @PathVariable Long eventId,
35+
// @RequestHeader(value = "X-Recaptcha-Token", required = false) String recaptchaToken) {
36+
// reCaptchaService.verifyToken(recaptchaToken, null);
3237
// @Valid @RequestBody PreRegisterCreateRequest request) {
3338
// Long userId = httpRequestContext.getUserId();
3439
// PreRegisterResponse response = preRegisterService.register(eventId, userId, request);
@@ -37,7 +42,10 @@ public class PreRegisterController implements PreRegisterApi {
3742

3843
@Override
3944
@PostMapping("/events/{eventId}/pre-registers")
40-
public ApiResponse<PreRegisterResponse> register(@PathVariable Long eventId) {
45+
public ApiResponse<PreRegisterResponse> register(
46+
@PathVariable Long eventId,
47+
@RequestHeader(value = "X-Recaptcha-Token", required = false) String recaptchaToken) {
48+
reCaptchaService.verifyToken(recaptchaToken, null);
4149
Long userId = httpRequestContext.getUserId();
4250
PreRegisterResponse response = preRegisterService.quickPreRegister(eventId, userId);
4351
return ApiResponse.created("사전등록이 완료되었습니다.", response);

backend/src/main/java/com/back/global/error/code/CommonErrorCode.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ public enum CommonErrorCode implements ErrorCode {
2020
// ===== HTTP METHOD 오류 =====
2121
METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "지원하지 않는 HTTP 메서드입니다."),
2222

23+
// ===== reCAPTCHA 관련 =====
24+
RECAPTCHA_VERIFICATION_FAILED(HttpStatus.BAD_REQUEST, "reCAPTCHA 검증에 실패했습니다."),
25+
RECAPTCHA_SCORE_TOO_LOW(HttpStatus.BAD_REQUEST, "봇으로 의심되는 활동이 감지되었습니다."),
26+
RECAPTCHA_TOKEN_MISSING(HttpStatus.BAD_REQUEST, "reCAPTCHA 토큰이 누락되었습니다."),
27+
2328
// ===== 서버 내부 오류 =====
2429
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다.");
2530

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package com.back.global.recaptcha.config;
2+
3+
import org.springframework.context.annotation.Bean;
4+
import org.springframework.context.annotation.Configuration;
5+
import org.springframework.web.client.RestTemplate;
6+
7+
@Configuration
8+
public class ReCaptchaConfig {
9+
10+
@Bean
11+
public RestTemplate restTemplate() {
12+
return new RestTemplate();
13+
}
14+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package com.back.global.recaptcha.config;
2+
3+
import org.springframework.boot.context.properties.ConfigurationProperties;
4+
import org.springframework.stereotype.Component;
5+
6+
import lombok.Getter;
7+
import lombok.Setter;
8+
9+
@Getter
10+
@Setter
11+
@Component
12+
@ConfigurationProperties(prefix = "google.captcha")
13+
public class ReCaptchaProperties {
14+
15+
private String secretKey;
16+
private static final String VERIFY_URL = "https://www.google.com/recaptcha/api/siteverify";
17+
18+
public String getVerifyUrl() {
19+
return VERIFY_URL;
20+
}
21+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.back.global.recaptcha.dto;
2+
3+
import com.fasterxml.jackson.annotation.JsonProperty;
4+
5+
import lombok.AllArgsConstructor;
6+
import lombok.Getter;
7+
8+
@Getter
9+
@AllArgsConstructor
10+
public class ReCaptchaRequest {
11+
12+
@JsonProperty("secret")
13+
private String secret;
14+
15+
@JsonProperty("response")
16+
private String response;
17+
18+
@JsonProperty("remoteip")
19+
private String remoteIp;
20+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package com.back.global.recaptcha.dto;
2+
3+
import java.time.LocalDateTime;
4+
import java.util.List;
5+
6+
import com.fasterxml.jackson.annotation.JsonProperty;
7+
8+
import lombok.Getter;
9+
import lombok.NoArgsConstructor;
10+
import lombok.Setter;
11+
12+
@Getter
13+
@Setter
14+
@NoArgsConstructor
15+
public class ReCaptchaResponse {
16+
17+
@JsonProperty("success")
18+
private boolean success;
19+
20+
@JsonProperty("score")
21+
private Double score;
22+
23+
@JsonProperty("action")
24+
private String action;
25+
26+
@JsonProperty("challenge_ts")
27+
private LocalDateTime challengeTs;
28+
29+
@JsonProperty("hostname")
30+
private String hostname;
31+
32+
@JsonProperty("error-codes")
33+
private List<String> errorCodes;
34+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package com.back.global.recaptcha.service;
2+
3+
import org.springframework.http.HttpEntity;
4+
import org.springframework.http.HttpHeaders;
5+
import org.springframework.http.MediaType;
6+
import org.springframework.stereotype.Service;
7+
import org.springframework.util.LinkedMultiValueMap;
8+
import org.springframework.util.MultiValueMap;
9+
import org.springframework.web.client.RestClientException;
10+
import org.springframework.web.client.RestTemplate;
11+
12+
import com.back.global.error.code.CommonErrorCode;
13+
import com.back.global.error.exception.ErrorException;
14+
import com.back.global.recaptcha.config.ReCaptchaProperties;
15+
import com.back.global.recaptcha.dto.ReCaptchaResponse;
16+
17+
import lombok.RequiredArgsConstructor;
18+
import lombok.extern.slf4j.Slf4j;
19+
20+
@Slf4j
21+
@Service
22+
@RequiredArgsConstructor
23+
public class ReCaptchaService {
24+
25+
private final ReCaptchaProperties reCaptchaProperties;
26+
private final RestTemplate restTemplate;
27+
28+
private static final double MINIMUM_SCORE = 0.5;
29+
30+
/**
31+
* reCAPTCHA v3 토큰 검증
32+
*
33+
* @param token reCAPTCHA 토큰
34+
* @param remoteIp 클라이언트 IP
35+
*/
36+
public void verifyToken(String token, String remoteIp) {
37+
if (token == null || token.isBlank()) {
38+
log.warn("reCAPTCHA 토큰이 누락되었습니다.");
39+
throw new ErrorException(CommonErrorCode.RECAPTCHA_TOKEN_MISSING);
40+
}
41+
42+
ReCaptchaResponse response = sendVerificationRequest(token, remoteIp);
43+
44+
validateResponse(response);
45+
}
46+
47+
/**
48+
* Google reCAPTCHA API로 검증 요청 전송
49+
*/
50+
private ReCaptchaResponse sendVerificationRequest(String token, String remoteIp) {
51+
try {
52+
HttpHeaders headers = new HttpHeaders();
53+
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
54+
55+
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
56+
params.add("secret", reCaptchaProperties.getSecretKey());
57+
params.add("response", token);
58+
if (remoteIp != null && !remoteIp.isBlank()) {
59+
params.add("remoteip", remoteIp);
60+
}
61+
62+
HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(params, headers);
63+
64+
ReCaptchaResponse response = restTemplate.postForObject(
65+
reCaptchaProperties.getVerifyUrl(),
66+
request,
67+
ReCaptchaResponse.class
68+
);
69+
70+
log.debug("reCAPTCHA 검증 응답: success={}, score={}, action={}, errorCodes={}",
71+
response != null ? response.isSuccess() : null,
72+
response != null ? response.getScore() : null,
73+
response != null ? response.getAction() : null,
74+
response != null ? response.getErrorCodes() : null
75+
);
76+
77+
return response;
78+
79+
} catch (RestClientException e) {
80+
log.error("reCAPTCHA API 호출 중 오류 발생", e);
81+
throw new ErrorException(CommonErrorCode.RECAPTCHA_VERIFICATION_FAILED);
82+
}
83+
}
84+
85+
/**
86+
* reCAPTCHA 응답 검증
87+
*/
88+
private void validateResponse(ReCaptchaResponse response) {
89+
if (response == null) {
90+
log.error("reCAPTCHA 응답이 null입니다.");
91+
throw new ErrorException(CommonErrorCode.RECAPTCHA_VERIFICATION_FAILED);
92+
}
93+
94+
if (!response.isSuccess()) {
95+
log.warn("reCAPTCHA 검증 실패: {}", response.getErrorCodes());
96+
throw new ErrorException(CommonErrorCode.RECAPTCHA_VERIFICATION_FAILED);
97+
}
98+
99+
if (response.getScore() == null || response.getScore() < MINIMUM_SCORE) {
100+
log.warn("reCAPTCHA 점수가 너무 낮습니다: {} (최소: {})", response.getScore(), MINIMUM_SCORE);
101+
throw new ErrorException(CommonErrorCode.RECAPTCHA_SCORE_TOO_LOW);
102+
}
103+
104+
log.info("reCAPTCHA 검증 성공: score={}, action={}", response.getScore(), response.getAction());
105+
}
106+
}

backend/src/main/resources/application-dev.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,9 @@ cookie:
1919
sms:
2020
test:
2121
enabled: true # 고정 인증번호 활성화
22-
fixed-code: "123456" # HTTP 파일에서 123456 사용
22+
fixed-code: "123456" # HTTP 파일에서 123456 사용
23+
24+
# reCAPTCHA
25+
google:
26+
captcha:
27+
secret-key: ${RECAPTCHA_SECRET_KEY}

backend/src/main/resources/application-prod.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,4 +115,9 @@ cloud:
115115
bucket: ${S3_BUCKET_NAME}
116116
presigned:
117117
put-expire-minutes: 5
118-
get-expire-minutes: 10
118+
get-expire-minutes: 10
119+
120+
# reCAPTCHA
121+
google:
122+
captcha:
123+
secret-key: ${RECAPTCHA_SECRET_KEY}

0 commit comments

Comments
 (0)