【Nuxt.js+Spring】Google reCAPTCHAでスパム対策をする方法

プログラミング等

どうもこじらです。

フロントエンド側をVue.js(Nuxt.js)、バックエンド側をJava(Spring)を使用してGoogle reCAPTCHAのスパム対策をしてみました。

画像選択したりするアレですね。

Vue.jsの記事はたくさんありますが、Springの記事が少なくて躓いたので記事にしてみました。

全体の流れ

Google reCAPTCHA実装までの流れ、ユーザが画面操作した際の処理の流れをざっくり説明します。

実装までのざっくりした流れ

まず、Google reCAPTCHAのサイト登録を行い、「サイトトークン」と「シークレットトークン」を発行しておきます。

ログイン - Google アカウント

サイトトークンをフロント側、シークレットトークンをバックエンド側に埋め込んで、処理周りを実装したら完了です。

まぁ詳細は後程。

v2とv3どっちが良い?

v3はマウス操作やフリック操作等からロボットかどうかの判定を行い、v2は画像選択等の操作で判定を行います。

脆弱性の観点からどっちが良いかは私には分からないので、

  • ユーザ第一→v3
  • かっこよさ第一→v2

って感じで良いと思いますww

まぁ普通に考えたらv3になりますかね?(私はv2にしました。ニッコリ)

テスト環境の登録

テスト環境でも使用したい場合は、reCAPTCHAの管理コンソールでその環境のIPアドレスをドメインとして追加してください。

ポート番号を含めたり「localhost」として登録してもうまく認識してくれないようです。

※加えて、「reCAPTCHA ソリューションの入手元を検証する」のチェックも外す必要があるみたいです。

ユーザ操作時のざっくりとした

次はユーザが操作した際の流れです。

  1. サイトトークンと操作内容をフロント側からreCAPTCHA APIに送信しロボットか確認。
  2. 「レスポンス」トークンをバックエンド側に送信し、シークレットトークンと一緒に、今度はバックエンド側からreCAPTCHA APIに送信して、ユーザが確認済みかを確認する。

正確かは分かりませんが、この認識で実装できました。

 

図上にある「チャレンジ」は画像選択のようなボット判定のための操作を指しています。

OAuthのような認証処理と似た流れですね。

 

フロント側の実装

Nuxt.jsのreCaptchaライブラリをインストールします。

@nuxtjs/recaptcha
Simple and easy Google reCAPTCHA integration with Nuxt.js
npm i @nuxtjs/recaptcha

編集するファイルは、

  • nuxt.config.js
  • 対象の画面ファイル

の2つだけです。

コード

nuxt.config.js

※一部抜粋

  modules: [
   '@nuxtjs/recaptcha'
  ],
  recaptcha: {
    hideBadge: true,
    siteKey: '6Lfk96oaAAAAAEkK05e8EkwWlteEEnxHABWyB8Oh',
    version: 2
  }

バージョンは登録したときのバージョンに合わせてください。(2or3)

画面ファイル(Inquiry.vue)

v2の場合は<recaptcha />タグを追加する必要があります。↓その位置によく見るこいつが挿入されます。

v3の場合は<recaptcha />の追加は不要です。

※一部抜粋。v2の書き方です。Vuetifyを使用しています。

<template>
  <div>
    <v-container>
      <h2 class="display-1">
        <v-row>
          <v-col style="font-weight:bold;">
            問い合わせフォーム
          </v-col>
        </v-row>
      </h2>
    </v-container>
    <v-container>
      <v-row>
        <v-col
          cols="12"
          md="5"
          lg="5"
          xl="5"
        >
          ユーザID
        </v-col>
        <v-col
          cols="12"
          md="7"
          lg="7"
          xl="7"
        >
          {{ userId ? userId : 'ログインなし' }}
        </v-col>
      </v-row>
      <v-row>
        <v-col
          cols="12"
          md="5"
          lg="5"
          xl="5"
        >
          <v-icon>
            mdi-pen
          </v-icon>
          メールアドレス
          <span class="badge badge-danger">必須</span>
        </v-col>
        <v-col
          cols="12"
          md="7"
          lg="7"
          xl="7"
        >
          <v-text-field
            v-model="email"
            background-color="white"
            outlined
            dense
            height="5"
            rows="1"
            clearable
            :rules="rules.email"
            :counter="256"
            maxlength="256"
            type="mail"
            autocomplete="off"
          />
        </v-col>
      </v-row>
      <v-row>
        <v-col
          cols="12"
          md="5"
          lg="5"
          xl="5"
        >
          <v-icon>
            mdi-pen
          </v-icon>
          質問内容
          <span class="badge badge-danger">必須</span>
        </v-col>
        <v-col
          cols="12"
          md="7"
          lg="7"
          xl="7"
        >
          <v-textarea
            v-model="text"
            background-color="white"
            outlined
            dense
            rows="10"
            no-resize
            counter
            autocomplete="off"
          />
        </v-col>
      </v-row>
      <v-row>
        <v-col>
          <recaptcha />
        </v-col>
      </v-row>
      <v-row>
        <v-col align="center">
          <v-btn
            rounded
            min-width="50%"
            color="success"
            @click="onSubmit"
          >
            送信
          </v-btn>
        </v-col>
      </v-row>
    </v-container>
  </div>
</template>

