はじめに

Amazon Cognito User Pool を用いたWebサービスにおけるユーザー管理・運用の事例にあるように、ユーザー認証や管理には外部の認証基盤を利用することも多くなってきています。

利用する認証基盤には様々な選択肢があり、例えば次に挙げるようなものがあります。

  • 前述の記事で挙げているAmazon Cognito User Pool(以下Cognito)
  • LINEのようなSNSのアカウントを利用するソーシャルログイン
  • Auth0のようなIDaaS(Identity as a Service)

このような外部の認証基盤を利用した場合、認証に成功すると認証基盤側からID(ユーザー識別子)を含むユーザー情報が連携されることになります。

例えばCognitoであれば、ユーザー情報が含まれる「IDトークン」等いくつかのトークンが発行されます。

本ドキュメントでは、REST APIを提供するバックエンドのアプリケーションを対象に、Cognitoが発行する「IDトークン」をREST APIを保護するための認証に利用する実装例をご紹介いたします。

バックエンドのアプリケーションはフレームワークにNablarchを使用しているものを想定しています。ただ、Nablarchに依存している箇所は少ないため、Nablarchを使用していない場合でも広く参考にできると考えています。

認証によるREST APIの保護

今回の実装例は、次のようなアプリケーション構成を想定しています。

  • フロントエンドとしてモバイル向けネイティブアプリやシングルページアプリケーションがある
  • バックエンドとしてREST APIを提供するアプリケーションがある

このようなアプリケーション構成の場合、フロントエンドの環境からバックエンドのREST APIへアクセスできるようにするため、REST APIを公開することになります。そのような場合、悪意のある攻撃者からの不正アクセスを防ぐため、何らかの方法でREST APIへのアクセスを制御し、保護する必要があります。

ここではその方法の1つとして、トークンを用いたアクセス元ユーザーの認証をバックエンドで実装します。

CognitoによるIDトークンの発行

今回の実装例では、ユーザー認証基盤にCognitoを利用する場合を想定しています。

Cognitoでは認証に成功すると、OpenID Connect(以下OIDC)仕様に準拠したIDトークンやアクセストークン、リフレッシュトークンが発行されます。ここからは、その中の「IDトークン」について説明します(ユーザープールのトークンの使用 – Amazon Cognito)。

OIDCはID連携するためのプロトコルであり、基本的な仕様はOpenID Connect Coreで定義されています。IDトークンの仕様についても、この中の「2. ID Token」で定義されています。

IDトークンはJSON Web Token(以下JWT)形式で作成されます。JWTの仕様はRFC7519で定義されており、構造化された情報をURLセーフな文字列で連携するための形式になります。

JWTにはクレームと呼ばれるkey-valueをペアとした情報を含めることができ、IDトークンにはいくつかのクレームが定義されています。例えば次のようなクレームがあります。

クレーム名 説明
iss IDトークン発行元の識別子
sub ユーザーの識別子
aud IDトークン発行先の識別子
exp IDトークンの有効期限

これらのクレームはあくまで標準として定義されているものであり、発行元によってはこれらに加えて独自のクレームが含まれることになります。例えばCognitoでは、トークン用途を表す token_useといった独自のクレームが含まれます。

また、JWTで使用する形式には、JSON Web Signature(以下JWS)とJSON Web Encryption(以下JWE)の2種類があります。JWSはRFC7515、JWEはRFC7516でそれぞれ定義されています。簡単な特徴としては、JWSでは電子署名がされる、JWEでは暗号化されるといった点が挙げられます。これらは組み合わせることも可能であり、IDトークンではJWSによる電子署名は必須となっているため、JWSについては必ず使用されることになります。

これらの仕様により、IDトークンではクレームを参照することでユーザー情報を確認でき、また電子署名によりそれが改竄されたものでないかも確認できます。

IDトークンを用いた認証

CognitoでIDトークンを取得するための認証フローにはいくつかのパターンがあります(ユーザープール認証フロー – Amazon Cognito)。

認証フローについても様々な点を考慮した上で検討する必要がありますが、今回の実装例では認証フローについては扱いません。

