구글의 캡차 서비스인 reCAPTCHA(리캡차)는 버전 3(v3)까지 나왔다

reCAPTCHA 3 버전의 특징은 유저에게 직접적인 challenge 노출 없이, 마우스 움직임 등의 이벤트를 인식해서 봇 여부를 판단한다는 점이다. 점수는 0(봇)에서 1(사람)까지 나오고, 기본적으로 0.5가 기준이 되어 이 이상인 경우만 success 응답이 나온다. 이 threshold는 수정 가능하다.

평가당 가격은 매 월 10000건까지는 무료, 그 이후는 10만건까지 8$가 부과된다. 엔터프라이즈 사용시엔 그 이후 1000건당 1$가 부과된다. (가격 참고)

 

reCAPTCHA 키 발급

https://www.google.com/recaptcha/admin

위 링크에서 키를 발급한다

  • site key: 공개키
  • secret key: 비밀키

주의할 점은 secret key는 절대 클라이언트에 노출되면 안된다.

또한, localhost는 기본적으로 막혀 있어서 dev용 reCAPTCHA 키를 따로 만들어 origin 검증하지 않도록 해야 테스트가 용이하다.

 

간단한 버전

가장 먼저, 클라이언트에 최소한의 js만 넣어서 recaptcha v3를 사용할 수 있다

서버를 거칠 필요는 없다. site key만 입력하면 된다

 <script src="https://www.google.com/recaptcha/api.js"></script>
 
  <script>
   function onSubmit(token) {
     document.getElementById("demo-form").submit();
   }
 </script>
 
 <button class="g-recaptcha" 
        data-sitekey="reCAPTCHA_site_key" 
        data-callback='onSubmit' 
        data-action='submit'>Submit</button>

만약 이 이상으로 처리가 필요하다면, 수동으로 challenge를 호출해야 한다.

 

수동으로 challenge 호출하기

위 방식을 사용할 수 없으면 수동으로 challenge를 호출해야한다.

초간단하게 토큰 검증 api만 하나 추가하고 싶다면, 아래와 같은 프로세스로 진행한다

  • 1) 클라이언트에서 토큰 발급
  • 2) 클라 -> 서버로 토큰 전송
  • 3) 서버 -> google로 토큰 검증
  • 4) 서버 -> 클라로 토큰 검증 결과값 전송
  • 5) 클라에서 검증 결과값을 이용해 API 호출 유무 결정

OR, 요청 자체에 토큰을 받아서 처리하는 것도 방법이다. (이게 더 정확하다)

  • 1) 클라이언트에서 토큰 발급
  • 2) 클라 -> 서버로 요청시 토큰을 함께 전송
  • 3) 서버 -> google로 토큰 검증
  • 4) 서버: 토큰 검증 결과에 따라 API 거절 or 실행

 

이번 글에서는 초간단 버전으로 진행해본다.

먼저 클라이언트에서 recaptcha의 api.js를 불러올 때 render 쿼리파라미터를 추가해야 한다. 그 다음, grecaptcha 객체를 이용해 token을 발급받아야 한다. 발급받은 토큰을 백엔드 서버에 전송하는 로직을 넣으면 끝이다.

공식 문서의 예제는 아래와 같다

<script src="https://www.google.com/recaptcha/api.js?render=reCAPTCHA_site_key"></script>

   <script>
      function onClick(e) {
        e.preventDefault();
        grecaptcha.ready(function() {
          grecaptcha.execute('reCAPTCHA_site_key', {action: 'submit'}).then(function(token) {
              // Add your logic to submit to your backend server here.
          });
        });
      }
  </script>

 

react + spring 예시

// recaptcha.ts
declare global {
  interface Window {
    grecaptcha: any;
  }
}

export default class reCAPTCHA {
  siteKey: string;
  action: string;

  constructor(siteKey: string, action: string) {
    this.siteKey = siteKey;
    this.action = action;
  }

  async getToken(): Promise<string> {
    let token = "";
    await window.grecaptcha.execute(this.siteKey, { action: this.action })
      .then((res: string) => {
        token = res;
      })
    return token;
  }

