よーぐるとのブログ

技術ネタを中心。私的なことを徒然と綴っていきます。

Spring Boot(Kotlin)でOAuthを使ってGoogle Calendar APIを叩く

Java(Kotlin)アプリケーションからGoogle CalendarAPIを叩きたい

Spring Bootアプリケーション(Kotlin製)のAPIからGoogle Calendar APIを叩こうとしています。

GoogleAPIを叩くにはOauthログインが必要で、公式ガイドにはQuick StartがあるにはあるのですがWebアプリ向けではなく、少し工夫が必要でした。

Quick Startに書かれていること

Google Calendar APIにはいくつかの言語でのQuickStartがあり、その中にはJavaもあります。今回はKotlinで書かれたアプリケーションを作っているので厳密には異なるのですが、Javaでの書き方がわかれば問題ありません。

developers.google.com

QuickStartにはOauthによる認可を行いカレンダーAPIを叩くまでの流れとサンプルコードが記載されていますが、ここで実装されているのはコマンドラインアプリーケーションであり、Callback用のローカルサーバを立てて認証を行っています。

public class CalendarQuickstart {
    private static final String APPLICATION_NAME = "Google Calendar API Java Quickstart";
    private static final JsonFactory JSON_FACTORY = JacksonFactory.getDefaultInstance();
    private static final String TOKENS_DIRECTORY_PATH = "tokens";

    // アクセスする権限のスコープ
    private static final List<String> SCOPES = Collections.singletonList(CalendarScopes.CALENDAR_READONLY);
    // 認証情報
    private static final String CREDENTIALS_FILE_PATH = "/credentials.json";

    // 認証情報を取得する処理
    private static Credential getCredentials(final NetHttpTransport HTTP_TRANSPORT) throws IOException {
        // Load client secrets.
        InputStream in = CalendarQuickstart.class.getResourceAsStream(CREDENTIALS_FILE_PATH);
        if (in == null) {
            throw new FileNotFoundException("Resource not found: " + CREDENTIALS_FILE_PATH);
        }
        GoogleClientSecrets clientSecrets = GoogleClientSecrets.load(JSON_FACTORY, new InputStreamReader(in));

        // 認証に利用するオブジェクトを作成
        GoogleAuthorizationCodeFlow flow = new GoogleAuthorizationCodeFlow.Builder(
                HTTP_TRANSPORT, JSON_FACTORY, clientSecrets, SCOPES)
                .setDataStoreFactory(new FileDataStoreFactory(new java.io.File(TOKENS_DIRECTORY_PATH)))
                .setAccessType("offline")
                .build();
        // ローカルサーバを8888ポートで立てる
        LocalServerReceiver receiver = new LocalServerReceiver.Builder().setPort(8888).build();
        return new AuthorizationCodeInstalledApp(flow, receiver).authorize("user");
    }

    // メイン関数
    public static void main(String... args) throws IOException, GeneralSecurityException {
        final NetHttpTransport HTTP_TRANSPORT = GoogleNetHttpTransport.newTrustedTransport();
        // カレンダーAPI呼び出し用のオブジェクトを作成。この時点でアクセストークンがないと認証が必要になる。
        Calendar service = new Calendar.Builder(HTTP_TRANSPORT, JSON_FACTORY, getCredentials(HTTP_TRANSPORT))
                .setApplicationName(APPLICATION_NAME)
                .build();

        // ここから先はGoogle Calendar APIを叩く処理
        DateTime now = new DateTime(System.currentTimeMillis());
        Events events = service.events().list("primary")
                .setMaxResults(10)
                .setTimeMin(now)
                .setOrderBy("startTime")
                .setSingleEvents(true)
                .execute();
        List<Event> items = events.getItems();
        if (items.isEmpty()) {
            System.out.println("No upcoming events found.");
        } else {
            System.out.println("Upcoming events");
            for (Event event : items) {
                DateTime start = event.getStart().getDateTime();
                if (start == null) {
                    start = event.getStart().getDate();
                }
                System.out.printf("%s (%s)\n", event.getSummary(), start);
            }
        }
    }
}

