Table of Contents
この短いチュートリアルでは、Java ウェブアプリケーションを Authlete API と統合する手順を説明します。
AuthleticGearという実店舗を持つ小売業者は、顧客向けにロイヤルティプログラムを運営しています。プログラムのメンバーは、ロイヤルティプログラムのウェブサイトにログインして、ポイント残高や取引を確認したり、リンクされた銀行口座への現金振込にポイントを利用したりできます。ロイヤルティプログラムのウェブサイトは、Eclipse Jerseyを介してRESTful API を提供するJavaウェブアプリケーションバックエンドと、HTML5/JavaScriptフロントエンドで構成されています。
同社は近日中にeコマースウェブサイトを立ち上げる予定です。このPoCの要件は、既存のロイヤルティプログラムメンバーが自分のロイヤルティアカウントをeコマースサイトにリンクし、eコマースのフロントページでポイント残高を表示できるようにすることです。将来的には、顧客がロイヤルティポイントを購入に利用できる本番システムを導入する予定です。
この統合は「ファーストパーティ」統合であることに注意してください。ロイヤルティプログラムとeコマースサイトはどちらも同じ会社が運営しています。このようなケースでは、クライアントがサービスでユーザーデータにアクセスできるかどうかを明示的に尋ねるステップを省略する場合があります。しかし、たとえロイヤルティプログラムのウェブサイトに既にアクティブセッションがあったとしても、アカウントをリンクする際には、ユーザーが本当にアカウントをリンクしたいことを確認するためにログインを要求します。
eコマースチームは既にOAuth 2.0 クライアントを実装済みです。あなたの仕事は、ロイヤルティプログラムチームの開発者として、ロイヤルティプログラムのウェブアプリケーションにOAuth 2.0 の認可サーバーとリソースサーバーの役割を実装することです。
あなたはOAuth 2.0の基本を理解しており、顧客のブラウザ(以下の図での「リソースオーナーの『ユーザーエージェント』」)、eコマースウェブサイト(「クライアント」)、およびロイヤルティプログラムのウェブサイト(「認可サーバー」と「リソースサーバー」)の間でどのようなやり取りが行われるかも把握しています。
注意: OAuthの仕様および上記の図では、ユーザーを認証および認可する役割を担う認可サーバーと、APIリクエストを処理する役割を担うリソースサーバーを区別しています。仕様ではこれらを2つの別々の役割として明示していますが、単一のアプリケーションが両方の役割を果たすことも可能であり、このチュートリアルでのロイヤルティプログラムのウェブアプリケーションはこの例に該当します。
大変な仕事のように見えるかもしれませんが、Authleteがあれば、これを1〜2時間で完了できます!
必要なもの:
デモシステムは、eコマースウェブサイト用とロイヤルティサイト用の2つのJava EEウェブアプリケーションをそれぞれ格納するDockerコンテナのペアとして実装されています。
上記のフローのステップ3では、eコマースOAuthクライアントがロイヤルティ認可サーバーに直接リクエストを送信します。このため、eコマースコンテナがロイヤルティコンテナのIPアドレスを解決できるように、Dockerネットワークを作成する必要があります。以下のコマンドを実行してください:
docker network create --driver bridge authlete-net
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
の引数を変更できます:
--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」をクリックして、リンクされた銀行口座に対して現金を振り込むためにロイヤルティポイントを交換するシミュレーションを行うことができます。この機能を使用すると、システムが稼働している間にアカウント残高を簡単に変更できるため、残高が動的に取得されていることを確認できます。
取れる唯一の他のアクションはログアウトで、これによりホームページに戻ります。
ブラウザでhttp://localhost:8080/ecommerce/を開きます。このページは、典型的なeコマースウェブサイトのシンプルなモックアップです。機能している唯一の機能は、「Link my Loyalty account」リンクです。リンクをクリックすると、ロイヤルティサイトにログインしていない場合は、ログインするためにロイヤルティサイトに誘導されます。すでにログインしている場合、またはログイン後は、http://localhost:8081/loyalty/oauth/authorization
で404エラーが表示されます。これは、ロイヤルティプログラムがまだOAuth 2.0をサポートしていないためです。
このチュートリアルには、ロイヤルティプログラムウェブアプリケーションをOAuth 2.0対応にするために必要なすべてが含まれています。ロイヤルティおよびeコマースウェブアプリケーションのソースコードはhttps://github.com/authlete/java-getting-startedで自由に確認できます。どちらもJava JDK 11でApache Tomcat 9.0.x向けに書かれています。アプリケーションには以下の技術が使用されています:
eコマースチームはOAuthクライアントを実装し、ロイヤルティ認可サーバーのURLについても合意していますが、まだロイヤルティ認可サーバーの実装は存在していません。それを修正しましょう!
Authleteアカウントにサインアップし、サービスオーナーコンソールに遷移します。すると、初期作成されたサービスの一覧が表示されます。
サービスをクリックして詳細を確認します。
ロイヤルティプログラムは、Authleteの観点からは「サービス」と見なされ、ここでそのAuthlete設定を管理します。
APIキーとAPIシークレットをメモしておいてください。次のステップで必要になります。
下にスクロールすると、クライアントアプリ開発者コンソールへのリンクが表示されます。
リンクをクリックし、APIキーをログインID、APIシークレットをパスワードとして使用してログインします。
デフォルトのクライアントアプリケーションが表示されない場合は、アプリ作成をクリックしてアプリケーションを作成します。クライアントアプリケーションは、eコマースサイトのOAuth 2.0設定に該当します。
アプリケーションをクリックして詳細を確認します。
クライアントIDとクライアントシークレットをメモしておいてください。これも後で必要になります。
次に、このシナリオに合わせてクライアントの初期設定をいくつか変更します。
ページの下部にスクロールし、編集をクリックします。
eコマースアプリケーションは、クレデンシャルの機密性を維持できるWebアプリケーションです(詳細はClient Typesを参照)。クレデンシャルはサーバーで安全に保存されるため、アプリケーションタイプをWEBに、クライアントタイプをCONFIDENTIALに変更します。
次に、認可タブをクリックします。このシナリオでは、クライアントはauthorization code grant typeを使用し、認可サーバーから認可コードを受け取ることを期待しているため、認可種別のAUTHORIZATION_CODEにチェックが入っていることを確認し、さらに応答種別のCODEににチェックが入っていることを確認します。
上記のステップ2で、ユーザーを認証した後、認可サーバーがユーザーのブラウザをクライアントアプリケーションにリダイレクトします。このリダイレクトURIをクライアント設定に追加する必要があります。リダイレクトURI作成をクリックし、クライアントのリダイレクトURIを入力します:
http://localhost:8080/ecommerce/oauth
その後、作成をクリックし、モックのリダイレクトURIを削除します。
アクセストークンを取得する際、クライアントは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_ID
とCLIENT_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_uri
とredirect_uri
エンドポイントは、ローカルマシン(Dockerホスト)上で実行されているブラウザからアクセスされます。これらの2つのURLのポート番号を、ロイヤルティコンテナおよびeコマースコンテナで使用しているホストポートに変更します。
token_uri
やapi_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>"
}
各クレデンシャルはダブルクォートで囲む必要があることに注意してください。
では、コーディングを始めましょう!
このステップでは、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/simpleauth
にoauth
という新しいディレクトリを作成し、その中に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/");
}
先ほど作成した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クラスが定義されていないというエラーが表示される可能性があります。すぐにこのクラスを追加します。
一見すると多くのことが行われているように見えますが、実際にはプロセスは非常にシンプルです。メソッド内のコメントに従って進めます:
サーブレットはAuthlete APIとHTTPを介してやり取りするため、Jersey HTTPクライアントを取得します。
注: Authlete Java SDKを使用することもできますが、このチュートリアルではAuthlete APIと直接やり取りする方法を示し、メッセージフローを明確に理解できるようにしています。
Authlete APIは、以下のようにクエリ文字列を含むparameters
プロパティをもつJSONペイロードを期待しています:
{
"parameters": "response_type=code&client_id=..."
}
PoC を速やかに行うために、Javaクラスを生成するのではなく、必要な構造を持つMapを作成します。
OAuthフローを続行するために、Authleteの/auth/authorizationエンドポイントを呼び出します。
サーブレットがAPI呼び出しを行い、レスポンスを別のJava Mapにパースします。
レスポンスのaction
プロパティは、サーブレットが次に何をすべきかを示し、responseContent
はクライアントに返すべきデータを保持します。
OAuthは非常に柔軟なプロトコルであり、ユースケースやサービス設定に応じて、この時点でサービスが取るべき多くのアクションがあります。Authleteの/auth/authorizationエンドポイントのドキュメントで「Description」をクリックすると、すべてのパターンを確認できます。
ここで注目するのはINTERACTION
です。これは、ユーザーとの何らかのインタラクションが必要であることを示しています。ユースケースを振り返ってみると、この時点で、ユーザーがロイヤルティプログラムのウェブサイトでアクティブなセッションを持っているかどうかに関係なく、ログインを促す必要があります。ここでのコードは、APIレスポンスにLOGIN
エントリを含むprompts
プロパティがあるかどうかを確認し、そのケースを処理します。
prompts
プロパティがない、または単一のLOGIN
エントリ以外のものが含まれている場合、実行はメソッドの最後のエラーハンドラー(9)に移ります。
後続処理で利用するためにAPIレスポンスをセッションに保存し、ユーザーをログインページにリダイレクトします。ログイン後、ユーザーは再びこのサーブレットにリダイレクトされます。
Authlete APIは、action
プロパティを介してエラーを示す場合があり、サーブレットはそれに応じて処理する必要があります。この場合は、responseContent
がエラーレスポンスのペイロードを保持しています。
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
ビルド結果がログに出力されます。コードにエラーがある場合、ビルドは停止し、エラーの場所を示します。例えば:
ログを手がかりに問題の場所を特定し、修正し、再度ビルドを試みてください。
問題がなければ、ビルドが成功したことを表すログが表示されます:
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
ここまでの作業を振り返ると、OAuth 2.0フローのステップ1を実装しました。現在、eコマースアプリは、ロイヤルティプログラムアプリがユーザーのブラウザをリダイレクトし、認可コードをクエリパラメータとして返してくることを期待しています。
したがって、次のタスクは、認可コードを取得するためにAuthlete APIを呼び出して、ステップ2を実装することです。
OAuthAuthorizationServlet.java
のprocessAuthleteAuthorization()
メソッドのスタブ実装を以下のコードに置き換えます:
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);
}
メソッドの説明:
Authlete APIに送信するパラメーターをマップで作成します。
Authleteによる各認可は、最初のAuthlete API呼び出しで返される一意のticket
によって識別されます。ticket
の値は、後続のAPIリクエストに含める必要があります。
ユーザーが正常にログインしていることを確認します。もしもログインしていない場合は/auth/authorization/failエンドポイントを呼び出して失敗を通知します。OAuthUtils.handleAuthleteApiCall()
メソッドについては後ほど説明します。
認証されたユーザーのユーザー名をsubject
としてリクエストマップに追加し、Authleteの/auth/authorization/issueエンドポイントを呼び出します。
ユースケースに応じて、サーブレットはここでより多くの処理を行う場合もあります。例えば、このファーストパーティによるPoCでは、クライアントはリクエストにscope
パラメータを渡しませんが、サードパーティが関わるOAuthインタラクションでは、クライアントは通常、scope
パラメータで権限のリストを渡し、サーブレットはクライアントがそれらの権限を受け取ることに対するエンドユーザーの同意を求めます。
/auth/authorization/issueエンドポイントのドキュメントを見ると、異なる操作を行なっているものの /auth/authorization
エンドポイントと非常に似た動作をしていることがわかります。/auth/authorization/issue
エンドポイントはaction
とresponseContent
を含む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トークンエンドポイントにリクエストを送信しようとしていることがわかります。しかし、トークンエンドポイントはまだ存在していません:
次に、クライアントが持つ認可コードをアクセストークンに交換するために、ロイヤルティプログラムのウェブサイトにもう一つのサーブレットを追加します。
問題が発生した場合、変更を破棄してソースをステップ1の終了時点に戻すか、ステップ2の終了時点のソースをチェックアウトしてください。
変更を破棄するには:
git restore .
変更を破棄してステップ2の終了時点にスキップするには:
git restore .
git checkout step-2
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()
メソッドと非常に似ています。主な違いは次のとおりです。
/auth/authorization
ではなく/auth/token
を呼び出す点INTERACTION
ではなくOK
アクションを処理し、HTTPステータス200 OK
とresponseContent
をクライアントに返す点Authleteの/auth/tokenエンドポイントのドキュメントを確認すると、/auth/token
エンドポイントはリクエストボディを期待しており、action
がOK
または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_CLIENT
はBAD_REQUEST
と同様に処理されます。
再ビルドスクリプトを実行してアプリを再ビルドおよび再デプロイします:
docker exec -it authlete-loyalty /run/rebuild.sh
再度、http://localhost:8080/ecommerceにアクセスし、「Link my Loyalty Account」をクリックしてフローを開始します。ログインすると、今回は次のようなエラーが表示されます:
ステップ3を実装したので、クライアントはアクセストークンを取得することができました。次に、クライアントはそのアクセストークンを使用して、ロイヤルティプログラムのREST APIエンドポイントhttp://authlete-loyalty:8080/loyalty/api/currentCustomer
を呼び出しますが、JSONレスポンスではなく、ログインページのHTMLを受け取っています。
eコマースアプリがエンドユーザーのロイヤルティプログラムデータを取得できるようにするために、最後のステップを完了する必要があります。
問題が発生した場合、変更を破棄してソースをステップ2の終了時点に戻すか、ステップ3の終了時点のソースをチェックアウトしてください。
変更を破棄するには:
git restore .
変更を破棄してステップ3の終了時点にスキップするには:
git restore .
git checkout step-3
ロイヤルティ認可サーバーは、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);
}
ロジックはとてもシンプルです:
リクエストされたページが「公開」されている場合、リクエストを許可します。公開されているのはログインページ、フロントページ、CSSファイル、OAuthサーブレットのみです。それ以外のすべてのURLには、ユーザー認証が必要です。
ユーザーが認証されると、ログインサーブレットはアカウントオブジェクトをHTTPセッションに添付します。フィルタは、そのセッションから認証されたユーザーのアカウントを取得しようとします。
アカウントが存在する場合、フィルタは認証されたユーザーのユーザー名をリクエストにユーザープリンシパルとして添付し、リクエストを許可します。
上記以外の場合、フィルタはログインページへのリダイレクトで応答します。
eコマースウェブサイトからのAPIコールには、アクセストークンを含むAuthorizationヘッダーが含まれます。既存のログインフィルタの前に実行する新しいフィルタを実装し、アクセストークンを検証し、ユーザーのアカウントオブジェクトをHTTPセッションに添付して、ログインフィルタがリクエストを許可するようにします。
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() {
}
}
このフィルタのロジックもシンプルです:
フィルタは、Authorization
HTTPヘッダーが"Bearer "
で始まる値を持っているかどうかを確認します(スペースに注意)。そのようなヘッダーがない場合は、これ以上の処理は必要ないため、リクエストをフィルタチェーンに渡します。
フィルタは、HTTPヘッダーからアクセストークンを抽出し、Authlete APIのイントロスペクションエンドポイントに送信して検証します。実際のクライアントでは、パフォーマンスを向上させるために、イントロスペクションレスポンスをキャッシュすることもあります。
Authleteから返されたaction
がエラーを示している場合、OAuthUtils.handleAuthleteApiCall()
が適切なアクションをすでに実行しているため、アクセスを拒否した事実をログに記録して終了します。
APIレスポンスには、認証されたユーザーのユーザー名がsubject
プロパティに含まれています。それをリクエストに添付し、リクエストをチェーンに渡します。
ここに到達することはありません!
このシンプルなファーストパーティデモでは、OAuthスコープを使用していません。サードパーティのOAuthクライアントからのAPIコールを検証するリソースサーバーは、イントロスペクションレスポンスでトークンに関連付けられたスコープのリストを受け取り、HTTPメソッドとURLがトークンに関連付けられたスコープで許可されていることを確認します。
Authleteの/auth/introspectionエンドポイントのドキュメントを確認すると、トークンが有効である場合、action
はOK
となり、レスポンスにはエンドユーザーを識別する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にアクセスします。すると、ユーザー名とロイヤルティアカウントのポイント残高が表示されます:
「今回はなぜログインを求められなかったのか?」と思うかもしれません。
ステップ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フローの全体像です:
Authleteがフローの各ステップで何をしているかを見てみましょう:
この初期ステップでは、ユーザーが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
パラメーターが含まれています。
このレスポンスは、ユーザーを認証し、次のステップに進むべきであることを示しています。
次に、サーブレットは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トークンサーブレットに直接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
}
(わかりやすくするために整形しています)
最後のステップでは、OAuthサーブレットフィルタに注目します。このフィルタは、ロイヤルティAPIエンドポイントへのすべてのリクエストをインターセプトするように設定されています。フィルタは、APIリクエストからアクセストークンを抽出し、それをAuthleteの/auth/introspection
エンドポイントに送信して検証します。Authleteがトークンを検証し、それが有効であれば、レスポンスにはOK
アクションとusername
として設定されたサブジェクトが含まれます。フィルタはそのユーザー名をリクエストに添付し、それをチェーンに渡します。
エラーが発生した場合は、Authleteが適切なaction
とresponseContent
を設定し、それらに基づいてロイヤルティアプリはレスポンスを返します。
リクエストがログインフィルタに到達すると、ユーザー名が存在することに基づいてリクエストを許可します。ロイヤルティAPIは、リクエスト内のユーザー名に基づいて関連データを返します。
約1時間で、既存のロイヤルティプログラムのウェブアプリケーションに、OAuth認可サーバーおよびリソースサーバー機能を追加しました。OAuthクライアント(eコマースウェブサイト)からの入力を解析する必要はありませんでしたし、OAuthプロトコルを実装する必要も、クライアントへのレスポンスを組み立てる必要もありませんでした。
OAuthリクエストをAuthlete APIに渡し、APIのレスポンスに応じて動作するコードを追加するだけで、ロイヤルティプログラムのウェブアプリにアプリに簡単にOAuthを実装し、驚くほど短時間ででPoCを完了することができました!