JavaでのAuthlete API入門

Table of Contents

はじめに

この短いチュートリアルでは、Java ウェブアプリケーションを Authlete API と統合する手順を説明します。

シナリオ

AuthleticGearという実店舗を持つ小売業者は、顧客向けにロイヤルティプログラムを運営しています。プログラムのメンバーは、ロイヤルティプログラムのウェブサイトにログインして、ポイント残高や取引を確認したり、リンクされた銀行口座への現金振込にポイントを利用したりできます。ロイヤルティプログラムのウェブサイトは、Eclipse Jerseyを介してRESTful API を提供するJavaウェブアプリケーションバックエンドと、HTML5/JavaScriptフロントエンドで構成されています。


Loyalty Program Web Application

同社は近日中にeコマースウェブサイトを立ち上げる予定です。このPoCの要件は、既存のロイヤルティプログラムメンバーが自分のロイヤルティアカウントをeコマースサイトにリンクし、eコマースのフロントページでポイント残高を表示できるようにすることです。将来的には、顧客がロイヤルティポイントを購入に利用できる本番システムを導入する予定です。


eCommerce Web Application

この統合は「ファーストパーティ」統合であることに注意してください。ロイヤルティプログラムとeコマースサイトはどちらも同じ会社が運営しています。このようなケースでは、クライアントがサービスでユーザーデータにアクセスできるかどうかを明示的に尋ねるステップを省略する場合があります。しかし、たとえロイヤルティプログラムのウェブサイトに既にアクティブセッションがあったとしても、アカウントをリンクする際には、ユーザーが本当にアカウントをリンクしたいことを確認するためにログインを要求します。

eコマースチームは既にOAuth 2.0 クライアントを実装済みです。あなたの仕事は、ロイヤルティプログラムチームの開発者として、ロイヤルティプログラムのウェブアプリケーションにOAuth 2.0 の認可サーバーとリソースサーバーの役割を実装することです。

あなたはOAuth 2.0の基本を理解しており、顧客のブラウザ(以下の図での「リソースオーナーの『ユーザーエージェント』」)、eコマースウェブサイト(「クライアント」)、およびロイヤルティプログラムのウェブサイト(「認可サーバー」と「リソースサーバー」)の間でどのようなやり取りが行われるかも把握しています。


OAuth Flow

  1. 認可の開始
    • 顧客がeコマースサイトで「Link Account」をクリックします。
    • eコマースのOAuthクライアントが、クライアントIDとリダイレクトURLをクエリパラメータとして含む形で、顧客のブラウザをロイヤルティプログラムの認可サーバーにリダイレクトします。
    • ロイヤルティプログラムの認可サーバーがログインフォームを表示します。
    • 顧客が通常の方法でロイヤルティプログラムのウェブサイトにログインします。
  2. クライアントへの認可コードの発行
    • 認可サーバーが顧客のブラウザをeコマースサイトにリダイレクトし、認可コードを渡します。
  3. クライアントのアクセストークン要求の処理
    • eコマースのOAuthクライアントが認可コード、クライアントID、およびクライアントシークレットを認可サーバーに送信します。
    • 認可サーバーがアクセストークンをクライアントに返します。
  4. クライアントからのロイヤルティプログラムAPIへのリクエストの検証
    • eコマースウェブサイトは、顧客のポイント残高を要求するAPIリクエストにアクセストークンを含めます。
    • ロイヤルティプログラムのリソースサーバーはアクセストークンを検証し、そのリクエストに顧客の識別子を添付して、ロイヤルティプログラムAPIに処理を移します。

注意: OAuthの仕様および上記の図では、ユーザーを認証および認可する役割を担う認可サーバーと、APIリクエストを処理する役割を担うリソースサーバーを区別しています。仕様ではこれらを2つの別々の役割として明示していますが、単一のアプリケーションが両方の役割を果たすことも可能であり、このチュートリアルでのロイヤルティプログラムのウェブアプリケーションはこの例に該当します。

大変な仕事のように見えるかもしれませんが、Authleteがあれば、これを1〜2時間で完了できます!

前提条件

必要なもの:

  • Docker Desktop
  • 任意のコードエディタ
  • Java EE の基礎知識

セットアップ

デモシステムは、eコマースウェブサイト用とロイヤルティサイト用の2つのJava EEウェブアプリケーションをそれぞれ格納するDockerコンテナのペアとして実装されています。

Dockerネットワークの作成

上記のフローのステップ3では、eコマースOAuthクライアントがロイヤルティ認可サーバーに直接リクエストを送信します。このため、eコマースコンテナがロイヤルティコンテナのIPアドレスを解決できるように、Dockerネットワークを作成する必要があります。以下のコマンドを実行してください:

docker network create --driver bridge authlete-net

Start the E-Commerce Container

Start the e-commerce container with

docker run -d \
  --name authlete-ecommerce \
  --network authlete-net \
  --publish 8080:8080 \
  us-docker.pkg.dev/authlete/demo/java-getting-started-ecommerce:latest

環境に合わせてdocker runの引数を変更できます:

  • Tomcatはコンテナ内でポート8080でリッスンするように設定されています。上記の例では、Dockerがホストのポート8080にそのポートを公開しています。マシン上でポート8080がすでに使用されている場合は、--publish 12345:8080を指定して別のホストポートを選択できます。その場合、チュートリアル全体で8080を選択したポートに変更する必要があります。

以下のコマンドでコンテナのログを表示することで、コンテナが正しく起動し、Tomcatが準備完了状態であることを確認できます:

docker logs authlete-ecommerce

以下のような2行のログが表示されます:

19-Mar-2022 22:24:15.466 INFO [main] org.apache.coyote.AbstractProtocol.start Starting ProtocolHandler ["http-nio-8080"]
19-Mar-2022 22:24:15.472 INFO [main] org.apache.catalina.startup.Catalina.start Server startup in [3463] milliseconds

エラーが起きていないにも関わらず、出力の最後にこれらのログが表示されない場合は、Tomcatがまだ起動中です。数秒待ってから再度確認してください。

必要に応じて、以下のコマンドでDockerコンテナを一時停止できます:

docker pause authlete-ecommerce

チュートリアルを再開する準備ができたら、以下のコマンドで再開します:

docker resume authlete-ecommerce

ロイヤルティコンテナの起動

ロイヤルティコンテナは、Docker bind mountを使用してローカルディレクトリにソースコードを公開します。これにより、任意のソースコードエディターやIDEでホストマシン(Dockerホスト)のコードを編集できます。

ソースディレクトリは、ローカルマシン上の任意の場所に作成できます。このチュートリアルでは、このディレクトリを$SOURCE_ROOTと呼びます。

ソースディレクトリはDockerコンテナを起動する前に存在している必要があり、docker runでコンテナを起動する際に--mountオプションで参照する必要があります。

例えば、/Users/jdoe/authlete_srcをソースディレクトリとして使用する場合:

mkdir /Users/jdoe/authlete_src
docker run -d \
  --name authlete-loyalty \
  --network authlete-net \
  --publish 8081:8080 \
  --mount type=bind,source="/Users/jdoe/authlete_src",target=/mount \
  us-docker.pkg.dev/authlete/demo/java-getting-started-loyalty:latest

コンテナの起動後、ls /Users/jdoe/authlete_srcコマンドでソースディレクトリを見ると、loyaltyサブディレクトリが含まれていることが確認できます。このディレクトリにはロイヤルティウェブアプリケーションのソースコードが含まれています。このソースディレクトリにはgitリポジトリも含まれているため、チュートリアルの各ステップ後に必要に応じてコードをチェックアウトすることが簡単にできます。