Cognitoのユーザー認証に成功して発行されたIDトークンをフロントエンドで保持しており、それを利用する想定とします。

今回ご紹介する認証方式では、まず認証するためのREST APIにIDトークンを連携してもらいます。IDトークンの正当性を検証し、異常が検出されなければ、IDトークンに含まれるユーザー情報を持って認証成功とします。認証成功後はログインセッションを確立し、他のREST APIへのアクセスが可能になるといった方式にしています。

今回の実装例では、認証するためのREST APIで実装するIDトークンの検証処理についてご紹介します。セッションによる認証状態の管理については、今回ご紹介するIDトークンを用いた認証に強く依存するものでは無いため、実装例は省略しております。

IDトークンを扱うためのライブラリ選定

IDトークンはJWT形式ですが、JWTを扱うためのライブラリがOpenID Foundationjwt.ioでリストアップされています。これらの情報を参考に、使用するライブラリを選定します。

今回の実装例では、Auth0が公開しているjava-jwtjwks-rsa-javaを使用します。

実装例

Nablarchではサンプルアプリケーションとしてnablarch-example-webが公開されていますので、今回の実装例ではこれをベースにしています。

事前準備

NablarchにはDIコンテナによりオブジェクトを管理できるシステムリポジトリという仕組みがあります。

今回実装するActionや関連クラスではインジェクションを使用するため、これらのオブジェクトはシステムリポジトリで管理します。

Actionをシステムリポジトリで管理するためには、ルーティングアダプタのDelegateFactoryをSystemRepositoryDelegateFactoryに差し替える必要があります。そのため、これらの設定を事前に行ってください。

なお、複数のルーティングアダプタを定義して異なるDelegateFactoryを使用する場合、それぞれのルーティングアダプタでDelegateFactoryを明示的に指定する必要があります。 今回ベースとするnablarch-example-webでは2種類のルーティングアダプタが定義されているため、ご注意ください。

IDトークンで認証するREST API

まず、IDトークンで認証するためのREST APIを作成するため、IdTokenAuthenticationActionクラスを作成します。

package com.nablarch.example.app.web.action;

import com.auth0.jwt.exceptions.JWTVerificationException;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.nablarch.example.app.web.action.authn.IdTokenVerifier;
import nablarch.core.validation.ee.Required;
import nablarch.core.validation.ee.ValidatorUtil;
import nablarch.fw.web.HttpErrorResponse;
import nablarch.fw.web.HttpResponse;

import javax.ws.rs.Consumes;
import javax.ws.rs.core.MediaType;

/**
 * IDトークン認証アクション。
 */
public class IdTokenAuthenticationAction {

    /** IDトークンの検証 */
    private IdTokenVerifier idTokenVerifier;

    /**
     * IDトークンを用いてユーザーを認証する。
     *
     * 認証が成功したらログインセッションを確立し、他のREST APIにアクセスできるようにする。
     *
     * @param request HTTPリクエスト
     * @param context 実行コンテキスト
     * @param requestBody 入力値
     */
    @Consumes(MediaType.APPLICATION_JSON)
    public void login(HttpRequest request, ExecutionContext context, LoginRequest requestBody) {
        ValidatorUtil.validate(requestBody);

        try {
            DecodedJWT idToken = idTokenVerifier.verify(requestBody.idToken);

            // IDトークンの検証で異常が検出されなければ、IDトークンに含まれるユーザー情報で認証成功とする。
            // ログインセッションを確立し、認証状態を保持する。
            // (実装については省略)

        } catch (JWTVerificationException e) {
            throw new HttpErrorResponse(HttpResponse.Status.BAD_REQUEST.getStatusCode(), e);
        }
    }

    public void setIdTokenVerifier(IdTokenVerifier idTokenVerifier) {
        this.idTokenVerifier = idTokenVerifier;
    }

    /**
     * 認証リクエスト。
     */
    public static class LoginRequest {

        /** IDトークン */
        @Required
        public String idToken;
    }
}

ここでは、認証するためのREST APIで実行する処理を実装しています。IDトークンの具体的な検証処理については、別のクラスで実装しています。