  load() {
    if (document.getElementById('recaptcha')) {
      return;
    }

    const script = document.createElement('script')
    script.src = `https://www.recaptcha.net/recaptcha/api.js?render=${this.siteKey}`
    script.id = 'recaptcha'
    document.body.appendChild(script)
  }

  unload() {
    const script = document.getElementById('recaptcha');
    if (script) {
      script.remove();
    }

    const badge = document.querySelector('.grecaptcha-badge');
    if (badge) {
      badge.remove();
    }
  }
}

script element를 이렇게 직접 만들어서 넣어주는거 말고는 다른 방법이 없는걸로 보인다.

const LoginComponent = () => {
  const recaptchaRef = useRef<reCAPTCHA | null>(null);

  useEffect(() => {
    if (!recaptchaRef.current) {
      recaptchaRef.current = new reCAPTCHA(import.meta.env.VITE_RECAPTCHA_SITE_KEY, 'login');
    }

    recaptchaRef.current.load();
  }, []);
  
  const handleSubmit = async (e) => {
    e.preventDefault();
    if (!recaptchaRef.current) {
      console.error('reCAPTCHA not loaded');
      return;
    }
    
    const token = await recaptchaRef.current.getToken();
    
    // 백엔드 서버로 verify 요청
    // ...

컴포넌트에서는 이런식으로 쓰면 될거다.

 

// RecaptchaService.kt
interface RecaptchaService {
    interface Verify {
        data class Response(
            val success: Boolean,

            /**
             * timestamp of the challenge load (ISO format yyyy-MM-dd'T'HH:mm:ssZZ)
             */
            @JsonProperty("challenge_ts")
            val challengeTimestamp: Instant? = null,

            /**
             * the hostname of the site where the reCAPTCHA was solved
             */
            val hostname: String? = null,

            @JsonProperty("error-codes")
            val errorCodes: List<String>? = null
        )
    }

    fun verify(response: String, remoteIp: String? = null): Verify.Response
}

/**
 * @see <a href="https://developers.google.com/recaptcha/docs/verify">Google reCAPTCHA</a>
 */
interface RecaptchaServiceInternal {

    /**
     * Verifies the user's response to the reCAPTCHA challenge.
     *
     * @param secret The shared key between your site and reCAPTCHA.
     * @param response The user response token provided by the reCAPTCHA client-side integration on your site.
     * @param remoteIp (Optional) The user's IP address.
     * @return The response from the reCAPTCHA service.
     */
    @PostExchange(
        url = "/recaptcha/api/siteverify",
        contentType = MediaType.APPLICATION_FORM_URLENCODED_VALUE,
        accept = [MediaType.APPLICATION_JSON_VALUE]
    )
    fun verify(
        @RequestParam secret: String,
        @RequestParam response: String,
        @RequestParam remoteIp: String? = null
    ): Verify.Response
}

class RecaptchaServiceImpl(
    private val delegate: RecaptchaServiceInternal,
    private val secret: String
) : RecaptchaService {

    override fun verify(response: String, remoteIp: String?): Verify.Response {
        return try {
            delegate.verify(
                secret = secret,
                response = response,
                remoteIp = remoteIp
            )
        } catch (e: Exception) {
            throw RuntimeException("Failed to verify reCAPTCHA", e)
        }
    }
}
// RecaptchaConfig.kt
@Configuration
class RecaptchaConfig(
    @Value("\${recaptcha.secret-key}") private val secret: String
) {

    @Bean
    fun recaptchaService(): RecaptchaService {
        val restClient = RestClient.builder()
            .baseUrl("https://www.google.com/")
            .build()

        val adapter = RestClientAdapter.create(restClient)
        val factory = HttpServiceProxyFactory.builderFor(adapter).build()
        val client = factory.createClient(RecaptchaServiceInternal::class.java)

        return RecaptchaServiceImpl(client, secret)
    }
}

백엔드 코드 예시. spring HTTP interface client를 써봤는데, secret을 Service에 주입하려다보니 저런 구조가 되었다. 이게 맞나..?

주의할 점은 siteverify POST 요청시 form urlencoded 형식만 가능하다.

 

콘솔

gcp 콘솔에서 자세한 호출 내역을 볼 수 있다.

로깅은 따로 켜야 한다.

 

참고자료

 

반응형