ロイヤルティコンテナは、eコマースコンテナとは異なるホストポートでリッスンする必要があります。このチュートリアルでは、eコマースコンテナはポート8080で、ロイヤルティコンテナはポート8081でリッスンします。

前セクションに記載されているように、docker runの引数を変更すると、ホストポート番号を変更することができます。

eコマースコンテナと同様のコマンドを使用して、コンテナログの確認、コンテナの一時停止、および再開を行います。その際は、eコマースインスタンス名の代わりにロイヤルティインスタンス名であるauthlete-loyaltyを使用することを忘れないでください。

サンプルウェブサイトの確認

ロイヤルティプログラムウェブサイト

http://localhost:8081/loyalty/にアクセスします。ロイヤルティプログラムのホームページが表示され、プレースホルダーテキストとログインリンクが表示されます。リンクをクリックして、ページに表示されているクレデンシャルのセットのいずれか1つを使ってログインします。すると、アカウントの概要が表示され、取引リストが表示されます。ロイヤルティプログラムウェブアプリケーションは、アプリケーションが起動するたびにサンプル取引が読み込まれるインメモリデータベースを使用しています。「Redeem Points」をクリックして、リンクされた銀行口座に対して現金を振り込むためにロイヤルティポイントを交換するシミュレーションを行うことができます。この機能を使用すると、システムが稼働している間にアカウント残高を簡単に変更できるため、残高が動的に取得されていることを確認できます。


Loyalty Web Application

取れる唯一の他のアクションはログアウトで、これによりホームページに戻ります。

eコマースウェブサイト

ブラウザでhttp://localhost:8080/ecommerce/を開きます。このページは、典型的なeコマースウェブサイトのシンプルなモックアップです。機能している唯一の機能は、「Link my Loyalty account」リンクです。リンクをクリックすると、ロイヤルティサイトにログインしていない場合は、ログインするためにロイヤルティサイトに誘導されます。すでにログインしている場合、またはログイン後は、http://localhost:8081/loyalty/oauth/authorizationで404エラーが表示されます。これは、ロイヤルティプログラムがまだOAuth 2.0をサポートしていないためです。


404 error

このチュートリアルには、ロイヤルティプログラムウェブアプリケーションをOAuth 2.0対応にするために必要なすべてが含まれています。ロイヤルティおよびeコマースウェブアプリケーションのソースコードはhttps://github.com/authlete/java-getting-startedで自由に確認できます。どちらもJava JDK 11でApache Tomcat 9.0.x向けに書かれています。アプリケーションには以下の技術が使用されています:

  • Eclipse Jersey Java RESTフレームワーク、バージョン2.34
  • Hibernate Java Persistence API (JPA) の実装、バージョン5.6.4.Final
  • H2 インメモリデータベースエンジン、バージョン2.1.210
  • Apache Log4j ロギングフレームワーク、バージョン2.17.1
  • Java Server Pages (JSP) をeコマースウェブサイトで使用
  • HTML5、JavaScript、CSSをロイヤルティウェブサイトで使用

eコマースチームはOAuthクライアントを実装し、ロイヤルティ認可サーバーのURLについても合意していますが、まだロイヤルティ認可サーバーの実装は存在していません。それを修正しましょう!

Authleteアカウントの作成とクライアントアプリケーションの設定

Authleteアカウントにサインアップし、サービスオーナーコンソールに遷移します。すると、初期作成されたサービスの一覧が表示されます。


Authlete service list

サービスをクリックして詳細を確認します。


Authlete service details

ロイヤルティプログラムは、Authleteの観点からは「サービス」と見なされ、ここでそのAuthlete設定を管理します。

APIキーAPIシークレットをメモしておいてください。次のステップで必要になります。

下にスクロールすると、クライアントアプリ開発者コンソールへのリンクが表示されます。


Authlete service details

リンクをクリックし、APIキーをログインID、APIシークレットをパスワードとして使用してログインします。

デフォルトのクライアントアプリケーションが表示されない場合は、アプリ作成をクリックしてアプリケーションを作成します。クライアントアプリケーションは、eコマースサイトのOAuth 2.0設定に該当します。


Authlete application list

アプリケーションをクリックして詳細を確認します。


Authlete application details

クライアントIDクライアントシークレットをメモしておいてください。これも後で必要になります。

次に、このシナリオに合わせてクライアントの初期設定をいくつか変更します。

ページの下部にスクロールし、編集をクリックします。

eコマースアプリケーションは、クレデンシャルの機密性を維持できるWebアプリケーションです(詳細はClient Typesを参照)。クレデンシャルはサーバーで安全に保存されるため、アプリケーションタイプWEBに、クライアントタイプCONFIDENTIALに変更します。


Edit the Authlete application

次に、認可タブをクリックします。このシナリオでは、クライアントはauthorization code grant typeを使用し、認可サーバーから認可コードを受け取ることを期待しているため、認可種別AUTHORIZATION_CODEにチェックが入っていることを確認し、さらに応答種別CODEににチェックが入っていることを確認します。

上記のステップ2で、ユーザーを認証した後、認可サーバーがユーザーのブラウザをクライアントアプリケーションにリダイレクトします。このリダイレクトURIをクライアント設定に追加する必要があります。リダイレクトURI作成をクリックし、クライアントのリダイレクトURIを入力します:

http://localhost:8080/ecommerce/oauth

その後、作成をクリックし、モックのリダイレクトURIを削除します。


Edit the Authlete application

アクセストークンを取得する際、クライアントはsection 2.3 of RFC 674に従って認可サーバーに対してクレデンシャルをPOSTします(上記のステップ3)。これを設定するには、トークンエンドポイントまでスクロールし、クライアント認証方式CLIENT_SECRET_POSTに変更します。下までスクロールし、更新をクリックしてから、OKをクリックして設定を保存します。

クレデンシャルを設定しクライアントアプリケーションを再起動する

eコマースアプリケーションは環境変数からクレデンシャルを読み取るため、eコマースコンテナを停止して、先ほどメモしたクライアントIDとシークレットを渡したうえで再起動します。

まず、eコマースコンテナを停止し、削除します:

docker stop authlete-ecommerce
docker rm authlete-ecommerce

次に、再度実行し、コマンドラインでCLIENT_IDCLIENT_SECRET環境変数を設定します:

docker run -d \
  --name authlete-ecommerce \
  --network authlete-net \
  --publish 8080:8080 \
  --env CLIENT_ID="your_client_id" \
  --env CLIENT_SECRET="your_client_secret" \
  us-docker.pkg.dev/authlete/demo/java-getting-started-ecommerce:latest

注: アプリケーションをデフォルトのポート8080および8081以外で実行している場合は、eコマースアプリケーションの設定ファイルも更新する必要があります。以下の内容で、oauthService.jsonという名前のファイルをローカルマシン上に作成します: \

{
  "service_name": "Loyalty",
  "auth_uri": "http://localhost:8081/loyalty/oauth/authorization",
  "redirect_uri": "http://localhost:8080/ecommerce/oauth",
  "token_uri": "http://authlete-loyalty:8080/loyalty/oauth/token",
  "api_endpoint": "http://authlete-loyalty:8080/loyalty/api/currentCustomer",
  "query_params": {
    "prompt": "login"
  },
}

auth_uriredirect_uriエンドポイントは、ローカルマシン(Dockerホスト)上で実行されているブラウザからアクセスされます。これらの2つのURLのポート番号を、ロイヤルティコンテナおよびeコマースコンテナで使用しているホストポートに変更します。

