1. 개요
이 예제에서는 사람과 봇을 구별하기 위해 등록 프로세스에 Google reCAPTCHA 를 추가하여 Spring Security Registration 시리즈를 계속할 것입니다.
2. Google의 reCAPTCHA 통합
Google의 reCAPTCHA 웹 서비스를 통합하려면 먼저 사이트를 서비스에 등록하고 해당 라이브러리를 페이지에 추가한 다음 웹 서비스에서 사용자의 Security 문자 응답을 확인해야 합니다.
https://www.google.com/recaptcha/admin 에서 사이트를 등록해 보겠습니다 . 등록 프로세스 는 웹 서비스에 액세스하기 위한 사이트 키 와 비밀 키를 생성합니다.
2.1. API 키 쌍 저장
application.properties 에 키를 저장합니다 .
google.recaptcha.key.site=6LfaHiITAAAA...
google.recaptcha.key.secret=6LfaHiITAAAA...
그리고 @ConfigurationProperties 로 어노테이션이 달린 빈을 사용하여 Spring에 노출합니다 .
@Component
@ConfigurationProperties(prefix = "google.recaptcha.key")
public class CaptchaSettings {
private String site;
private String secret;
// standard getters and setters
}
2.2. 위젯 표시
시리즈의 사용방법(예제)를 기반 으로 Google 라이브러리를 포함 하도록 registration.html 을 수정합니다.
등록 양식 안에 data-sitekey 속성이 site-key 를 포함 할 것으로 예상하는 reCAPTCHA 위젯을 추가합니다 .
위젯은 제출될 때 요청 매개변수 g-recaptcha-response 를 추가합니다 .
<!DOCTYPE html>
<html>
<head>
...
<script src='https://www.google.com/recaptcha/api.js'></script>
</head>
<body>
...
<form method="POST" enctype="utf8">
...
<div class="g-recaptcha col-sm-5"
th:attr="data-sitekey=${@captchaSettings.getSite()}"></div>
<span id="captchaError" class="alert alert-danger col-sm-4"
style="display:none"></span>
3. 서버 측 검증
새 요청 매개변수는 사이트 키와 사용자가 챌린지를 성공적으로 완료했음을 식별하는 고유 문자열을 인코딩합니다.
그러나 우리는 우리 자신을 식별할 수 없기 때문에 사용자가 제출한 것이 적법하다고 믿을 수 없습니다. 웹 서비스 API로 Security 문자 응답 의 유효성을 검사하기 위해 서버 측 요청이 이루어집니다 .
엔드포인트는 URL https://www.google.com/recaptcha/api/siteverify 에서 쿼리 매개변수 secret , response 및 remoteip 를 사용 하여 HTTP 요청을 수락합니다 . 스키마가 있는 JSON 응답을 반환합니다.
{
"success": true|false,
"challenge_ts": timestamp,
"hostname": string,
"error-codes": [ ... ]
}
3.1. 사용자 응답 검색
reCAPTCHA 챌린지에 대한 사용자의 응답은 HttpServletRequest 를 사용하여 요청 매개변수 g-recaptcha-response 에서 검색되고 CaptchaService 로 검증됩니다 . 응답을 처리하는 동안 예외가 발생하면 나머지 등록 논리가 중단됩니다.
public class RegistrationController {
@Autowired
private ICaptchaService captchaService;
...
@RequestMapping(value = "/user/registration", method = RequestMethod.POST)
@ResponseBody
public GenericResponse registerUserAccount(@Valid UserDto accountDto, HttpServletRequest request) {
String response = request.getParameter("g-recaptcha-response");
captchaService.processResponse(response);
// Rest of implementation
}
...
}
3.2. 검증 서비스
획득한 Security 문자 응답을 먼저 소독해야 합니다. 간단한 정규 표현식이 사용됩니다.
응답이 합법적으로 보이면 secret-key , captcha 응답 및 클라이언트의 IP 주소 를 사용하여 웹 서비스에 요청합니다 .
public class CaptchaService implements ICaptchaService {
@Autowired
private CaptchaSettings captchaSettings;
@Autowired
private RestOperations restTemplate;
private static Pattern RESPONSE_PATTERN = Pattern.compile("[A-Za-z0-9_-]+");
@Override
public void processResponse(String response) {
if(!responseSanityCheck(response)) {
throw new InvalidReCaptchaException("Response contains invalid characters");
}
URI verifyUri = URI.create(String.format(
"https://www.google.com/recaptcha/api/siteverify?secret=%s&response=%s&remoteip=%s",
getReCaptchaSecret(), response, getClientIP()));
GoogleResponse googleResponse = restTemplate.getForObject(verifyUri, GoogleResponse.class);
if(!googleResponse.isSuccess()) {
throw new ReCaptchaInvalidException("reCaptcha was not successfully validated");
}
}
private boolean responseSanityCheck(String response) {
return StringUtils.hasLength(response) && RESPONSE_PATTERN.matcher(response).matches();
}
}
3.3. 검증의 객관화
Jackson 어노테이션 으로 장식된 Java bean 은 유효성 검사 응답을 캡슐화합니다.
@ JsonInclude(JsonInclude.Includef. NcON_NULL)
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonPropertyOrder({
"success",
"challenge_ts",
"hostname",
"error-codes"
})
public class GoogleResponse {
@JsonProperty("success")
private boolean success;
@JsonProperty("challenge_ts")
private String challengeTs;
@JsonProperty("hostname")
private String hostname;
@JsonProperty("error-codes")
private ErrorCode[] errorCodes;
@JsonIgnore
public boolean hasClientError() {
ErrorCode[] errors = getErrorCodes();
if(errors == null) {
return false;
}
for(ErrorCode error : errors) {
switch(error) {
case InvalidResponse:
case MissingResponse:
return true;
}
}
return false;
}
static enum ErrorCode {
MissingSecret, InvalidSecret,
MissingResponse, InvalidResponse;
private static Map<String, ErrorCode> errorsMap = new HashMap<String, ErrorCode>(4);
static {
errorsMap.put("missing-input-secret", MissingSecret);
errorsMap.put("invalid-input-secret", InvalidSecret);
errorsMap.put("missing-input-response", MissingResponse);
errorsMap.put("invalid-input-response", InvalidResponse);
}
@JsonCreator
public static ErrorCode forValue(String value) {
return errorsMap.get(value.toLowerCase());
}
}
// standard getters and setters
}
암시된 바와 같이 success 속성의 진리값은 사용자가 검증되었음을 의미합니다. 그렇지 않으면 errorCodes 속성에 이유가 채워집니다.
호스트 이름 은 사용자를 reCAPTCHA로 리디렉션한 서버를 나타냅니다. 많은 도메인을 관리하고 모든 도메인이 동일한 키 쌍을 공유하도록 하려면 호스트 이름 속성을 직접 확인하도록 선택할 수 있습니다 .
3.4. 검증 실패
유효성 검사에 실패하면 예외가 발생합니다. reCAPTCHA 라이브러리는 클라이언트에게 새 챌린지를 생성하도록 지시해야 합니다.
라이브러리의 grecaptcha 위젯에서 reset을 호출하여 클라이언트의 등록 오류 처리기에서 이를 수행합니다.
register(event){
event.preventDefault();
var formData= $('form').serialize();
$.post(serverContext + "user/registration", formData, function(data){
if(data.message == "success") {
// success handler
}
})
.fail(function(data) {
grecaptcha.reset();
...
if(data.responseJSON.error == "InvalidReCaptcha"){
$("#captchaError").show().html(data.responseJSON.message);
}
...
}
}
4. 서버 리소스 보호
악성 클라이언트는 브라우저 샌드박스의 규칙을 따를 필요가 없습니다. 따라서 우리의 Security 사고방식은 노출된 리소스와 어떻게 남용될 수 있는지에 대한 것이어야 합니다.
4.1. 캐시 시도
reCAPTCHA를 통합하면 모든 요청이 서버에서 요청을 확인하기 위해 소켓을 생성하게 된다는 점을 이해하는 것이 중요합니다.
진정한 DoS 완화를 위해서는 더 계층화된 접근 방식이 필요하지만 클라이언트를 4개의 실패한 Security 문자 응답으로 제한하는 기본 캐시를 구현할 수 있습니다.
public class ReCaptchaAttemptService {
private int MAX_ATTEMPT = 4;
private LoadingCache<String, Integer> attemptsCache;
public ReCaptchaAttemptService() {
super();
attemptsCache = CacheBuilder.newBuilder()
.expireAfterWrite(4, TimeUnit.HOURS).build(new CacheLoader<String, Integer>() {
@Override
public Integer load(String key) {
return 0;
}
});
}
public void reCaptchaSucceeded(String key) {
attemptsCache.invalidate(key);
}
public void reCaptchaFailed(String key) {
int attempts = attemptsCache.getUnchecked(key);
attempts++;
attemptsCache.put(key, attempts);
}
public boolean isBlocked(String key) {
return attemptsCache.getUnchecked(key) >= MAX_ATTEMPT;
}
}
4.2. 검증 서비스 리팩토링
클라이언트가 시도 제한을 초과한 경우 중단하여 캐시가 먼저 통합됩니다. 그렇지 않으면 실패한 GoogleResponse 를 처리할 때 클라이언트 응답에 오류가 포함된 시도를 기록합니다. 성공적인 유효성 검사는 시도 캐시를 지웁니다.
public class CaptchaService implements ICaptchaService {
@Autowired
private ReCaptchaAttemptService reCaptchaAttemptService;
...
@Override
public void processResponse(String response) {
...
if(reCaptchaAttemptService.isBlocked(getClientIP())) {
throw new InvalidReCaptchaException("Client exceeded maximum number of failed attempts");
}
...
GoogleResponse googleResponse = ...
if(!googleResponse.isSuccess()) {
if(googleResponse.hasClientError()) {
reCaptchaAttemptService.reCaptchaFailed(getClientIP());
}
throw new ReCaptchaInvalidException("reCaptcha was not successfully validated");
}
reCaptchaAttemptService.reCaptchaSucceeded(getClientIP());
}
}
5. Google의 reCAPTCHA v3 통합
Google의 reCAPTCHA v3는 사용자 상호 작용이 필요하지 않기 때문에 이전 버전과 다릅니다. 그것은 단순히 우리가 보내는 각 요청에 대한 점수를 제공하고 웹 응용 프로그램에 대해 수행할 최종 작업을 결정할 수 있도록 합니다.
다시 말하지만 Google의 reCAPTCHA 3을 통합하려면 먼저 사이트를 서비스에 등록하고 해당 라이브러리를 페이지에 추가한 다음 웹 서비스에서 토큰 응답을 확인해야 합니다.
따라서 https://www.google.com/recaptcha/admin/create 에서 사이트를 등록하고 reCAPTCHA v3를 선택한 후 새 비밀 및 사이트 키를 얻습니다.
5.1. application.properties 및 CaptchaSettings 업데이트
등록 후 새로운 키와 선택한 점수 임계값으로 application.properties 를 업데이트해야 합니다.
google.recaptcha.key.site=6LefKOAUAAAAAE...
google.recaptcha.key.secret=6LefKOAUAAAA...
google.recaptcha.key.threshold=0.5
0.5 로 설정된 임계값 은 기본값이며 Google 관리 콘솔 에서 실제 임계값을 분석하여 시간이 지남에 따라 조정할 수 있습니다 .
다음으로 CaptchaSettings 클래스 를 업데이트하겠습니다 .
@Component
@ConfigurationProperties(prefix = "google.recaptcha.key")
public class CaptchaSettings {
// ... other properties
private float threshold;
// standard getters and setters
}
5.2. 프런트 엔드 통합
이제 사이트 키와 함께 Google 라이브러리를 포함 하도록 registration.html 을 수정합니다.
등록 양식 내에서 grecaptcha.execute 함수 에 대한 호출에서 받은 응답 토큰을 저장할 숨겨진 필드를 추가합니다 .
<!DOCTYPE html>
<html>
<head>
...
<script th:src='|https://www.google.com/recaptcha/api.js?render=${@captchaService.getReCaptchaSite()}'></script>
</head>
<body>
...
<form method="POST" enctype="utf8">
...
<input type="hidden" id="response" name="response" value="" />
...
</form>
...
<script th:inline="javascript">
...
var siteKey = /*[[${@captchaService.getReCaptchaSite()}]]*/;
grecaptcha.execute(siteKey, {action: /*[[${T(com.baeldung.captcha.CaptchaService).REGISTER_ACTION}]]*/}).then(function(response) {
$('#response').val(response);
var formData= $('form').serialize();
5.3. 서버 측 유효성 검사
웹 서비스 API로 응답 토큰의 유효성을 검사하려면 reCAPTCHA 서버 측 유효성 검사 에 표시된 것과 동일한 서버 측 요청 을 수행해야 합니다.
응답 JSON 객체에는 두 가지 추가 속성이 포함됩니다.
{
...
"score": number,
"action": string
}
점수는 사용자의 상호 작용을 기반으로 하며 0(봇일 가능성이 높음)에서 1.0(인간일 가능성이 높음) 사이의 값입니다.
Action은 동일한 웹 페이지에서 많은 reCAPTCHA 요청을 실행할 수 있도록 Google에서 도입한 새로운 개념입니다.
reCAPTCHA v3를 실행할 때마다 작업을 지정해야 합니다. 그리고 응답 의 action 속성 값이 예상 이름과 일치하는지 확인해야 합니다 .
5.4. 응답 토큰 검색
reCAPTCHA v3 응답 토큰은 HttpServletRequest 를 사용하여 응답 요청 매개변수 에서 검색하고 CaptchaService 로 유효성을 검사 합니다. 메커니즘은 reCAPTCHA에서 위에서 본 것과 동일합니다 .
public class RegistrationController {
@Autowired
private ICaptchaService captchaService;
...
@RequestMapping(value = "/user/registration", method = RequestMethod.POST)
@ResponseBody
public GenericResponse registerUserAccount(@Valid UserDto accountDto, HttpServletRequest request) {
String response = request.getParameter("response");
captchaService.processResponse(response, CaptchaService.REGISTER_ACTION);
// rest of implementation
}
...
}
5.5. v3로 검증 서비스 리팩토링
리팩토링 된 CaptchaService 유효성 검사 서비스 클래스에는 이전 버전의 processResponse 메서드 와 유사한 processResponse 메서드가 포함되어 있지만 GoogleResponse 의 작업 및 점수 매개변수 를 확인하는 데 주의를 기울입니다 .
public class CaptchaService implements ICaptchaService {
public static final String REGISTER_ACTION = "register";
...
@Override
public void processResponse(String response, String action) {
...
GoogleResponse googleResponse = restTemplate.getForObject(verifyUri, GoogleResponse.class);
if(!googleResponse.isSuccess() || !googleResponse.getAction().equals(action)
|| googleResponse.getScore() < captchaSettings.getThreshold()) {
...
throw new ReCaptchaInvalidException("reCaptcha was not successfully validated");
}
reCaptchaAttemptService.reCaptchaSucceeded(getClientIP());
}
}
유효성 검사가 실패하면 예외가 발생하지만 v3에서는 JavaScript 클라이언트에서 호출 할 재설정 메서드가 없습니다.
서버 리소스를 보호하기 위해 위에서 본 것과 동일한 구현이 계속 유지 됩니다.
5.6. GoogleResponse 클래스 업데이트
GoogleResponse 자바 빈 에 새 속성 점수 와 작업 을 추가해야 합니다.
@JsonPropertyOrder({
"success",
"score",
"action",
"challenge_ts",
"hostname",
"error-codes"
})
public class GoogleResponse {
// ... other properties
@JsonProperty("score")
private float score;
@JsonProperty("action")
private String action;
// standard getters and setters
}
6. 결론
이 기사에서는 Google의 reCAPTCHA 라이브러리를 등록 페이지에 통합하고 서버 측 요청으로 Security 문자 응답을 확인하는 서비스를 구현했습니다.
나중에 Google의 reCAPTCHA v3 라이브러리로 등록 페이지를 업그레이드했으며 사용자가 더 이상 조치를 취할 필요가 없기 때문에 등록 양식이 더 간결해지는 것을 확인했습니다.
이 예제의 전체 구현은 GitHub에서 사용할 수 있습니다 .