IDトークンの検証処理で異常が検出されなければ、IDトークンに含まれるユーザー情報をもって認証成功とし、ログインセッションを確立します。前述のとおり、これらの実装については省略しています。

IDトークンの検証処理で異常を検出した場合は、不正なリクエストとしてHTTステータスが400(Bad Request)のエラーレスポンスを返却するようにしています。Nablarchでは HttpErrorResponse例外クラスを送出すると、ハンドリングしてエラーのHTTPレスポンスに変換してくれます。

REST APIのルーティング設定

NablarchではルーティングアダプタによりリクエストとActionのマッピングを定義しています。

先ほど作成したActionをREST APIで呼び出せるようにするため、routes.xmlに次の設定を追加します。

<post path="/api/login" to="IdTokenAuthentication#login"/>

IDトークンの検証処理

IDトークンの検証処理を実装するため IdTokenVerifier クラスを作成します。

package com.nablarch.example.app.web.action.authn;

import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.exceptions.JWTVerificationException;

/**
 * IDトークンの検証。
 */
public class IdTokenVerifier {

    /** IDトークンの電子署名のアルゴリズム情報 */
    private IdTokenSignatureAlgorithmProvider signatureAlgorithmProvider;

    /** ユーザープールのURL */
    private String userPoolUrl;

    /** ユーザープールのクライアントID */
    private String userPoolClientId;

    /**
     * 指定されたIDトークンの正当性を検証します。
     *
     * 検証で異常が検出されなければ、デコードしたIDトークンを返却します。
     *
     * @param token IDトークン
     * @return デコードしたIDトークン
     * @throws JWTVerificationException 検証で異常が検出された場合
     */
    public DecodedJWT verify(String token) throws JWTVerificationException {
        JWTVerifier verifier = JWT.require(signatureAlgorithmProvider.get())
                .withAudience(userPoolClientId)
                .withIssuer(userPoolUrl)
                .withClaim("token_use", "id")
                .build();
        return verifier.verify(token);
    }

    public void setSignatureAlgorithmProvider(IdTokenSignatureAlgorithmProvider signatureAlgorithmProvider) {
        this.signatureAlgorithmProvider = signatureAlgorithmProvider;
    }

    public void setUserPoolUrl(String userPoolUrl) {
        this.userPoolUrl = userPoolUrl;
    }

    public void setUserPoolClientId(String userPoolClientId) {
        this.userPoolClientId = userPoolClientId;
    }
}

Cognitoで発行されたIDトークンを検証する方法についてはデベロッパーガイドに記載されていますので、これに沿って検証していきます。

JWTの構造に問題があればライブラリがデコード時に検出してくれますので、ここでは電子署名とクレームの検証を実装しています。

クレームの検証については次の内容になります。

  • 有効期限が切れていないか(これはライブラリ内で現在時刻からチェックしてくれています)
  • audクレームがユーザープールのクライアントIDであるか
  • issクレームがユーザープールのURLであるか
  • token_useクレームがidであるか

電子署名の検証については、JWTのヘッダにあるalgクレームから使用しているアルゴリズムが取得してしまうと、値をnoneに改竄して電子署名の検証を回避するといった攻撃に対応できなくなります。Cognitoで発行されたIDトークンの電子署名はSHA-256を使用したRSA署名ですので、前述の攻撃に対応するため固定で指定します。

RSA署名を検証するための公開鍵については、CognitoがJWK Setで公開していますのでそれを使用します。RFC7517で定義されているJSON形式で鍵を表現するJSON Web Key(JWK)という仕様があり、JWK SetはそのJWKのセットを表す仕様になります。

電子署名のアルゴリズムに関する処理は、いくつかのクラスに分けて作成します。

package com.nablarch.example.app.web.action.authn;

import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.interfaces.RSAKeyProvider;

/**
 * IDトークンの電子署名のアルゴリズム情報。
 */
public class IdTokenSignatureAlgorithmProvider {

    /** RSA署名の鍵情報 */
    private RSAKeyProvider rsaKeyProvider;