token_uriapi_endpointのポート番号は変更しないでください。eコマースアプリケーションはこれらのエンドポイントにDockerネットワーク経由で直接アクセスします。

次に、oauthService.jsonをeコマースアプリケーションのコンテナにコピーします:

docker cp oauthService.json authlete-ecommerce:/src/ecommerce/src/main/webapp/WEB-INF/oauthService.json

最後に、eコマースアプリケーションを再ビルドします:

docker exec -it authlete-ecommerce /run/rebuild.sh

これで、eコマースアプリケーションにOAuth 2.0クライアントとして必要なすべてが揃いました。次はロイヤルティアプリケーションに取り掛かります。

サービスクレデンシャルの設定

$SOURCE_ROOT/loyalty/src/main/webapp/WEB-INFディレクトリにauthleteCredential.jsonという名前のファイルを作成し、先ほどメモしたサービスオーナークレデンシャル(APIキーとAPIシークレット)を記述します:

{
  "api_key" : "<your api key>",
  "api_secret" : "<your api secret>"
}

各クレデンシャルはダブルクォートで囲む必要があることに注意してください。

では、コーディングを始めましょう!

ステップ1: 認可の開始


OAuth step 1

このステップでは、http://localhost:8081/loyalty/oauth/authorizationにサーブレットを作成し、認可リクエストの処理を行います。

このサーブレットは、受信した認可リクエストをAuthleteのAuthorization APIに転送し、そのレスポンスに従って動作し、ログインページにリダイレクトします。

始める前に

前述の通り、ソースディレクトリにはGitリポジトリが含まれています。リポジトリが正しい開始状態にあることを確認するために、以下のコマンドを実行します:

git status

以下のような出力が表示されます:

On branch main
Your branch is up to date with 'origin/main'.

nothing to commit, working tree clean

リポジトリがmainブランチではない場合は、以下のコマンドで正しいブランチをチェックアウトします:

git checkout main

認可サーブレットの作成

$SOURCE_ROOT/loyalty/src/main/java/com/authlete/simpleauthoauthという新しいディレクトリを作成し、その中にOAuthAuthorizationServlet.javaという新しいJavaソースファイルを以下の内容で作成します:

package com.authlete.simpleauth.oauth;

import com.authlete.simpleauth.LoginUtils;
import com.authlete.simpleauth.UserAccount;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.glassfish.jersey.client.authentication.HttpAuthenticationFeature;

import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.ws.rs.client.Client;
import javax.ws.rs.client.Entity;
import javax.ws.rs.core.GenericType;
import javax.ws.rs.core.MediaType;
import java.io.IOException;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

@WebServlet("/oauth/authorization")
public class OAuthAuthorizationServlet extends HttpServlet {
    private static final Logger logger = LogManager.getLogger();
    private static final long serialVersionUID = 1L;
    private final ObjectMapper mapper = new ObjectMapper();

    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException {
    }
}

ロイヤルティウェブアプリケーションは、サーブレットフィルタを使用してHTTPリクエストをログインページにリダイレクトします。LoginUtilsクラスのisPublicPage()メソッドは、ユーザーのログインを必要とせずに返すことができるリソースを判断します。OAuthサーブレットのパスをそのリソースリストに追加し、サーブレットがログインプロセスを制御できるようにする必要があります。

$SOURCE_ROOT/loyalty/src/main/java/com/authlete/simpleauth/LoginUtils.javaを開き、isPublicPage()メソッドを以下のコードに置き換えます:

  public static boolean isPublicPage(HttpServletRequest request) {
    String requestUri = request.getRequestURI();
    String contextPath = request.getContextPath();

    // The login page, front page and CSS path are public
    return requestUri.equals(contextPath + "/login") ||
            requestUri.equals(contextPath + "/index.html") ||
            requestUri.equals(contextPath + "/") ||
            requestUri.startsWith(contextPath + "/css") ||
            requestUri.startsWith(contextPath + "/oauth/");
  }

AuthleteのAuthorization APIの呼び出し

先ほど作成したOAuthAuthorizationServlet.javaファイルに戻り、doGet()メソッドに以下のコードを追加します:

        initiateAuthleteAuthorization(request, response);

次に、同じクラス内に以下のコードを追加し、initiateAuthleteAuthorization()メソッドを定義します:

    private void initiateAuthleteAuthorization(HttpServletRequest request, HttpServletResponse response) throws IOException {
        // 1. Get a Jersey HTTP client
        Client client = OAuthUtils.getClient(getServletContext());

        // 2. We will wrap the incoming query string in a JSON object
        Map<String, Object> requestMap = Collections.singletonMap("parameters", request.getQueryString());

        // 3. Call the Authlete Authorization endpoint
        String url = "https://api.authlete.com/api/auth/authorization";

        logger.info("Sending API request to {}:\n{}", url, OAuthUtils.prettyPrint(requestMap));

        // 4. Make the API call, parsing the JSON response into a map
        Map<String, Object> authApiResponse = client.target(url)
                .request()
                .post(Entity.entity(requestMap, MediaType.APPLICATION_JSON_TYPE), new GenericType<>() {
                });

        logger.info("Received API response:\n{}", OAuthUtils.prettyPrint(authApiResponse));

        // 5. 'action' tells us what to do next, 'responseContent' is the payload we'll return
        String action = (String) authApiResponse.get("action");
        String responseContent = (String) authApiResponse.get("responseContent");

        // 6. Perform the action
        switch (action) {
            case "INTERACTION":
                List<Object> prompts = (List<Object>) authApiResponse.get("prompts");
                for (Object prompt : prompts) {
                    if (prompt.equals("LOGIN")) {
                        // 7. Prompt the user to login
                        request.getSession().setAttribute("authApiResponse", authApiResponse);
                        LoginUtils.redirectForLogin(request, response);
                        return;
                    }
                }
                break;

            // 8. Handle errors
            case "INTERNAL_SERVER_ERROR":
                OAuthUtils.setResponseBody(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, responseContent);
                return;

            case "BAD_REQUEST":
                OAuthUtils.setResponseBody(response, HttpServletResponse.SC_BAD_REQUEST, responseContent);
                return;
        }

        // 9. We should never get here!
        Map<String, String> errorResponse = Map.of(
            "error", "unexpected_error",
            "error_description", "Contact the service owner for details"
        );
        OAuthUtils.setResponseBody(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, mapper.writeValueAsString(errorResponse));
    }

注意 - IDEを使用している場合、OAuthUtilsクラスが定義されていないというエラーが表示される可能性があります。すぐにこのクラスを追加します。

