どうもこじらです。
フロントエンド側をVue.js(Nuxt.js)、バックエンド側をJava(Spring)を使用してGoogle reCAPTCHAのスパム対策をしてみました。
画像選択したりするアレですね。
Vue.jsの記事はたくさんありますが、Springの記事が少なくて躓いたので記事にしてみました。
全体の流れ
Google reCAPTCHA実装までの流れ、ユーザが画面操作した際の処理の流れをざっくり説明します。
実装までのざっくりした流れ
まず、Google reCAPTCHAのサイト登録を行い、「サイトトークン」と「シークレットトークン」を発行しておきます。
サイトトークンをフロント側、シークレットトークンをバックエンド側に埋め込んで、処理周りを実装したら完了です。
まぁ詳細は後程。
v2とv3どっちが良い?
v3はマウス操作やフリック操作等からロボットかどうかの判定を行い、v2は画像選択等の操作で判定を行います。
脆弱性の観点からどっちが良いかは私には分からないので、
- ユーザ第一→v3
- かっこよさ第一→v2
って感じで良いと思いますww
まぁ普通に考えたらv3になりますかね?(私はv2にしました。ニッコリ)
テスト環境の登録
テスト環境でも使用したい場合は、reCAPTCHAの管理コンソールでその環境のIPアドレスをドメインとして追加してください。
ポート番号を含めたり「localhost」として登録してもうまく認識してくれないようです。
※加えて、「reCAPTCHA ソリューションの入手元を検証する」のチェックも外す必要があるみたいです。
ユーザ操作時のざっくりとした
次はユーザが操作した際の流れです。
- サイトトークンと操作内容をフロント側からreCAPTCHA APIに送信しロボットか確認。
- 「レスポンス」トークンをバックエンド側に送信し、シークレットトークンと一緒に、今度はバックエンド側からreCAPTCHA APIに送信して、ユーザが確認済みかを確認する。
正確かは分かりませんが、この認識で実装できました。
図上にある「チャレンジ」は画像選択のようなボット判定のための操作を指しています。
OAuthのような認証処理と似た流れですね。
フロント側の実装
Nuxt.jsのreCaptchaライブラリをインストールします。
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
ということで、まずは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では受け付けてくれるのに…。)
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; }
実装完了。
とまぁ今回はこんな感じで。
こじらでした
じゃ