はじめに
機械的に大量に行われるユーザ登録やログイン試行、コメント投稿等を防ぐために、機械ではなく人間がUIが操作していることを確認する方法としてCAPTCHAがあります。GoogleはreCAPTCHAというサービスを提供していて、Android向けにもAPIを提供しているので使ってみました。
参考文献
この記事はAndroid公式サイトの「SafetyNet reCAPTCHA API」の内容に沿っています。
Google reCAPTCHAのサイトで登録する
必要事項を送信する
Google reCAPTCHAのサイトにアクセスして登録します。
- ラベルに分かりやすい名前を付けます。
- reCAPTCHAタイプを
reCAPTCHA v2
にして、reCAPTCHA Android
を選択します。 - パッケージはAndroidアプリのパッケージ名を入力します。
-
reCAPTCHA 利用条件に同意する
にチェックを入れます。 -
送信
ボタンを押します。
サイトキーとシークレットキーを保持する。
サイトキーとシークレットキーが表示されます。それらをこの画面で保存します。以後表示することは出来ません。
AndroidアプリにreCHAPTCHA v2を追加する
ライブラリの追加
モジュールレベルのbuild.gradleファイルにライブラリを追加します。
dependencies {
// 追加
implementation 'com.google.android.gms:play-services-safetynet:17.0.0'
}
入力画面を作る
今回はサンプルプログラムとしてコメント入力欄と投稿ボタンだけの簡単な画面を作りました。

投稿するときに自動プログラムによる入力でないことを確認する
// 投稿ボタンをクリックしたときの処理
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}")
}
}
}
アプリを実行して投稿ボタンを押すと、このようなプログレスダイアログが表示されて、その後トークンを得ることが出来ます。トークンはサーバーサイドで検証する必要があるので、それを次の節で説明します。

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

サーバサイドで検証する
トークンをサーバーサイドで検証するために、サーバサイドアプリケーションを開発します。この記事ではサーバサイドKotlinのWebフレームワークであるKtorを使用して開発していますが言語やフレームワークを問いません。サーバサイドでHTTPSのAPIが呼べれば何でも良いです。Ktorについては先日投稿した記事「Androidエンジニアが慣れている技術で作る維持費0円のサーバサイドアプリケーション」に本番環境へのデプロイまで解説しています。
トークン検証APIクライアントを作成する
トークンの検証はトークンを持ってGoogleのAPIを呼ぶことで出来ます。APIの仕様はこちらにあります。
Verifying the user's response
まずはサーバサイドで動作させるためのAPIクライアントをRetrofitで作ります。
dependencies {
// Retrofit
def retrofit_version = '2.9.0'
implementation "com.squareup.retrofit2:retrofit:$retrofit_version"
implementation "com.squareup.retrofit2:converter-gson:$retrofit_version"
}
data class VerifyResponse(
val success: Boolean,
val challenge_ts: Date,
val apk_package_name: String,
@SerializedName("error-codes")
val error_codes: List<String>
)
interface VerifyService {
@FormUrlEncoded
@POST("/recaptcha/api/siteverify")
suspend fun verify(
@Field("secret") secret: String,
@Field("response") response: String
): VerifyResponse
}
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
リクエスト本文
{
"comment": "ユーザが記入したコメントです。",
"token": "reCAPTCHAのトークンです。サーバサイドで検証します"
}
まず各アプリ固有の情報であるGoogle reCAPTCHAのサイトで登録したAndroidアプリのパッケージ名と、取得したシークレットキーを準備します。
val packageName = System.getenv("APK_PACKAGE_NAME")
val secret = System.getenv("RECAPTCHA_SECRET_KEY")
そしてAPIのエンドポイントを検証付きで作ります。
data class CommentRequest(val comment: String, val token: String)
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を呼び出す
あとはトークンと投稿内容を合わせてサーバに送信すれば良いです。
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"
}
data class CommentRequest(val comment: String, val token: String)
interface ApiService {
@POST("/comments")
suspend fun postComment(@Body request: CommentRequest)
}
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
}
}
}
class MainActivity : AppCompatActivity() {
private val viewModel: MainViewModel by viewModels()
// 略
}
SafetyNet.getClient(this)
.verifyWithRecaptcha(getString(R.string.api_site_key)/* サイトキーを入力 */)
.addOnSuccessListener { tokenResponse ->
tokenResponse.tokenResult?.let {
viewModel.postComment(comment, it)
}
}
全体ソースコード
Githubで公開しています。