Spring Boot + Kotlin で Google Sign-In のバックエンド認証を行う

Spring Boot + Gradle + Kotlin の環境で、Google Sign-In のバックエンド側の認証処理を実装してみました。

はじめに

Google Sign-In とは、Googleが提供している認証系ツールの総称である Google Identity Platform のツールの1つで、スマホアプリやWebアプリの認証をGoogleアカウントでできるようにするものです。

参考:

itexplorer.hateblo.jp

今回の内容は、クライアント側でGoogle認証が通った後、バックエンドのAPIをたたく時に、バックエンド側で認証をチェックする部分の話です。

クライアントからのリクエストが認証済みのものかどうかをチェックし、確認できたらユーザー情報を取得します。

一般的な実装方法の情報

一般的な方法は以下のページで説明されています:

Authenticate with a backend server  |  Google Sign-In for Websites  |  Google Developers

ただし、Java の例しかないので、今回は Kotlin で書き直します。

準備

今回は Spring Boot + Gradle + Kotlin で作ります。

環境構築の方法については、以前の記事でまとめました:

itexplorer.hateblo.jp

Google API Client Library の導入

一応、すべてのAPIが使えるように、build.gradle に以下の項目を追加します:

repositories {
    mavenCentral()
}
dependencies {
    compile 'com.google.api-client:google-api-client:1.22.0'
}

実際には、リポジトリーの指定はすでに入っているはずなので、google-api-client の追加だけで済みます。

また、認証APIだけであれば、以下の項目だけでOKなはずです(未確認):

compile 'com.google.apis:google-api-services-oauth2:v2-rev127-1.22.0'

その後、ライブラリーをダウンロードするために、プロジェクトを選択し、右クリックメニューから Gradle (STS) > Refresh All を実行します。

Ref.

ロジックの実装

上でも挙げた「Authenticate with a backend server  |  Google Sign-In for Websites  |  Google Developers」の説明に従って実装します。

ただし、Kotlin の例は載っていないので、Java の例を Kotlin に書き換えます。

途中、かなり試行錯誤しましたが、最終的に以下のようなコードで動作させることができました:

// Controller
package com.example.demo

import org.springframework.web.bind.annotation.RequestHeader
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import com.example.demo.Auth

@RestController
class HelloController {
    
    @RequestMapping("/")
    fun index(@RequestHeader("Authorization") authToken: String): Result {
        val idTokenString = getIdTokenString(authToken)
        if (idTokenString == "") {
            return Result(false, "Token is invalid.")
        }
        
        val auth = Auth()
        val isAuth = auth.verify(idTokenString)
        if (isAuth) {
            val userInfo = auth.getUserInfo()
            return Result(true, userInfo)
        } else {
            return Result(false, "You are not authorized.")
        }
    }
    
    fun getIdTokenString(authToken: CharSequence): String {
        // authToken の形式は「bearer {idTokenString}」
        val elems = authToken.split(Regex(" "), 2)
        if (elems.size != 2) {
            return ""
        } else {
            return elems[1].trim()
        }
    }
}

data class Result(val isSuccess: Boolean, val message: String)
// Auth class
package com.example.demo

import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken
import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken.Payload
import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier
import com.google.api.client.http.javanet.NetHttpTransport
import com.google.api.client.json.jackson2.JacksonFactory
import java.util.Arrays

const val CLIENT_ID: String = "xxxxx"  // クライアントで使用しているID

class Auth {
    
    var idToken: GoogleIdToken? = null
    
    // IDトークンを検証
    fun verify(idTokenString: String): Boolean {
        val transport = NetHttpTransport()
        val jsonFactory = JacksonFactory.getDefaultInstance()
        val verifier = GoogleIdTokenVerifier.Builder(transport, jsonFactory)
            .setAudience(Arrays.asList(CLIENT_ID))  // 複数のCLIENT_IDを指定可能
            .setIssuer("accounts.google.com")
            .build()
        idToken = verifier.verify(idTokenString)
        return (idToken != null)
    }
    