一見すると多くのことが行われているように見えますが、実際にはプロセスは非常にシンプルです。メソッド内のコメントに従って進めます:

  1. サーブレットはAuthlete APIとHTTPを介してやり取りするため、Jersey HTTPクライアントを取得します。

    注: Authlete Java SDKを使用することもできますが、このチュートリアルではAuthlete APIと直接やり取りする方法を示し、メッセージフローを明確に理解できるようにしています。

  2. Authlete APIは、以下のようにクエリ文字列を含むparametersプロパティをもつJSONペイロードを期待しています:

    {
      "parameters": "response_type=code&client_id=..."
    }
    

    PoC を速やかに行うために、Javaクラスを生成するのではなく、必要な構造を持つMapを作成します。

  3. OAuthフローを続行するために、Authleteの/auth/authorizationエンドポイントを呼び出します。

  4. サーブレットがAPI呼び出しを行い、レスポンスを別のJava Mapにパースします。

  5. レスポンスのactionプロパティは、サーブレットが次に何をすべきかを示し、responseContentはクライアントに返すべきデータを保持します。

  6. OAuthは非常に柔軟なプロトコルであり、ユースケースやサービス設定に応じて、この時点でサービスが取るべき多くのアクションがあります。Authleteの/auth/authorizationエンドポイントのドキュメントで「Description」をクリックすると、すべてのパターンを確認できます。

    ここで注目するのはINTERACTIONです。これは、ユーザーとの何らかのインタラクションが必要であることを示しています。ユースケースを振り返ってみると、この時点で、ユーザーがロイヤルティプログラムのウェブサイトでアクティブなセッションを持っているかどうかに関係なく、ログインを促す必要があります。ここでのコードは、APIレスポンスにLOGINエントリを含むpromptsプロパティがあるかどうかを確認し、そのケースを処理します。

    promptsプロパティがない、または単一のLOGINエントリ以外のものが含まれている場合、実行はメソッドの最後のエラーハンドラー(9)に移ります。

  7. 後続処理で利用するためにAPIレスポンスをセッションに保存し、ユーザーをログインページにリダイレクトします。ログイン後、ユーザーは再びこのサーブレットにリダイレクトされます。

  8. Authlete APIは、actionプロパティを介してエラーを示す場合があり、サーブレットはそれに応じて処理する必要があります。この場合は、responseContentがエラーレスポンスのペイロードを保持しています。

  9. actionが想定外の値だった場合、または他のエラーが発生した場合、一般的なエラーを返します。

サーブレットはいくつかのユーティリティクラスを参照しています。次に、$SOURCE_ROOT/loyalty/src/main/java/com/authlete/simpleauth/oauthディレクトリにOAuthUtils.javaを以下の内容で作成します:

package com.authlete.simpleauth.oauth;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.glassfish.jersey.client.ClientConfig;
import org.glassfish.jersey.client.authentication.HttpAuthenticationFeature;

import javax.servlet.ServletContext;
import javax.servlet.http.HttpServletResponse;
import javax.ws.rs.client.Client;
import javax.ws.rs.client.ClientBuilder;
import javax.ws.rs.client.Entity;
import javax.ws.rs.core.GenericType;
import javax.ws.rs.core.MediaType;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.util.Map;

public class OAuthUtils {
   private static final String AUTHLETE_CREDENTIAL_JSON = "/WEB-INF/authleteCredential.json";
   private static final ObjectMapper mapper = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT);

   private static AuthleteCredential getAuthleteCredential(ServletContext context) throws IOException {
       ObjectMapper mapper = new ObjectMapper();
       InputStream inputStream = context.getResourceAsStream(AUTHLETE_CREDENTIAL_JSON);
       if (inputStream == null) {
           throw new FileNotFoundException(AUTHLETE_CREDENTIAL_JSON);
       }
       return mapper.readValue(inputStream, AuthleteCredential.class);
   }

   public static synchronized Client getClient(ServletContext context) throws IOException {
       Client client = (Client)context.getAttribute("authleteClient");
       if (client == null) {
           AuthleteCredential authleteCredential = getAuthleteCredential(context);
           client = ClientBuilder.newClient(new ClientConfig())
                   .register(HttpAuthenticationFeature.basic(authleteCredential.getApiKey(), authleteCredential.getApiSecret()));
           context.setAttribute("authleteClient", client);
       }

       return client;
   }

   static void setResponseBody(HttpServletResponse response, int statusCode, String responseContent) throws IOException {
       response.setStatus(statusCode);
       response.setContentType(MediaType.APPLICATION_JSON);
       response.setHeader("Cache-Control", "no-store");
       response.setHeader("Pragma", "no-cache");
       response.getWriter().print(responseContent);
   }

   public static String prettyPrint(Map<String, Object> requestMap) {
       try {
           return mapper.writeValueAsString(requestMap);
       } catch (JsonProcessingException e) {
           return null;
       }
   }
}

OAuthUtils.javaの内容を見てみましょう:

  • getAuthleteCredential()は、先ほど作成したJSONファイルからAuthlete APIキーとシークレットを読み込みます。
  • getClient()は、初回実行時にHTTPクライアントを作成し、Authlete APIに対するHTTPベーシック認証を使用して認証します。このクライアントは、今後の使用のためにサーブレットコンテキストに保存されます。
  • setResponseBody()は、ステータスコードとJSONコンテンツを持つHttpServletResponseオブジェクトを設定します。
  • prettyPrint()は、JavaのMapを整形されたJSONとして表示します。

同じディレクトリにAuthleteCredential.javaを以下の内容で作成します:

package com.authlete.simpleauth.oauth;

import com.fasterxml.jackson.annotation.JsonProperty;

public class AuthleteCredential {
  @JsonProperty("api_key")
  private String apiKey;

  @JsonProperty("api_secret")
  private String apiSecret;

  @JsonProperty("api_key")
  public String getApiKey() {
    return apiKey;
  }

  @JsonProperty("api_key")
  public void setApiKey(String apiKey) {
    this.apiKey = apiKey;
  }

  @JsonProperty("api_secret")
  public String getApiSecret() {
    return apiSecret;
  }

  @JsonProperty("api_secret")
  public void setApiSecret(String apiSecret) {
    this.apiSecret = apiSecret;
  }
}

次のステップのスタブを追加する

今すぐアプリケーションを再ビルドして実行すると、eコマースサイトで「Link my Loyalty Account」をクリックしてログインすると、ログインを繰り返し求められることになります。サーブレットは、HTTPセッションにAuthlete APIのレスポンスを保存しています。次のステップを実行するためには、そのレスポンスが存在するかどうかを確認する必要があります。

OAuthAuthorizationServlet.javaに戻り、doGet()関数を以下のコードで置き換えます。

    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException {
        Map<String, Object> authApiResponse = (Map<String, Object>) request.getSession().getAttribute("authApiResponse");
        request.getSession().removeAttribute("authApiResponse");
        logger.info("{} API response in the session", authApiResponse == null ? "No" : "Found an");
        if (authApiResponse == null) {
            initiateAuthleteAuthorization(request, response);
        } else {
            processAuthleteAuthorization(request, response, authApiResponse);
        }
    }

これでサーブレットはセッションからAPIレスポンスを取得し、見つからない場合にのみ認証プロセスを開始します。それ以外の場合は、フローの次のステップに進みます。

最後に、その次のステップのスタブをOAuthAuthorizationServletクラスに追加します:

    private void processAuthleteAuthorization(HttpServletRequest request, HttpServletResponse response, Map<String, Object> authApiResponse) throws IOException {
        // Not yet implemented!
        Map<String, String> errorResponse = Map.of(
            "error", "not_yet_implemented",
            "error_description", "This step is not yet implemented"
        );
        OAuthUtils.setResponseBody(response, HttpServletResponse.SC_NOT_IMPLEMENTED, mapper.writeValueAsString(errorResponse));
    }

これで、私たちの作業が実際にどのように機能するかを確認する準備が整いました!

アプリケーションの再ビルド

Dockerコンテナには、アプリケーションを再ビルドし、それをTomcatに再デプロイするスクリプトが含まれています。すべての変更を保存し、ターミナルで以下のスクリプトを実行します:

docker exec -it authlete-loyalty /run/rebuild.sh

ビルド結果がログに出力されます。コードにエラーがある場合、ビルドは停止し、エラーの場所を示します。例えば:


Build error

ログを手がかりに問題の場所を特定し、修正し、再度ビルドを試みてください。