    /**
     * 電子署名のアルゴリズムを取得します。
     *
     * @return アルゴリズム
     */
    public Algorithm get() {
        return Algorithm.RSA256(rsaKeyProvider);
    }

    public void setRsaKeyProvider(RSAKeyProvider rsaKeyProvider) {
        this.rsaKeyProvider = rsaKeyProvider;
    }
}
package com.nablarch.example.app.web.action.authn;

import com.auth0.jwk.Jwk;
import com.auth0.jwk.JwkException;
import com.auth0.jwk.JwkProvider;
import com.auth0.jwk.JwkProviderBuilder;
import com.auth0.jwt.interfaces.RSAKeyProvider;
import nablarch.core.log.Logger;
import nablarch.core.log.LoggerManager;

import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;

/**
 * IDトークンのRSA署名の鍵情報。
 */
public class IdTokenRSAKeyProvider implements RSAKeyProvider {

    private static final Logger LOGGER = LoggerManager.get(IdTokenRSAKeyProvider.class);

    /** JWKセット情報 */
    private JwkProvider jwkProvider;

    /**
     * コンストラクタ。
     *
     * @param userPoolUrl ユーザープールのURL
     */
    public IdTokenRSAKeyProvider(String userPoolUrl) {
        // キャッシュやレートリミット、ブロキシサーバの設定も可能
        jwkProvider = new JwkProviderBuilder(userPoolUrl).build();
    }

    @Override
    public RSAPublicKey getPublicKeyById(String keyId) {
        try {
            Jwk jwk = jwkProvider.get(keyId);
            return (RSAPublicKey) jwk.getPublicKey();
        } catch (JwkException e) {
            if (LOGGER.isDebugEnabled()) {
                LOGGER.logDebug("Invalid public key.", e);
            }
            return null;
        }
    }

    @Override
    public RSAPrivateKey getPrivateKey() {
        return null;
    }

    @Override
    public String getPrivateKeyId() {
        return null;
    }
}
package com.nablarch.example.app.web.action.authn;

import nablarch.core.repository.di.ComponentFactory;

/**
 * {@link IdTokenRSAKeyProvider}のFactory。
 */
public class IdTokenRSAKeyProviderFactory implements ComponentFactory<IdTokenRSAKeyProvider> {

    /** ユーザープールのURL */
    private String userPoolUrl;

    @Override
    public IdTokenRSAKeyProvider createObject() {
        return new IdTokenRSAKeyProvider(userPoolUrl);
    }

    public void setUserPoolUrl(String userPoolUrl) {
        this.userPoolUrl = userPoolUrl;
    }
}

プロパティの設定

env.properties に、各クラスで使用する設定値を定義します。ここに定義している値は実行時にシステムプロパティや環境変数で上書きできるため、環境に依存するものをここで定義しています。

aws.cognito.userPool.url=https://cognito-idp.<リージョン>.amazonaws.com/<ユーザープールID>
aws.cognito.userPool.clientId=<ユーザープールのクライアントID>

システムリポジトリの設定

作成したクラス群はシステムリポジトリへ登録するため、web-component-configuration.xmlファイルにコンポーネントとして定義します。先ほどenv.propertiesに定義して設定値は、ここで必要なクラスへインジェクションするようにします。

<!-- IDトークン認証アクション -->
<component name="com.nablarch.example.app.web.action.IdTokenAuthenticationAction"
           class="com.nablarch.example.app.web.action.IdTokenAuthenticationAction">
  <property name="idTokenVerifier" ref="IdTokenVerifier"/>
</component>
<!-- IDトークン検証 -->
<component name="IdTokenVerifier" class="com.nablarch.example.app.web.action.authn.IdTokenVerifier">
  <property name="signatureAlgorithmProvider" ref="signatureAlgorithmProvider"/>
  <property name="userPoolUrl" value="${aws.cognito.userPool.url}"/>
  <property name="userPoolClientId" value="${aws.cognito.userPool.clientId}"/>