    // ユーザー情報を取得
    fun getUserInfo(): String {
        if (idToken == null) {
            return ""
        }
        val payload: Payload = (idToken as GoogleIdToken).getPayload()
        val userId = payload.getSubject()
        val email = payload.getEmail()
        return "User ID: ${userId}, Email: ${email}"
    }
}

まず、クライアントのIDトークンは Authorization ヘッダーで受け取っています。

Authorization ヘッダーの形式は、「bearer {ID Token}」です。

HelloController#getIdTokenString() でIDトークンを取り出しています。(ここはもう少しきれいに書けるかもしれません。)

取り出したIDトークンを Auth#verify() に渡して検証します。

ここで問題なのが GoogleIdTokenVerifier.Builder() に渡すパラメーターなのですが、上記のGoogleの説明には書かれていません。

探し回った挙句、上記のようなコードになりました。

検証が成功したら、Auth#getUserInfo() でユーザー情報を取得します。

Kotlin ではnullチェックが厳しいので、少しゴニョゴニョやる必要があります。

最後にコントローラー側で Result データクラスに詰めて返せば、JSON形式でレスポンスが返ります。

なお、RestController で実装しているので、 Postman を使うと簡単にテストできます。

setIssuer() について

Googleの説明には書かれていないのですが、GoogleIdTokenVerifier.Builder() のところで .setIssuer("accounts.google.com") を追加する必要がありました。

このパラメーターですが、IDトークンを直接検証できるエンドポイントが用意されているので、そのレスポンスに入っているものを使用しました:

// エンドポイント
https://www.googleapis.com/oauth2/v3/tokeninfo?id_token=XYZ123

// レスポンス
{
 "iss": "accounts.google.com",
 "sub": "110169484474386276334",
 "azp": "1008719970978-hb24n2dstb40o45d4feuo2ukqmcc6381.apps.googleusercontent.com",
 .....
}

iss の値がそれです。

ネットの情報だと、https が付いているものがあるので、念のために、実際の値を確認した方が良いと思います。

適当なIDトークンはエラーになる

ひと通り実装が終わった時に試しに動かしてみたのですが、以下のようなエラーが発生しました:

java.lang.IllegalArgumentException: null
    at com.google.api.client.repackaged.com.google.common.base.Preconditions.checkArgument(Preconditions.java:111) ~[google-http-client-1.22.0.jar:1.22.0]
    at com.google.api.client.util.Preconditions.checkArgument(Preconditions.java:37) ~[google-http-client-1.22.0.jar:1.22.0]
    at com.google.api.client.json.webtoken.JsonWebSignature$Parser.parse(JsonWebSignature.java:599) ~[google-http-client-1.22.0.jar:1.22.0]
    at com.google.api.client.googleapis.auth.oauth2.GoogleIdToken.parse(GoogleIdToken.java:57) ~[google-api-client-1.22.0.jar:1.22.0]
    at com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier.verify(GoogleIdTokenVerifier.java:191) ~[google-api-client-1.22.0.jar:1.22.0]

ここでかなり悩んだのですが、試行錯誤した結果、実際のものではない適当なIDトークンを指定すると、このエラーが発生することがわかりました。

また、渡すIDトークンの形式によって、別の例外になる場合もあるようです。

なので、上のコードには入れていませんが、実アプリでは例外をキャッチする必要があります。

あとがき

これで、ようやく Google Sign-In の実装をクライアントからバックエンドまで確認することができました。

でも、こんなに苦労するとは。。。

追記 2017-08-01

上記のコードはあくまで実装方法を確認するためのサンプルで、実際のアプリでは様々なリクエストでトークンチェックが必要になるため、共通化した方がいいです。

共通化の方法としては、Spring Security をベースに組み込むのが良さそうです。

以下は参考になりそうな情報です:

ただ、Spring Security については学習コストが高そうですね。

やはり、Spring framework について知らないと、Spring Boot で本格的な開発は難しそうですね。

Spring を使えるエンジニアも少なそうなので、Spring Boot でやっていくのは厳しいかな。。。