問題がなければ、ビルドが成功したことを表すログが表示されます:


Build success

変更をテストする

http://localhost:8080/ecommerceにアクセスし、「Link my Loyalty Account」をクリックすると、ログインを求められます。ログインをすると、先ほど追加した「not yet implemented」というエラーが表示されます:

{"error": "not_yet_implemented", "error_description": "This step is not yet implemented"}

このステップでは多くのことを行いましたが、そのほとんどはOAuthフローの残り部分の基礎を築くものでした。次に進みましょう!

トラブルシューティング

何か問題が発生した場合は、ソースを元の状態に戻すか、ステップ1の終了時点のソースをチェックアウトしてください。

変更を破棄するには:

git restore .

変更を破棄してステップ1の終了時点までスキップするには:

git restore .
git checkout step-1

ステップ2: クライアントに認可コードを発行


OAuth step 2

ここまでの作業を振り返ると、OAuth 2.0フローのステップ1を実装しました。現在、eコマースアプリは、ロイヤルティプログラムアプリがユーザーのブラウザをリダイレクトし、認可コードをクエリパラメータとして返してくることを期待しています。

したがって、次のタスクは、認可コードを取得するためにAuthlete APIを呼び出して、ステップ2を実装することです。

AuthleteのAuthorization Issue APIの呼び出し

OAuthAuthorizationServlet.javaprocessAuthleteAuthorization()メソッドのスタブ実装を以下のコードに置き換えます:

    private void processAuthleteAuthorization(HttpServletRequest request, HttpServletResponse response, Map<String, Object> authApiResponse) throws IOException {
        // 1. Create a Map to send in the Authlete API request
        Map<String, Object> requestMap = new HashMap<>();

        // 2. Copy the ticket from the last API response into the map
        requestMap.put("ticket", authApiResponse.get("ticket"));

        // 3. Verify that the user is actually logged in
        UserAccount authenticatedUser = LoginUtils.getAuthenticatedUser(request.getSession());
        if (authenticatedUser == null) {
            requestMap.put("reason", "NOT_LOGGED_IN");
            OAuthUtils.handleAuthleteApiCall(getServletContext(), response, "/auth/authorization/fail", requestMap);
            return;
        }

        // 4. Issue the code
        requestMap.put("subject", authenticatedUser.getUsername());
        OAuthUtils.handleAuthleteApiCall(getServletContext(), response, "/auth/authorization/issue", requestMap);
    }

メソッドの説明:

  1. Authlete APIに送信するパラメーターをマップで作成します。

  2. Authleteによる各認可は、最初のAuthlete API呼び出しで返される一意のticketによって識別されます。ticketの値は、後続のAPIリクエストに含める必要があります。

  3. ユーザーが正常にログインしていることを確認します。もしもログインしていない場合は/auth/authorization/failエンドポイントを呼び出して失敗を通知します。OAuthUtils.handleAuthleteApiCall()メソッドについては後ほど説明します。

  4. 認証されたユーザーのユーザー名をsubjectとしてリクエストマップに追加し、Authleteの/auth/authorization/issueエンドポイントを呼び出します。

ユースケースに応じて、サーブレットはここでより多くの処理を行う場合もあります。例えば、このファーストパーティによるPoCでは、クライアントはリクエストにscopeパラメータを渡しませんが、サードパーティが関わるOAuthインタラクションでは、クライアントは通常、scopeパラメータで権限のリストを渡し、サーブレットはクライアントがそれらの権限を受け取ることに対するエンドユーザーの同意を求めます。

認可サーブレットのリファクタリング

/auth/authorization/issueエンドポイントのドキュメントを見ると、異なる操作を行なっているものの /auth/authorizationエンドポイントと非常に似た動作をしていることがわかります。/auth/authorization/issueエンドポイントはactionresponseContentを含むJSONオブジェクトを返し、/auth/authorizationエンドポイントと同様のactionの値を持つ可能性があります。今回は、サーブレットがLOCATIONアクションを期待しており、HTTPステータス302 Foundをブラウザに返し、クエリ文字列に認可コードを含むURLにリダイレクトするよう指示します。

initiateAuthleteAuthorization()からprocessAuthleteAuthorization()にコードをコピーする代わりに、共通コードを抽出できます。以下の静的メソッドをOAuthUtilsに貼り付けてください:

    public static Map<String, Object> handleAuthleteApiCall(ServletContext context, HttpServletResponse response,
                                                            String api, Map<String, Object> requestMap) throws IOException {
        logger.debug("Calling API {} with params {}", api, OAuthUtils.prettyPrint(requestMap));

        Map<String, Object> responseMap = getClient(context).target(AUTHLETE_BASE + api)
                .request()
                .post(Entity.entity(requestMap, MediaType.APPLICATION_JSON_TYPE), new GenericType<>() {
                });

        logger.debug("Received API response {}", OAuthUtils.prettyPrint(responseMap));

        String action = (String)responseMap.get("action");
        String responseContent = (String)responseMap.get("responseContent");

        switch (action) {
            case "INTERNAL_SERVER_ERROR":
                setResponseBody(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, responseContent);
                return null;
            case "BAD_REQUEST":
                setResponseBody(response, HttpServletResponse.SC_BAD_REQUEST, responseContent);
                return null;
            case "LOCATION":
                response.setStatus(HttpServletResponse.SC_FOUND);
                response.setHeader("Location", responseContent);
                response.setHeader("Cache-Control", "no-store");
                response.setHeader("Pragma", "no-cache");
                return null;
        }

        return responseMap;
    }

このメソッドには、initiateAuthleteAuthorization()からのエラーハンドリングが含まれており、LOCATIONアクションの処理も追加されています。次に、OAuthUtilsの先頭に以下のスタティック変数を追加します:

    private static final String AUTHLETE_BASE = "https://api.authlete.com/api";
    private static final Logger logger = LogManager.getLogger();

次にOAuthAuthorizationServletに戻り、initiateAuthleteAuthorization()を以下のコードに置き換えます:

    private void initiateAuthleteAuthorization(HttpServletRequest request, HttpServletResponse response) throws IOException {
        // 1. Call the Authlete Authorization endpoint, wrapping the incoming query string in a JSON object
        Map<String, Object> authApiResponse = OAuthUtils.handleAuthleteApiCall(
                getServletContext(), response, "/auth/authorization",
                Collections.singletonMap("parameters", request.getQueryString()));

        // 2. handleAuthleteApiCall() returns null if it already returned a response to the client
        if (authApiResponse == null) {
            return;
        }

        // 3. Perform the action
        String action = (String)authApiResponse.get("action");
        if (action.equals("INTERACTION")) {
            List<Object> prompts = (List<Object>) authApiResponse.get("prompts");
            for (Object prompt : prompts) {
                if (prompt.equals("LOGIN")) {
                    request.getSession().setAttribute("authApiResponse", authApiResponse);
                    LoginUtils.redirectForLogin(request, response);
                    return;
                }
            }
        }

        // 4. We should never get here!
        Map<String, String> errorResponse = Map.of(
                "error", "unexpected_error",
                "error_description", "Contact the service owner for details"
        );
        OAuthUtils.setResponseBody(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, mapper.writeValueAsString(errorResponse));
    }

ご覧のように、OAuthUtils.handleAuthleteApiCall()に「定型文」コードの多くを抽出しました。残ったコードは、認可の開始に関する具体的な処理に焦点を絞っています。

アプリケーションを再ビルドして変更をテストする

再度、すべての変更を保存し、再ビルドスクリプトを実行してアプリを再ビルドおよび再デプロイします:

docker exec -it authlete-loyalty /run/rebuild.sh

http://localhost:8080/ecommerceにアクセスし、「Link my Loyalty Account」をクリックしてフローを開始します。ログインすると、クライアントがロイヤルティプログラムのOAuthトークンエンドポイントにリクエストを送信しようとしていることがわかります。しかし、トークンエンドポイントはまだ存在していません:


404 error

次に、クライアントが持つ認可コードをアクセストークンに交換するために、ロイヤルティプログラムのウェブサイトにもう一つのサーブレットを追加します。

トラブルシューティング

問題が発生した場合、変更を破棄してソースをステップ1の終了時点に戻すか、ステップ2の終了時点のソースをチェックアウトしてください。

変更を破棄するには:

git restore .

変更を破棄してステップ2の終了時点にスキップするには:

git restore .
git checkout step-2

ステップ3: クライアントのアクセストークン要求を処理


OAuth step 3

OAuthフローのステップ2までで認可サーブレットの仕事は完了しました。次に、トークンリクエストを処理するためのサーブレットを作成します。OAuthAuthorizationServlet.javaと同じディレクトリに、新しいJavaソースファイルOAuthTokenServlet.javaを作成します。

package com.authlete.simpleauth.oauth;

import com.fasterxml.jackson.databind.ObjectMapper;

import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.Map;

@WebServlet("/oauth/token")
public class OAuthTokenServlet extends HttpServlet {
    private static final long serialVersionUID = 1L;
    private final ObjectMapper mapper = new ObjectMapper();

    @Override
    protected void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException {
        // 1. Call the /auth/token endpoint, passing the request body
        String body = new String(request.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
        Map<String, Object> authApiResponse =
                OAuthUtils.handleAuthleteApiCall(getServletContext(), response, "/auth/token",
                        Collections.singletonMap("parameters", body));

        // 2. handleAuthleteApiCall() returns null if it already returned a response to the client
        if (authApiResponse == null) {
            return;
        }

        // 3. Perform the action
        String action = (String)authApiResponse.get("action");
        if (action.equals("OK")) {
            String responseContent = (String)authApiResponse.get("responseContent");
            OAuthUtils.setResponseBody(response, HttpServletResponse.SC_OK, responseContent);
            return;
        }

        // 4. We should never get here!
        Map<String, String> errorResponse = Map.of(
                "error", "unexpected_error",
                "error_description", "Contact the service owner for details"
        );
        OAuthUtils.setResponseBody(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, mapper.writeValueAsString(errorResponse));
    }
}

このサーブレットは、認可サーブレット内のinitiateAuthleteAuthorization()メソッドと非常に似ています。主な違いは次のとおりです。

  • HTTP POSTリクエストを処理する点
  • /auth/authorizationではなく/auth/tokenを呼び出す点
  • INTERACTIONではなくOKアクションを処理し、HTTPステータス200 OKresponseContentをクライアントに返す点

Authleteの/auth/tokenエンドポイントのドキュメントを確認すると、/auth/tokenエンドポイントはリクエストボディを期待しており、actionOKまたは3つのエラー値のいずれかとなっているレスポンスを返すことがわかります。以前に見た2つのエラーコードに加え、INVALID_CLIENTという新しいものがあります。INVALID_CLIENTを処理するためにOAuthUtils.java内のhandleAuthleteApiCallを拡張するのは簡単です。

switch 文を次のコードに置き換えます:

        switch (action) {
            case "INTERNAL_SERVER_ERROR":
                setResponseBody(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, responseContent);
                return null;
            case "BAD_REQUEST":
            case "INVALID_CLIENT":
                setResponseBody(response, HttpServletResponse.SC_BAD_REQUEST, responseContent);
                return null;
            case "LOCATION":
                response.setStatus(HttpServletResponse.SC_FOUND);
                response.setHeader("Location", responseContent);
                response.setHeader("Cache-Control", "no-store");
                response.setHeader("Pragma", "no-cache");
                return null;
        }

ご覧のとおり、INVALID_CLIENTBAD_REQUESTと同様に処理されます。

アプリケーションの再ビルドとテスト

再ビルドスクリプトを実行してアプリを再ビルドおよび再デプロイします:

docker exec -it authlete-loyalty /run/rebuild.sh

再度、http://localhost:8080/ecommerceにアクセスし、「Link my Loyalty Account」をクリックしてフローを開始します。ログインすると、今回は次のようなエラーが表示されます:


500 error

ステップ3を実装したので、クライアントはアクセストークンを取得することができました。次に、クライアントはそのアクセストークンを使用して、ロイヤルティプログラムのREST APIエンドポイントhttp://authlete-loyalty:8080/loyalty/api/currentCustomerを呼び出しますが、JSONレスポンスではなく、ログインページのHTMLを受け取っています。

eコマースアプリがエンドユーザーのロイヤルティプログラムデータを取得できるようにするために、最後のステップを完了する必要があります。

トラブルシューティング

問題が発生した場合、変更を破棄してソースをステップ2の終了時点に戻すか、ステップ3の終了時点のソースをチェックアウトしてください。

変更を破棄するには:

git restore .

変更を破棄してステップ3の終了時点にスキップするには:

git restore .
git checkout step-3

ステップ4: クライアントのロイヤルティプログラムAPIへのリクエストを検証


OAuth step 4

ロイヤルティ認可サーバーは、eコマースアプリを正常に認可し、アクセストークンを発行しました。最後のタスクは、現在のロイヤルティプログラムAPIを拡張して、APIコールの際にOAuth Authorizationヘッダーを認識できるようにし、OAuthフローのステップ4を実装することです。

既存のロイヤルティプログラムコードは、ユーザーが公開されていないURLにアクセスしようとした際に、ログインページにリダイレクトするためのサーブレットフィルタを実装しています。以下がそのコードです:

  @Override
  public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException,
      ServletException {
    HttpServletRequest request = (HttpServletRequest) req;
    HttpServletResponse response = (HttpServletResponse) resp;

    logger.info("Requested {} to {}", request.getMethod(), request.getRequestURL());

    // 1. If the request is for a public page, we can allow it to proceed
    if (LoginUtils.isPublicPage(request)) {
      logger.info("Allowing request for public page");
      chain.doFilter(request, response);
      return;
    }

    // 2. Try to retrieve user information from the HTTP session
    HttpSession session = request.getSession();
    UserAccount authenticatedUser = LoginUtils.getAuthenticatedUser(session);

    // 3. If there is an authenticated user we attach their username to the request and allow it to proceed
    if (authenticatedUser != null) {
      logger.info("Allowing request for authenticated user");
      chain.doFilter(new UserRequestWrapper(authenticatedUser.getUsername(), request), response);
      return;
    }

    // 4. There is no authenticated user - redirect for login
    logger.info("Redirecting for login");
    LoginUtils.redirectForLogin(request, response);
  }

ロジックはとてもシンプルです:

  1. リクエストされたページが「公開」されている場合、リクエストを許可します。公開されているのはログインページ、フロントページ、CSSファイル、OAuthサーブレットのみです。それ以外のすべてのURLには、ユーザー認証が必要です。

  2. ユーザーが認証されると、ログインサーブレットはアカウントオブジェクトをHTTPセッションに添付します。フィルタは、そのセッションから認証されたユーザーのアカウントを取得しようとします。

  3. アカウントが存在する場合、フィルタは認証されたユーザーのユーザー名をリクエストにユーザープリンシパルとして添付し、リクエストを許可します。

