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 , responseremoteip 를 사용 하여 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.propertiesCaptchaSettings 업데이트

등록 후 새로운 키와 선택한 점수 임계값으로 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에서 사용할 수 있습니다 .

Security footer banner