ターミナルからキックするとGoogleの認証URLがログに出力され、URLに飛んだ先で認証を行うことでOauthによる認可が行われます。これでAPIを叩くには叩けるんですが、そのままWebアプリに流用するような作りにはなっていません。 QuickStartにも以下の通りしっかり書かれています。

The authorization flow in this example is designed for a command-line application. For information on how to perform authorization in a web application, see Using OAuth 2.0 for Web Server Applications.

Web Server向けの案内はこちらにあるようです。

developers.google.com

Java向けの案内はなく・・・

f:id:yoghurt1131:20190707231248p:plain
google-oauth-for-web-server

QuickStartのFurther Readingにはいくつか参考になりそうなリンクがあるんですが、

developers.google.com

f:id:yoghurt1131:20190707231516p:plain
api-client-library-java

ここにあるのもQuickStartにあるCommand-Line Application、AndroidGoogle App Engineのみ(というか画像が古い...。Google+って終了してなかったっけ?)

Web Applicationでの叩き方

ということでQuickStatとUsing OAuth 2.0 for Web Server ApplicationsのHTTP版、JavaライブラリのAPI Referenceを参考にしつつWeb Applicationで同じことをやる方法を探りました。

Oauthの流れ

知っている人は読み飛ばしてもらって良いですが、Oauthはざっくりと以下のような流れで認可を行います。

f:id:yoghurt1131:20190708000409p:plain:w500
GoogleカレンダーでのOauthによる認可

初回ユーザがアクセスしてきた場合、アプリーケーションはGoogleの認証画面へとリダイレクトします。

f:id:yoghurt1131:20190708001255p:plain:w300
Googleの認証画面

認証が終わるとあらかじめ設定してあるコールバックURLに飛ばされます。コールバック時にはパラメータとして認証コードがついてきます。

https://oauth2.example.com/auth?code=AUTHORIZATION_CODE

アプリケーションで認証コードを受け取り、それをGoogleのOauth APIへと渡してアクセストークンを取得します。

取得したアクセストークンを利用することでGoogle Calendar APIにアクセスすることができます。

実装

ということでGoogle Calendar APIの利用には以下の処理が必要になります。

  • 認証済みでないユーザが来た際にGoogleの認証ページにリダイレクトする
  • コールバックのリクエストを受け取ってアクセストークンを取得する

認証済みでないユーザが来た際にGoogleの認証ページにリダイレクトする

リダイレクトURLはhttps://accounts.google.com/o/oauth2/v2/auth を叩けば良いようですが、必要なクレデンシャル情報も含めいくつかパラメータが必要になってきます。 ライブラリのリファレンスを漁っているとAuthorizationCodeRequestUrlというそれっぽいクラスが見つかったのでそれを利用して生成することにしました。 https://developers.google.com/api-client-library/java/google-oauth-java-client/reference/1.19.0/com/google/api/client/auth/oauth2/AuthorizationCodeRequestUrl

f:id:yoghurt1131:20190708003142p:plain:w500

このクラスのbuildメソッドをKotlinで呼び出してあげます。

val SCOPES = Collections.singletonList(CalendarScopes.CALENDAR_READONLY) // アクセスするスコープを設定
val authUrl = "https://accounts.google.com/o/oauth2/auth" // 認証URL
val clientId = "xxxxxxx" // Client ID
val callbackUrl = "http://localhost:8080/callback" // コールバック用URL

val requestUrl = AuthorizationCodeRequestUrl(authUri, clientId)
    .setRedirectUri(callbackUrl)
    .setScopes(SCOPES)
    .build()
// リダイレクト
response.sendRedirect(requestUrl)

コールバックのリクエストを受け取ってアクセストークンを取得する

こちらはQuickStartでも利用されていたGoogleAuthorizationCodeFlowのリファレンスに飛んだらヒントがありました。

