虎の穴開発室ブログ

虎の穴ラボ株式会社所属のエンジニアが書く技術ブログです

MENU

Kotlin+KtorでAmazon Cognitoのユーザーを認証する

みなさん、メリークリスマスイブ!特に予定はない虎の穴ラボの鷺山です。

この記事は、虎の穴ラボ Advent Calendar 2021の24日目の記事です。
23日目はSKさんの「インフラ担当はフルリモートの夢をみれるのか」が投稿されました。
最終日となる25日目は、虎の穴ラボCTO野田による記事が投稿されます。ぜひこちらもご覧ください。

はじめに

最近、Kotlin+Ktor製の社内システムにユーザー認証の仕組みを取り入れる必要があり、AWSのマネージドサービスであるAmazon Cognitoを使ったユーザー認証の仕組みを構築しました。
リファレンスが少なかっため当初は少し試行錯誤が必要でしたが、一度構築してみると設定や実装自体は難しくないことが分かりました。
そこで今回は、Kotlin+KtorでAmazon Cognitoのユーザーを認証する仕組みの構築手順をご紹介したいと思います。

目次

実行環境

  • Kotlin: 1.6.10
  • Ktor: 1.6.7
  • AWS CLI: 2.4.6
  • curl: 7.64.1
  • macOS Catalina

Amazon Cognitoのユーザープールの作成

まず、AWSマネジメントコンソールAmazon Cognitoの画面に移動し、「ユーザープール」から「ユーザープールを作成」を行います。

f:id:toranoana-lab:20211221112538p:plain

ウィザードに従ってユーザープールの設定を行っていきます。
以下の説明では、デフォルト値から変更した点のみをピックアップしてご紹介します。
なお、中には説明を簡単にする目的で設定している項目も含まれていますので、実際のシステムで使用する際はその要件に適した設定でユーザープールを作成してください。

「Cognito ユーザープールのサインインオプション」に「E メール」を選択します。 f:id:toranoana-lab:20211221113305p:plain

「多要素認証」に「MFA なし」を選択します。 f:id:toranoana-lab:20211221113701p:plain

「E メールプロバイダー」に「Cognito で E メールを送信」を選択します。 f:id:toranoana-lab:20211221114002p:plain

「ユーザープール名」を指定します。この例ではexample-user-poolとしました。 f:id:toranoana-lab:20211221114354p:plain

「アプリケーションタイプ」に「パブリッククライアント」を選択し、「アプリケーションクライアント名」を指定します。この例ではexample-app-clientとしました。 f:id:toranoana-lab:20211221114548p:plain

「高度なアプリケーションクライアントの設定」を開き、「認証フロー」に「ALLOW_ADMIN_USER_PASSWORD_AUTH」を選択します。これは後述するAWS CLIでユーザープールにログインする際に必要になります。 f:id:toranoana-lab:20211221142651p:plain

以上の設定で「ユーザープールの作成」を行います。 f:id:toranoana-lab:20211221151552p:plain

ユーザープールの作成が完了したら、ユーザープールのIDとアプリクライアントのIDを控えておきます。
ユーザープールのIDは、ユーザープールの詳細画面で確認できます。作成先が東京リージョンの場合はap-northeast-1_XXXXXXXXXのような形式になります。 f:id:toranoana-lab:20211221144043p:plain

アプリクライアントのIDは、ユーザープールの詳細画面から「アプリケーションの統合」タブを開き、その中の「アプリクライアント」で確認できます。26桁程度のランダムな英数字の形式です。
f:id:toranoana-lab:20211221145422p:plain f:id:toranoana-lab:20211221144854p:plain

本エントリーでは、ユーザープールのIDはap-northeast-1_XXXXXXXXX、アプリクライアントのIDはapplicationClientId0123456を例に以降の説明をします。

ユーザープールへのユーザーの追加

上記で作成したユーザープールにユーザーを追加します。

ユーザープールの詳細画面の「ユーザー」タブから「ユーザーを作成」することができます。 f:id:toranoana-lab:20211221145639p:plain

作成するユーザーの情報を入力します。
本エントリーでは説明のため、架空のメールアドレスuser01@example.comでユーザーを作成しています。仮パスワードはP@ssword1としました。 f:id:toranoana-lab:20211221150712p:plain
入力が完了したら、「ユーザーを作成」をクリックしてユーザーを作成します。

AWS CLIを使ったユーザーのログイン

上記で作成したユーザーでユーザープールにログインします。

実際のシステムであればログイン画面などを介してログインを行いますが、本エントリーでは確認が簡単なAWS Command Line Interface (AWS CLI)でログインを行います。
AWS CLIでAmazon Cognitoのユーザープールにログインするには、以下のようなaws cognito-idp admin-initiate-authコマンドを実行します。

