AndroidのreCAPTCHA APIを使う【私はロボットではありません】

# はじめに

機械的に大量に行われるユーザ登録やログイン試行、コメント投稿等を防ぐために、機械ではなく人間がUIが操作していることを確認する方法として[CAPTCHA](https://ja.wikipedia.org/wiki/CAPTCHA)があります。Googleは[reCAPTCHA](https://developers.google.com/recaptcha)というサービスを提供していて、[Android向けにもAPIを提供している](https://developer.android.com/training/safetynet/recaptcha.html)ので使ってみました。

# 参考文献

この記事はAndroid公式サイトの「[SafetyNet reCAPTCHA API](https://developer.android.com/training/safetynet/recaptcha)」の内容に沿っています。

# Google reCAPTCHAのサイトで登録する

## 必要事項を送信する

[Google reCAPTCHA](https://www.google.com/recaptcha/admin/create)のサイトにアクセスして登録します。

1. ラベルに分かりやすい名前を付けます。
2. reCAPTCHAタイプを `reCAPTCHA v2` にして、 `reCAPTCHA Android` を選択します。
3. パッケージはAndroidアプリのパッケージ名を入力します。
4. `reCAPTCHA 利用条件に同意する` にチェックを入れます。
5. `送信` ボタンを押します。

![スクリーンショット 2020-08-29 16.59.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334798/559dc26b-1a3c-2899-7777-de0f3c64024f.png)

## サイトキーとシークレットキーを保持する。

サイトキーとシークレットキーが表示されます。それらを**この画面で保存**します。以後表示することは出来ません。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334798/3d050a29-c74c-4072-d45f-9a2be620d148.png)

# AndroidアプリにreCHAPTCHA v2を追加する

## ライブラリの追加

モジュールレベルのbuild.gradleファイルにライブラリを追加します。

```app/build.gradle
dependencies {
    // 追加
    implementation 'com.google.android.gms:play-services-safetynet:17.0.0'
}
```

## 入力画面を作る

今回はサンプルプログラムとしてコメント入力欄と投稿ボタンだけの簡単な画面を作りました。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334798/2aaa7b8f-01d1-3f46-b738-e036fc376891.png" width="200px">

## 投稿するときに自動プログラムによる入力でないことを確認する

```MainActivity.kt
// 投稿ボタンをクリックしたときの処理
submit.setOnClickListener {
    SafetyNet.getClient(this)
        .verifyWithRecaptcha(getString(R.string.api_site_key)/* サイトキーを入力 */)
        .addOnSuccessListener { tokenResponse ->
            // 自動プログラムによる入力でないことの確認成功
            tokenResponse.tokenResult?.let {
                Log.d(TAG, "Success: $it")
            }
        }
        .addOnFailureListener {
            // 確認失敗
            if (it is ApiException) {
                Log.d(TAG, "Error: ${CommonStatusCodes.getStatusCodeString(it.statusCode)}")
            } else {
                Log.d(TAG, "Error: ${it.message}")
            }
        }
}
```

アプリを実行して投稿ボタンを押すと、このようなプログレスダイアログが表示されて、その後トークンを得ることが出来ます。トークンはサーバーサイドで検証する必要があるので、それを次の節で説明します。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334798/52731331-03f0-3eaf-6d27-68a65c1993da.png" width="200px">

デバッグのために何度か行っていると適切な画像を選択することを求められることがあります。

 <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334798/181a796d-5c99-93ee-d320-b5b298244f6d.png" width="200px">

# サーバサイドで検証する

トークンをサーバーサイドで検証するために、サーバサイドアプリケーションを開発します。この記事ではサーバサイドKotlinのWebフレームワークである[Ktor](https://ktor.io/)を使用して開発していますが言語やフレームワークを問いません。サーバサイドでHTTPSのAPIが呼べれば何でも良いです。Ktorについては先日投稿した記事「[Androidエンジニアが慣れている技術で作る維持費0円のサーバサイドアプリケーション](https://qiita.com/tfandkusu/items/5bee0e6eec035a74620b)」に本番環境へのデプロイまで解説しています。

## トークン検証APIクライアントを作成する

トークンの検証はトークンを持ってGoogleのAPIを呼ぶことで出来ます。APIの仕様はこちらにあります。
[Verifying the user's response](https://developers.google.com/recaptcha/docs/verify)

まずはサーバサイドで動作させるためのAPIクライアントをRetrofitで作ります。

```build.gradle
dependencies {
    // Retrofit
    def retrofit_version = '2.9.0'
    implementation "com.squareup.retrofit2:retrofit:$retrofit_version"
    implementation "com.squareup.retrofit2:converter-gson:$retrofit_version"
}
```

```VerifyResponse.kt
data class VerifyResponse(
    val success: Boolean,
    val challenge_ts: Date,
    val apk_package_name: String,
    @SerializedName("error-codes")
    val error_codes: List<String>
)
```

```VerifyService.kt
interface VerifyService {
    @FormUrlEncoded
    @POST("/recaptcha/api/siteverify")
    suspend fun verify(
        @Field("secret") secret: String,
        @Field("response") response: String
    ): VerifyResponse
}
```

```Application.kt
val retrofit = Retrofit.Builder()
    .addConverterFactory(GsonConverterFactory.create())
    .baseUrl("https://www.google.com/")
    .build()
val service = retrofit.create(VerifyService::class.java)
```

注意点としてレスポンス本文の形式が `application/json` であるのに対して、リクエスト本文の形式は`application/x-www-form-urlencoded` なので、間違えないようにしてください。

## コメント投稿APIで検証を行う

コメントの投稿を受け付けるAPIのサーバサイドを作ります。

今回はこのようなAPIを作ります。
エンドポイント `POST /comments` 
リクエスト本文

```json
{
    "comment": "ユーザが記入したコメントです。",
    "token": "reCAPTCHAのトークンです。サーバサイドで検証します"
}
```

まず各アプリ固有の情報であるGoogle reCAPTCHAのサイトで登録したAndroidアプリのパッケージ名と、取得したシークレットキーを準備します。

```Application.kt
val packageName = System.getenv("APK_PACKAGE_NAME")
val secret = System.getenv("RECAPTCHA_SECRET_KEY")
```

そしてAPIのエンドポイントを検証付きで作ります。

```CommentRequest.kt
data class CommentRequest(val comment: String, val token: String)
```

```Application.kt
routing {
    post("/comments") {
        // APIリクエストの取得
        val request = call.receive<CommentRequest>()
        // 検証API呼び出し
        val response = service.verify(secret, request.token)
        // 成功フラグとAndroidアプリのパッケージ名を確認する
        if (response.success &&
            response.apk_package_name == packageName
        ) {
            // 検証成功
            // request.comment をデータベースに書き込む等する
            call.respond(mapOf("result" to "ok", "comment" to request.comment))
        } else {
            // 検証失敗
            call.respond(HttpStatusCode.BadRequest)
        }
    }
}
```

# AndroidアプリからAPIを呼び出す

あとはトークンと投稿内容を合わせてサーバに送信すれば良いです。

```app/build.gradle
dependencies {
    def lifecycle_version = '2.2.0'
    // ViewModelとLiveData
    implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
    implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
    // by viewModels() 委譲プロパティを使えるようにする
    implementation "androidx.activity:activity-ktx:1.1.0"
    // Retrofit
    def retrofit_version = '2.9.0'
    implementation "com.squareup.retrofit2:retrofit:$retrofit_version"
    implementation "com.squareup.retrofit2:converter-gson:$retrofit_version"
}
```


```CommentRequest.kt
data class CommentRequest(val comment: String, val token: String)
```

```ApiService.kt
interface ApiService {
    @POST("/comments")
    suspend fun postComment(@Body request: CommentRequest)
}
```

```MainViewModel.kt
class MainViewModel : ViewModel() {

    /**
     * URLはデプロイ先。今回はGoogle App Engineにデプロイした。
     */
    private val retrofit = Retrofit.Builder()
        .addConverterFactory(GsonConverterFactory.create())
        .baseUrl("https://recaptcha-dot-tfandkusu.appspot.com/")
        .build()

    private val service = retrofit.create(ApiService::class.java)

    /**
     * CAPTCHAの検証が成功
     */
    val success = MutableLiveData<Boolean>()

    /**
     * CAPTCHAの検証が失敗
     */
    val fail = MutableLiveData<String>()

    /**
     * プログレス表示
     */
    val progress = MutableLiveData(false)

    fun postComment(comment: String, token: String) = viewModelScope.launch {
        try {
            progress.value = true
            service.postComment(CommentRequest(comment, token))
            // 投稿成功
            success.value = true
        } catch (e: Throwable) {
            // 投稿失敗
            fail.value = e.toString()
        } finally {
            progress.value = false
        }
    }
}
```

```MainActivity.kt
class MainActivity : AppCompatActivity() {
    private val viewModel: MainViewModel by viewModels()
    // 略
}
```

```MainActivity.kt
SafetyNet.getClient(this)
    .verifyWithRecaptcha(getString(R.string.api_site_key)/* サイトキーを入力 */)
    .addOnSuccessListener { tokenResponse ->
        tokenResponse.tokenResult?.let {
            viewModel.postComment(comment, it)
        }
    }
```

# 全体ソースコード

Githubで公開しています。

[サーバサイド](https://github.com/tfandkusu/recaptcha_server)
[Androidアプリ](https://github.com/tfandkusu/recaptchav2)