f:id:yoghurt1131:20190708005336p:plain

The web browser will then redirect to the redirect URL with a "code" query parameter which can then be used to request an access token using newTokenRequest(String).

newTokenRequest(String)を呼んであげれば良さそうです。

        val request = googleAuthorizationCodeFlow.newTokenRequest(code)
                .setRedirectUri(googleClientSecrets.details.redirectUris.first())
        val response = request.execute()
        googleCredential.setAccessToken(response.accessToken)
        return "This is endpoint for google oauth2 callback."

Spring Boot Applicationでの認証処理

最終的なコードは以下のようになりました。

Googleの認証画面へのリダイレクトをインターセプターで実装し、エンドポイントにアノテーションを付与することでControllerの指定したエンドポイントにアクセストークン無しで来た場合、Oauthによる認可が行われます。

@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class GoogleOAuth2  // ControllerにつけるAnnotation
// アクセストークンがない場合に自動でGoogleの認証ページにリダイレクトさせるInterceptor
class GoogleOAuth2Interceptor(
        private val googleClientSecrets: GoogleClientSecrets, // Oauthに利用するプロパティ
        private val googleCredential: GoogleCredential            // Calendar APIに利用するプロパティ
) : HandlerInterceptor {
    // 利用する権限:カレンダーのREADのみ
    private val SCOPES = Collections.singletonList(CalendarScopes.CALENDAR_READONLY)
    private val logger: Logger = LoggerFactory.getLogger(this::class.java)

    override fun preHandle(request: HttpServletRequest, response: HttpServletResponse, handler: Any): Boolean {
        logger.info("Start - GoogleOAuth2Interceptor $request, $response, $handler")
        val handerMethod = handler as HandlerMethod
        // アノテーションが付与されている場合、認証チェックを行う。
        val annotation = handerMethod.getMethodAnnotation(GoogleOAuth2::class.java)
        annotation?.let {
            logger.info("Check Google OAuth2....")
            // アクセストークンの有無を確認
            if(googleCredential.accessToken.isNullOrBlank()) {
                logger.info("No accessToken. Redirect Google Authentication Server")
                // アクセストークンがない場合は認証用URLを生成してリダイレクトする
                val requestUrl = AuthorizationCodeRequestUrl(googleClientSecrets.details.authUri, googleClientSecrets.details.clientId)
                        .setRedirectUri(googleClientSecrets.details.redirectUris.first())
                        .setScopes(SCOPES)
                        .build()
                 response.sendRedirect(requestUrl)
                return false
            }
            logger.info("AccessToken has found. Continue.")
        }
        return true
    }

    override fun postHandle(request: HttpServletRequest, response: HttpServletResponse, handler: Any, modelAndView: ModelAndView?) {
        logger.info("End - GoogleOAuth2Interceptor")
        super.postHandle(request, response, handler, modelAndView)
    }

}
@RestController
class CalendarController(
        private val googleCredential: GoogleCredential,
        private val googleClientSecrets: GoogleClientSecrets,
        private val googleAuthorizationCodeFlow: GoogleAuthorizationCodeFlow,
        private val calendarService: CalendarService) {

    private val logger = LoggerFactory.getLogger(javaClass)

    @GetMapping("/schedule/daily")
    @GoogleOAuth2  // 認証を行うエンドポイントにアノテーションを付与
    fun todaySchedule(): Schedule {
        val schedule = calendarService.getSchedule(1L)
        return schedule
    }

 // コールバック用エンドポイント
    @GetMapping("/callback")
    fun callback(code: String, scope: String): String {
        // 認証コードをもとにアクセストークンを取得
        val request = googleAuthorizationCodeFlow.newTokenRequest(code)
                .setRedirectUri(googleClientSecrets.details.redirectUris.first())
        val response = request.execute()
        googleCredential.setAccessToken(response.accessToken)
        return "This is endpoint for google oauth2 callback."

    }
}

すべてのコードはGithubにも掲載しています。

github.com