</component>
<!-- IDトークンの電子署名アルゴリズム情報 -->
<component name="signatureAlgorithmProvider" class="com.nablarch.example.app.web.action.authn.IdTokenSignatureAlgorithmProvider">
  <property name="rsaKeyProvider" ref="rsaKeyProvider"/>
</component>
<!-- IDトークンのRSA署名の鍵情報 -->
<component name="rsaKeyProvider" class="com.nablarch.example.app.web.action.authn.IdTokenRSAKeyProviderFactory" >
  <property name="userPoolUrl" value="${aws.cognito.userPool.url}"/>
</component>

これらの実装により、リクエスト処理時にIDトークン検証ハンドラが実行され、異常がなければスレッドコンテキストに保存されます。

必要に応じて考慮が必要なポイント

今回の実装では取り扱っていませんが、案件によっては考慮が必要となりそうな点についてご紹介します。

ゲストユーザーへのREST API公開

例えばユーザー認証を必要としないゲストユーザーでも利用できるREST APIがある場合、ユーザー認証の結果であるIDトークンが無いため、今回のようなIDトークンを用いた認証では保護できません。

そのような場合は、例えば権限に応じたアクセストークンを発行する等、今回ご紹介した認証ではなく認可をベースとした方法を検討する必要があります。

IDトークン漏洩時のリスク

IDトークンのクレームにはユーザー情報が含まれているため、もしIDトークンが漏洩した場合にはユーザー情報も漏洩するというセキュリティリスクがあります。IDトークンがJWEを使用したJWTでなければ暗号化されていないため、文字列をデコードするだけでクレームの値(例えばemailクレームのメールアドレス)を容易に確認できます。

今回の実装例ではフロントエンドで取得したIDトークンを使用する方式としていますが、パブリックなクライアントであるフロントエンドで取り扱う都合上、漏洩等のセキュリティリスクは少なからずあります。よりセキュリティリスクを減らしたい場合は、フロントエンドではなくバックエンドでIDトークンを取得する等、他の方法を検討する必要があります。

リプレイアタックへの対策

もしIDトークンが漏洩した場合、IDトークンを再利用して不正アクセスを行うリプレイアタックによるセキュリティリスクがあります。悪意のある攻撃者が有効なIDトークンを再利用した場合、内容が改竄されているわけではないため電子署名による検証では異常が検出されず、IDトークンに含まれるユーザー情報でログインすることが可能になります。

リプレイアタックへの対策には、JWTを一意に識別できるjtiクレームやセッションと紐づけるためのnonceクレーム等のクレームを利用できます。バックエンドでこれらのクレームを検証する等、リプレイアタックに対策するための方法について検討する必要があります。

ステートレスな認証方式

今回の実装では認証状態をセッションで管理していますが、管理せずにREST APIへアクセスする際には毎回トークンを要求するようなステートレスな認証方式もあります。

このようなステートレスな認証方式では認証状態をセッションで管理する必要がなくなるため、例えば次のようなメリットを享受できます。

  • 水平スケールが容易になりスケーラビリティの向上に繋がる
  • セッション管理運用に関わるインフラ面のコスト(ストレージ、サーバ等)削減に繋がる

ただし、トレードオフとして例えば次のようなセキュリティリスクを許容する必要も出てきます。

  • もしトークンが漏洩した場合でも、即座にトークンをバックエンドで使えなくするといったことはできない
  • トークンを長期間に渡って利用するため、トークンおよびそれに含まれるユーザー情報の漏洩リスクが高まる

トレードオフを理解した上でメリットを重視した場合などには、ステートレスな認証方式も検討する必要があります。

外部サービスを利用した実現案

もしバックエンドのアプリケーションをAWS環境で稼働させるのであれば、Amazon API Gatewayを併用することで今回ご紹介した実装と同等のアクセス制御を実現することもできます。

独自実装を避けたい場合などには、このような選択肢で実現することも可能になります。

おわりに

今回は、IDトークンを用いたREST APIでの認証例を紹介しました。

この事例が、今後REST APIの開発を行うプロジェクトにとって、少しでも参考になれば幸いです。


本コンテンツはクリエイティブコモンズ(Creative Commons) 4.0 の「表示—継承」に準拠しています。