$ aws cognito-idp admin-initiate-auth \
  --region ap-northeast-1 \
  --user-pool-id ap-northeast-1_XXXXXXXXX \
  --client-id applicationClientId0123456 \
  --auth-flow ADMIN_NO_SRP_AUTH \
  --auth-parameters USERNAME=user01@example.com,PASSWORD=P@ssword1
  • --regionには、対象のリージョンを指定します。
  • --user-pool-idには、対象のユーザープールのIDを指定します。
  • --client-idには、対象のアプリクライアントのIDを指定します。
  • --auth-flowには、ADMIN_NO_SRP_AUTHを指定します。
  • --auth-parametersには、ユーザーIDとパスワードをUSERNAME=<ユーザーID>,PASSWORD=<パスワード>の形式で指定します。

初めて対象のユーザーにログインした際は、以下のような応答が返ってきます。

{
    "ChallengeName": "NEW_PASSWORD_REQUIRED",
    "Session": "AYABe...",
    "ChallengeParameters": {
        "USER_ID_FOR_SRP": "651ba529-92c2-4e77-b9aa-d2584d358c4f",
        "requiredAttributes": "[]",
        "userAttributes": "{\"email\":\"user01@example.com\"}"
    }
}

パスワードの変更が求められているので、それに応じるためにaws cognito-idp admin-respond-to-auth-challengeコマンドでパスワードを変更します。

$ aws cognito-idp admin-respond-to-auth-challenge \
  --region ap-northeast-1 \
  --user-pool-id ap-northeast-1_XXXXXXXXX \
  --client-id applicationClientId0123456 \
  --challenge-name NEW_PASSWORD_REQUIRED \
  --challenge-response USERNAME=user01@example.com,NEW_PASSWORD=P@ssword2 \
  --session "AYABe..."
  • --region, --user-pool-id, --client-id には、上記のadmin-initiate-authコマンドと同様のパラメータを指定します。
  • --challenge-nameには、NEW_PASSWORD_REQUIREDを指定します。
  • --challenge-responseには、ユーザーIDと新しいパスワードをUSERNAME=<ユーザーID>,PASSWORD=<新しいパスワード>の形式で指定します。
  • --sessionには、上記のadmin-initiate-authコマンドで返ってきた"Session"の値をそのまま指定します。

ログイン及びパスワードの変更に成功すると、以下のような応答が返ってきます。ユーザープールから各種トークンが取得できていることが確認できます(※)。

{
    "ChallengeParameters": {},
    "AuthenticationResult": {
        "AccessToken": "eyJ...",
        "ExpiresIn": 86400,
        "TokenType": "Bearer",
        "RefreshToken": "eyJ...",
        "IdToken": "eyJ..."
    }
}

※ 実際のAccessToken, RefreshToken, IdTokenにはJWT (JSON Web トークン) 形式の文字列が入りますが、ここでは表記をeyJ...のように省略しています。

Amazon Cognitoのトークンを検証するKtorサーバーの実装

上記のAmazon Cognitoのトークンを検証するKtorサーバーを実装します。

ここでは、以下のような単純な応答を返すサーバーを構築します:

  • トークン検証の成功時には、トークンの中に含まれているEメールアドレスを返す。
  • トークン検証の失敗時には、HTTP 401 Unauthorizedを返す。

本エントリーのソースコードは、Ktor Project Generatorで以下の設定で生成した構成をベースにしています。

Project Namektor-cognito-auth
Build SystemGradle Kotlin
Websiteexample.com
Ktor version1.6.7
EngineNetty
Configuration inHOCON file
Plugins"Authentication JWT" を選択


まず、ビルド構成ファイルbuild.gradle.ktsio.ktor:ktor-authio.ktor:ktor-auth-jwtのライブラリの依存がない場合は追加します:

  • build.gradle.kts
dependencies {
    ...
    implementation("io.ktor:ktor-auth:$ktor_version")
    implementation("io.ktor:ktor-auth-jwt:$ktor_version")
    ...
}

次に、リソースファイルsrc\main\resources\application.confjwtというブロックを作り、その中にAmazon Cognitoで作成したユーザープールのURL、およびアプリクライアントのIDをそれぞれissuer, audienceというキーで以下のように追加します:

  • application.conf
jwt {
    issuer = "https://cognito-idp.ap-northeast-1.amazonaws.com/ap-northeast-1_XXXXXXXXX"
    audience = "applicationClientId0123456"
}

最後に、アプリケーションを記述します。
今回は分かりやすさのために一つのファイルApplication.ktにすべてを記述していますが、必要に応じてファイルを分割しても構いません。

  • Application.kt
package com.example

import com.auth0.jwk.JwkProviderBuilder
import io.ktor.application.*
import io.ktor.auth.*
import io.ktor.auth.jwt.*
import io.ktor.response.*
import io.ktor.routing.*

fun main(args: Array<String>): Unit = io.ktor.server.netty.EngineMain.main(args)

// ログインユーザーを表現するPrincipal
data class LoginUserPrincipal(val email: String) : Principal