<script>
import axios from 'axios'
export default {
  name: 'Inquiry',
  data () {
    return {
      text: '',
      email: '',
      userId: this.$store.getters.userId,
      rules: {
        email: [
          (v) => {
            if (!v) {
              return true || ''
            }
            const pattern = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
            return pattern.test(v) || 'メールアドレスの形式に合いません。'
          }
        ]
      }
    }
  },
  async mounted () {
    await this.$recaptcha.init()
  },
  methods: {
    async onSubmit () {
      try {
        const token = await this.$recaptcha.getResponse()

        await this.$recaptcha.reset()

        if (!this.inputCheck()) { return }

        const isOk = confirm('送信を行います。よろしいでしょうか?')

        if (isOk) {
          this.post(token)
        }
      } catch (e) {
        console.log('Login error' + e)
      }
    },
    inputCheck () {
      // 入力チェック
    },
    async post (token) {
      try {
        // APIにPOST送信
        const formData = new FormData()
        formData.append('userId', this.userId)
        formData.append('mailAddress', this.email)
        formData.append('inquiryText', this.text)
        formData.append('recaptchaResponseToken', token)
        const config = {
          headers: {
            'content-type': 'multipart/form-data'
          }
        }
        await axios.post(<バックエンド側URL>, formData, config)
          .then((res) => {
            // 何かしらの処理
          })
          .catch(() => {})
      } catch (error) {
        alert('送信に失敗しました。')
      }
    }
  }
}
</script>

フロント側はしょうもないミスを除けば特に躓くポイントはありませんでした。(nuxt.config.jsで独自定義の環境変数を読み込もうとしてかなり時間を食いました……。アホスンギ)

バックエンド側の実装

さて、ここからが本題って感じです。

javaでSpringを使用していきますが、reCAPTCHA専用のMavenプラグインがあるとか、そういう類のものではありません。

 

公式ドキュメントでは「API側で適当にPOST送信してね~。簡単だよ~。」みたいな雰囲気が漂っていますw

Verifying the user's response  |  reCAPTCHA  |  Google Developers

 

ということで、まずはcurlコマンドで動作確認。

謎に苦戦しましたが、以下で”success”: trueが返却されます。

curl -X POST -d "secret=シークレットトークン&response=レスポンス" https://www.google.com/recaptcha/api/siteverify
  • 「シークレットトークン」はサイト登録したときのバックエンド側のやつ
  • 「レスポンス」はフロント側でreCAPTCHA APIから受け取ったトークン

を指しています。

 

ちなみに、GET送信でcurlコマンドを投げても受け付けてくれますw

まぁ、公式で「POST送信で!」って言われているのでPOST送信で送ります。

以下のページに記載がありますが、APIはapplication/x-www-form-urlencoded;でしか受け付けていないようです。(GETでは受け付けてくれるのに…。)

応答、POST、アプリケーション
Uウェイ:mに非表示のreCAPTCHAを設定しています

 

SpringのRestTemplateを使用してreCAPTCHA APIにリクエストを投げるようにします。

コード

Controller

serviceを呼び出しているだけです。

※一部抜粋

	@PostMapping("/inquiry")
	public String inquiry(SaveInquiryForm form) throws Exception {
		return bjInquiryService.saveInquiry(form);
	}

Service

JSON形式での送信ができないので実装が少し面倒です。RestTemplateで送信して、返却内容に応じて処理を行います。

package jp.brainjuice.business.service.user;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;

import jp.brainjuice.web.form.user.req.SaveInquiryForm;

@Service
public class BjInquiryService {

	@Value("${recaptcha.secretToken}")
	private String secret;

	public String saveInquiry(SaveInquiryForm form) {

		// Google reCaptcha URL.
		String url = "https://www.google.com/recaptcha/api/siteverify";

		// ヘッダ作成
		HttpHeaders headers = new HttpHeaders();
		// application/x-www-form-urlencoded.
		headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

		// ボディ作成
		MultiValueMap<String, String> map = new LinkedMultiValueMap<String, String>();
		map.add("secret", secret);
		map.add("response", form.getRecaptchaResponseToken());

		// HttpRequestの作成(ヘッダ + ボディ)
		HttpEntity<MultiValueMap<String, String>> entity = new HttpEntity<>(map, headers);

		// 送信
		RestTemplate restTemplate = new RestTemplate();
		RecaptchaResult result = restTemplate.exchange(url, HttpMethod.POST, entity, RecaptchaResult.class).getBody();

	    if (result.isSuccess()) {
	    	// 成功時の処理
	    } else {
	        // 失敗時の処理
	    }

		return "完了";
	}
}

SaveInquiryForm

フロント側からリクエストを受け取る際の型です。

クラス名がダサい点は許して。

package jp.brainjuice.web.form.user.req;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class SaveInquiryForm {

	private String userId;
	private String mailAddress;
	private String inquiryText;
	private String recaptchaResponseToken;
}

RecaptchaResult

reCAPTCHA APIからのレスポンスを受け取る型です。

返却値はJSON形式で返ってきます。

package jp.brainjuice.business.service.user;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;

import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@JsonIgnoreProperties(ignoreUnknown = true)
@NoArgsConstructor
public class RecaptchaResult {

	private boolean success;
	@JsonProperty("challenge_ts")
	private String challengeTs;
	private String hostname;
	@JsonProperty("error-codes")
	private String[] errorCodes;
}

 

実装完了。

とまぁ今回はこんな感じで。

こじらでした

じゃ

参考

Java/SpringでreCAPTCHA v3を実装する - Qiita
はじめに ググってもJava向けの記事がなかったのと、公式サイトは若干わかりづらかったのでまとめてみました。 公式リンク Google reCAPTCHA公式 フロントエンド実装について バックエンド実装について ...
Google reCAPTCHAのSite key、Secret keyの取得方法・20170408バージョン
ログイン画面や登録画面に出てくるreCAPTCHAの「私はロボットではありません」の実装には利用するサイトの登録が必要。その登録方法を詳細解説。設定ミスによる不具合の分類、解決方法を解説。
タイトルとURLをコピーしました