  4. 上記以外の場合、フィルタはログインページへのリダイレクトで応答します。

eコマースウェブサイトからのAPIコールには、アクセストークンを含むAuthorizationヘッダーが含まれます。既存のログインフィルタの前に実行する新しいフィルタを実装し、アクセストークンを検証し、ユーザーのアカウントオブジェクトをHTTPセッションに添付して、ログインフィルタがリクエストを許可するようにします。

APIリクエスト用の新しいサーブレットフィルタの実装

com.authlete.simpleauth.oauthパッケージにOAuthFilter.javaという新しいJavaソースファイルを作成し、以下の内容を追加します:

package com.authlete.simpleauth.oauth;

import com.authlete.simpleauth.UserRequestWrapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

import javax.servlet.*;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Collections;
import java.util.Map;

@WebFilter(filterName="oauthFilter")
public class OAuthFilter implements Filter  {
    private static final String BEARER_SPACE = "Bearer ";
    private static final Logger logger = LogManager.getLogger();
    private final ObjectMapper mapper = new ObjectMapper();

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
    }

    @Override
    public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest)req;
        HttpServletResponse response = (HttpServletResponse)resp;

        // 1. Is there an Authorization header with an access token?
        String authHeader = request.getHeader("Authorization");
        if (authHeader == null || !authHeader.startsWith(BEARER_SPACE)) {
            chain.doFilter(request, response);
            return;
        }

        // 2. Extract the bearer token from the header value and send it to the introspection endpoint.
        String token = authHeader.substring(BEARER_SPACE.length());
        logger.info("Found access token {} - validating it with Authlete", token);
        Map<String, Object> authApiResponse = OAuthUtils.handleAuthleteApiCall(req.getServletContext(), response,
                "/auth/introspection", Collections.singletonMap("token", token));

        if (authApiResponse == null) {
            // 3. There was an error calling the Authlete API. OAuthUtils.handleAuthleteApiCall has handled it.
            // Nothing more to do here.
            logger.error("Disallowing API request with access token {}", token);
            return;
        }

        // 4. Attach the username to the request and pass the request down the chain
        String action = (String)authApiResponse.get("action");
        if (action.equals("OK")) {
            String username = (String) authApiResponse.get("subject");
            logger.info("Allowing API request to {} for user {}", request.getRequestURI() , username);
            chain.doFilter(new UserRequestWrapper(username, request), response);
            return;
        }

        // 5. We should never get here!
        Map<String, String> errorResponse = Map.of(
                "error", "unexpected_error",
                "error_description", "Contact the service owner for details"
        );
        OAuthUtils.setResponseBody(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, mapper.writeValueAsString(errorResponse));
    }

    @Override
    public void destroy() {
    }
}

このフィルタのロジックもシンプルです:

  1. フィルタは、Authorization HTTPヘッダーが"Bearer "で始まる値を持っているかどうかを確認します(スペースに注意)。そのようなヘッダーがない場合は、これ以上の処理は必要ないため、リクエストをフィルタチェーンに渡します。

  2. フィルタは、HTTPヘッダーからアクセストークンを抽出し、Authlete APIのイントロスペクションエンドポイントに送信して検証します。実際のクライアントでは、パフォーマンスを向上させるために、イントロスペクションレスポンスをキャッシュすることもあります。

  3. Authleteから返されたactionがエラーを示している場合、OAuthUtils.handleAuthleteApiCall()が適切なアクションをすでに実行しているため、アクセスを拒否した事実をログに記録して終了します。

  4. APIレスポンスには、認証されたユーザーのユーザー名がsubjectプロパティに含まれています。それをリクエストに添付し、リクエストをチェーンに渡します。

  5. ここに到達することはありません!

このシンプルなファーストパーティデモでは、OAuthスコープを使用していません。サードパーティのOAuthクライアントからのAPIコールを検証するリソースサーバーは、イントロスペクションレスポンスでトークンに関連付けられたスコープのリストを受け取り、HTTPメソッドとURLがトークンに関連付けられたスコープで許可されていることを確認します。

イントロスペクションエラーに対応するエラーハンドリングの追加

Authleteの/auth/introspectionエンドポイントのドキュメントを確認すると、トークンが有効である場合、actionOKとなり、レスポンスにはエンドユーザーを識別するsubjectプロパティが含まれることがわかります。また、追加のエラー値として、UNAUTHORIZEDおよびFORBIDDENが存在します。

UNAUTHORIZEDは、アクセストークンが認識されない、または期限切れであることを意味します。FORBIDDENは、アクセストークンが必要なスコープをカバーしていないか、アクセストークンに関連付けられた主体がリクエストに含まれる主体と異なることを意味します。このデモでは、FORBIDDENに該当する状況は発生しませんが、完全性を保つためにエラーハンドリングを含めています。

これらのエラーでは、認可サーバーはクライアントにWWW-Authenticate HTTPヘッダーでresponseContentの内容を返す必要があります。

OAuthUtils.handleAuthleteApiCall()にさらにエラーハンドリングを追加する必要があります。switch文を次のコードに置き換えます:

        switch (action) {
            case "INTERNAL_SERVER_ERROR":
                setResponseBody(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, responseContent);
                return null;
            case "BAD_REQUEST":
            case "INVALID_CLIENT":
                setResponseBody(response, HttpServletResponse.SC_BAD_REQUEST, responseContent);
                return null;
            case "UNAUTHORIZED":
                setAuthenticateHeader(response, HttpServletResponse.SC_UNAUTHORIZED, responseContent);
                return null;
            case "FORBIDDEN":
                setAuthenticateHeader(response, HttpServletResponse.SC_FORBIDDEN, responseContent);
                return null;
            case "LOCATION":
                response.setStatus(HttpServletResponse.SC_FOUND);
                response.setHeader("Location", responseContent);
                response.setHeader("Cache-Control", "no-store");
                response.setHeader("Pragma", "no-cache");
                return null;
            default:
                break;
        }

また、上記で参照されている新しいユーティリティ関数を追加します:

    private static void setAuthenticateHeader(HttpServletResponse response, int statusCode, String responseContent) {
        response.setStatus(statusCode);
        response.setHeader("WWW-Authenticate", responseContent);
        response.setHeader("Cache-Control", "no-store");
        response.setHeader("Pragma", "no-cache");
    }

ログインフィルタを修正してユーザープリンシパルを確認

新しいOAuthフィルタがHTTPリクエストにユーザー名を設定するため、これらのリクエストを許可するように既存のログインフィルタを修正する必要があります。$SOURCE_ROOT/loyalty/src/main/java/com/authlete/simpleauth/LoginFilter.javaを開き、ステップ1と2の間に以下のコードを追加します:

    // 1.5 Is there already a username attached to the request?
    Principal principal = request.getUserPrincipal();
    if (principal != null) {
      String username = principal.getName();
      if (username != null) {
        logger.info("Allowing request for user principal {}", username);
        chain.doFilter(request, response);
        return;
      }
    }

フィルタチェーンにサーブレットフィルタを追加