fun Application.module() {
    // 認証設定
    authentication {
        jwt {
            val audience = environment.config.property("jwt.audience").getString()
            val issuer = environment.config.property("jwt.issuer").getString()
            val jwkProvider = JwkProviderBuilder(issuer).build()

            verifier(jwkProvider, issuer)
            validate { credential ->
                if (credential.payload.audience.contains(audience)) {
                    val email = credential.payload.getClaim("email").asString()
                    LoginUserPrincipal(email)
                } else {
                    null
                }
            }
        }
    }

    // ルーティング設定
    routing {
        authenticate {
            get("/hello") {
                val principal = call.authentication.principal<LoginUserPrincipal>()
                call.respond("Hello, ${principal?.email}")
            }
        }
    }
}

上記のコードを詳しく解説します。

以下の記述で、ログインユーザーを表現するPrincipal (認証主体) であるLoginUserPrincipalのデータクラスを宣言しています。このクラスはプロパティとしてEメールアドレスemailを持ちます。

// ログインユーザーを表現するPrincipal
data class LoginUserPrincipal(val email: String) : Principal

以下の記述で、JWTを検証するVerifierを構築しています。検証先のユーザープールのURL (Issuer) やアプリクライアントのID (Audience) を、設定ファイルapplication.confから読み出して設定しています。トークンの有効期限や署名は、このVerifierがチェックしてくれます。

    authentication {
        jwt {
            val audience = environment.config.property("jwt.audience").getString()
            val issuer = environment.config.property("jwt.issuer").getString()
            val jwkProvider = JwkProviderBuilder(issuer).build()

            verifier(jwkProvider, issuer)
            ...
        }
    }

以下の記述で、アプリクライアントのID (Audience) のチェックを行っています。OKだった場合は検証成功とみなし、前述のログインユーザーを表現するLoginUserPrincipalオブジェクトを生成して返します。このとき、トークンの中のemailクレームからEメールアドレスを取り出してLoginUserPrincipalのプロパティに設定しています。チェックがNGだった場合は検証失敗とみなしnullを返します。

            validate { credential ->
                if (credential.payload.audience.contains(audience)) {
                    val email = credential.payload.getClaim("email").asString()
                    LoginUserPrincipal(email)
                } else {
                    null
                }
            }

以下の記述でルーティングを設定しています。
/helloルートをauthenticateブロックで囲むことで、/helloルートへのアクセスには認証が必要であることを表現できます。
/helloルートの中では、トークン検証時に生成したLoginUserPrincipalを取得し、応答のメッセージにEメールアドレスを含めています。

    routing {
        authenticate {
            get("/hello") {
                val principal = call.authentication.principal<LoginUserPrincipal>()
                call.respond("Hello, ${principal?.email}")
            }
        }
    }

上記で実装したサーバーを起動するには、プロジェクトのルートディレクトリで以下のコマンドを実行します (Ktor Project GeneratorでBuild SystemにGradle GroovyかGradle Kotlinを選択している場合):

$ ./gradlew run

動作確認

curlを使って、上記で実装したサーバーの動作確認をします。
事前に「AWS CLIを使ったユーザーのログイン」の章を参考にして、Amazon Cognitoのトークンを取得しておいてください。

有効なトークンでアクセスが成功することを確認する

有効なトークンで/helloルートへのアクセスが成功することを確認します。
Amazon Cognitoのログインレスポンス中のIDトークン (IdToken) の値 (eyJ...) をコピーし、BearerスキームでAuthorizationヘッダに指定します。

$ curl --header "Authorization: Bearer eyJ..." http://localhost:8080/hello

トークンが有効な場合は、以下のようにCognitoでログインしたユーザーのEメールアドレスを含む応答が返ります。

Hello, user01@example.com

無効なトークンではアクセスが失敗することを確認する

無効なトークンでは/helloルートへのアクセスが失敗することを確認します。
Auhorizationヘッダにトークンを含めなかったり、期限切れのトークンを指定した場合は、HTTP 401 Unauthorizedが返ります。

$ curl --verbose http://localhost:8080/hello
...
< HTTP/1.1 401 Unauthorized
...

まとめ

今回は、Kotlin+KtorでAmazon Cognitoのユーザーを認証する仕組み (正確には「Kotlin+KtorでAmazon Cognitoのトークンを検証する仕組み」でしたが) の構築手順についてご紹介しました。
Amazon Cognitoを使うことで、ユーザーの管理や認証などの処理を自前のシステムで作り込むことなく、AWSに任せることができるようになります。
Ktorの場合はJWTを扱うプラグインがKtor Project Generatorにより提供されているので、より簡単に導入することができると思います。

P.S.

■ 採用情報
とらのあなでは、オタクなエンジニアを募集しています。

カジュアル面談も随時開催中です。お申し込みはこちら!

■ ToraLab.fmスタートしました!
メンバーによるPodcastを配信中!是非スキマ時間に聞いて頂けると嬉しいです。
anchor.fm

■ Twitterもフォローしてくださいね!
Twitterでも随時情報発信をしています。