最後のタスクは、APIリクエストの場合にOAuthフィルタがログインフィルタの前に呼び出されるようにすることです。$SOURCE_ROOT/loyalty/src/main/webapp/WEB-INF/web.xmlで、このフィルタマッピングをログインフィルタのマッピングの前に挿入します:

  <filter-mapping>
    <filter-name>oauthFilter</filter-name>
    <url-pattern>/api/*</url-pattern>
  </filter-mapping>

Note that url-pattern is relative to the loyalty web application’s context root, /loyalty.

url-patternはロイヤルティウェブアプリケーションのコンテキストルート/loyaltyに対する相対パスであることに注意してください。

アプリケーションの再ビルドとテスト

再び、再ビルドスクリプトを実行してアプリを再ビルドおよび再デプロイします:

docker exec -it authlete-loyalty /run/rebuild.sh

One last time, browse to http://localhost:8080/ecommerce. You will immediately see the user’s name and loyalty account points balance:

もう一度、http://localhost:8080/ecommerceにアクセスします。すると、ユーザー名とロイヤルティアカウントのポイント残高が表示されます:


E-Commerce web application with loyalty points balance

「今回はなぜログインを求められなかったのか?」と思うかもしれません。

ステップ3の最後にログインしたとき、eコマースアプリはアクセストークンを取得し、それをHTTPセッションに保存しました。今回、ブラウザがeコマースアプリのフロントページを取得した際、そのセッションはまだアクティブだったため、eコマースアプリはAPIを呼び出すためにアクセストークンを使用しました。一方、ロイヤルティアプリはAuthleteを使ってアクセストークンを検証し、ユーザー名を取得できたので、APIコールは成功しました。

Unlink my Loyalty Accountをクリックしてから、再び「Link my Loyalty Account」をクリックすると、ログインを求められます。そして再度ログインをすると、ユーザー名とロイヤルティアカウントのポイント残高がeコマースアプリに表示されます。

成功です – 記録的な速さでOAuthのPoCが完了しました!

後処理

Dockerコンテナを停止する場合は、以下のコマンドを実行します:

docker stop authlete-loyalty

コンテナを再起動するには:

docker start authlete-loyalty

コンテナを完全に削除するには:

docker rm authlete-loyalty

トラブルシューティング

問題が発生した場合、変更を破棄してソースをステップ3の終了時点に戻すか、ステップ4の終了時点のソースをチェックアウトしてください。

変更を破棄するには:

git restore .

変更を破棄してステップ4の終了時点にスキップするには:

git restore .
git checkout step-4

レビュー

ロイヤルティプログラムのウェブアプリケーションに対して、OAuth 2.0の認可サーバーおよびリソースサーバーとしての機能を追加するためにコードを追加しました。しかし、追加したコードは非常に少なく、そのほとんどはクライアントのリクエストをAuthlete APIに渡し、APIのレスポンスをクライアントに返すだけのものでした。

以下はOAuth 2.0フローの全体像です:


OAuth flow

Authleteがフローの各ステップで何をしているかを見てみましょう:

ステップ1: 認可の開始


OAuth step 1

この初期ステップでは、ユーザーがeコマースアプリでリンクをクリックすると、認可リクエストが発生し、以下のようなURLで認可サーバーにリダイレクトされます:

http://localhost:8081/loyalty/oauth/authorization?
response_type=code&
client_id=01234567890123&
redirect_uri=http://localhost:8080/ecommerce/oauth&
state=Loyalty&
prompt=login

(わかりやすくするために改行を追加しています)

ロイヤルティアプリのOAuth認可サーブレットは、このクエリ文字列(?以降のすべて)をAuthleteの/auth/authorizationエンドポイントに転送します。このサーブレットは、クエリ文字列を解析する必要はなく、OAuthパラメータについて知識を持つ必要もありません。

Authleteのレスポンスには、このあとの一連のやり取りを一意に識別するticket、サーブレットが取るべき動作を表すactionパラメーター、および、サーブレットの動作として取るべきプロンプトを表すpromptsパラメーターが含まれています。

このレスポンスは、ユーザーを認証し、次のステップに進むべきであることを示しています。

ステップ2: クライアントに認可コードを発行


OAuth step 2

次に、サーブレットはAuthleteに認可コードを発行するよう依頼します。このステップでサーブレットは、ユーザーが確かにログインしていることを確認しなければなりません。

何らかの理由でサーブレットがこのステップに到達し、ユーザーが認証されていない場合、サーブレットはreasonパラメーターにNOT_LOGGED_INを指定してAuthleteの/auth/authorization/failエンドポイントを呼び出します。この場合、Authleteは、LOCATIONのアクションと、クライアントのリダイレクトURLと、ユーザーがログインを要求されたが行われなかったことを示すエラーパラメータを含むURLを返します。

一方、すべてが正常であれば、サーブレットはステップ1で発行されたticketとユーザーのユーザー名を含むリクエストをAuthleteの/auth/authorization/issueエンドポイントに送信します。Authleteのレスポンスには値がLOCATIONとなっているactionパラメーターが含まれており、サーブレットはブラウザにHTTPステータス302 Foundを返し、responseContentに含まれるURL(認可コードをクエリ文字列として含む)にリダイレクトするよう指示します。

ここでも、サーブレットはOAuthプロトコルの詳細に関心を持つ必要はありません。サーブレットはクライアントとAuthleteの間の仲介者であり、各APIレスポンスに従って動作するだけです。

ステップ3: クライアントのアクセストークン要求を処理


OAuth step 3

ステップ3では、クライアントは、前のステップのリダイレクトによって取得した認可コードを、OAuthトークンサーブレットに直接POSTします。

トークンサーブレットは、リクエストボディ全体を解析せずに、そのままAuthleteの/auth/tokenエンドポイントに送信します。この場合、サーブレットはリクエストの内容についての知識を持つ必要はありません。このリクエストは、例えば次のようなOAuthアクセストークンリクエストを含むフォームポストです:

grant_type=authorization_code&
code=some_random_string_of_characters&
client_id=01234567890123&
client_secret=shared_secret_between_the_client_and_authorization_server&
redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fecommerce%2Foauth

(わかりやすくするために改行を追加しています)

トークンサーブレットは、認可サーブレットと同様に、Authlete APIからのレスポンスに基づいて動作し、今回はクライアントにJSONペイロードを返します:

{
  "access_token": "another_random_string_of_characters",
  "scope": null,
  "token_type": "Bearer",
  "expires_in": 86400
}

(わかりやすくするために整形しています)

ステップ4: クライアントのロイヤルティプログラムAPIへのリクエストを検証


OAuth step 4

最後のステップでは、OAuthサーブレットフィルタに注目します。このフィルタは、ロイヤルティAPIエンドポイントへのすべてのリクエストをインターセプトするように設定されています。フィルタは、APIリクエストからアクセストークンを抽出し、それをAuthleteの/auth/introspectionエンドポイントに送信して検証します。Authleteがトークンを検証し、それが有効であれば、レスポンスにはOKアクションとusernameとして設定されたサブジェクトが含まれます。フィルタはそのユーザー名をリクエストに添付し、それをチェーンに渡します。

エラーが発生した場合は、Authleteが適切なactionresponseContentを設定し、それらに基づいてロイヤルティアプリはレスポンスを返します。

リクエストがログインフィルタに到達すると、ユーザー名が存在することに基づいてリクエストを許可します。ロイヤルティAPIは、リクエスト内のユーザー名に基づいて関連データを返します。

まとめ

約1時間で、既存のロイヤルティプログラムのウェブアプリケーションに、OAuth認可サーバーおよびリソースサーバー機能を追加しました。OAuthクライアント(eコマースウェブサイト)からの入力を解析する必要はありませんでしたし、OAuthプロトコルを実装する必要も、クライアントへのレスポンスを組み立てる必要もありませんでした。

OAuthリクエストをAuthlete APIに渡し、APIのレスポンスに応じて動作するコードを追加するだけで、ロイヤルティプログラムのウェブアプリにアプリに簡単にOAuthを実装し、驚くほど短時間ででPoCを完了することができました!