メインコンテンツまでスキップ

RBAC と JWT 検証で Vert.x Web API を保護する

このガイドでは、Logto が発行する ロールベースのアクセス制御 (RBAC)JSON Web Token (JWT) を使用して Vert.x Web API に認可 (Authorization) を実装し、セキュリティを強化する方法を説明します。

始める前に

クライアントアプリケーションは Logto から アクセス トークン (Access token) を取得する必要があります。まだクライアント統合を設定していない場合は、React、Vue、Angular などのクライアントフレームワーク向け クイックスタート や、サーバー間アクセス用の マシン間通信 (M2M) ガイド をご覧ください。

このガイドは、Vert.x Web アプリケーションにおけるこれらのトークンの サーバーサイド検証 に焦点を当てています。

A figure showing the focus of this guide

学べること

  • JWT 検証:アクセス トークン (Access token) を検証し、認証 (Authentication) 情報を抽出する方法
  • ミドルウェア実装:API 保護のための再利用可能なミドルウェアの作成
  • 権限モデル:さまざまな認可 (Authorization) パターンの理解と実装
    • アプリケーション全体のエンドポイント向けグローバル API リソース
    • テナント固有の機能制御のための組織 (Organization) 権限
    • マルチテナントデータアクセスのための組織レベル API リソース
  • RBAC 統合:API エンドポイントでロールベースの権限 (Permission) とスコープ (Scope) を強制する方法

前提条件

  • Java の最新安定版がインストールされていること
  • Vert.x Web および Web API 開発の基礎知識
  • Logto アプリケーションが設定済み(必要に応じて クイックスタート を参照)

権限モデルの概要

保護を実装する前に、アプリケーションアーキテクチャに適した権限モデルを選択してください。これは Logto の 3 つの主要な 認可 (Authorization) シナリオ に対応しています:

グローバル API リソース RBAC
  • ユースケース: アプリケーション全体で共有される API リソースを保護する(組織固有ではない)
  • トークンタイプ: グローバルオーディエンスを持つアクセス トークン
  • 例: パブリック API、コアプロダクトサービス、管理エンドポイント
  • 最適: すべての顧客が利用する API を持つ SaaS プロダクト、テナント分離のないマイクロサービス
  • 詳細: グローバル API リソースの保護

💡 進める前にモデルを選択してください — このガイド全体で選択したアプローチを参照します。

クイック準備手順

Logto リソースと権限の設定

  1. API リソースの作成: コンソール → API リソース にアクセスし、API を登録します(例: https://api.yourapp.com
  2. 権限の定義: read:productswrite:orders などのスコープを追加します – 権限付き API リソースの定義 を参照
  3. グローバルロールの作成: コンソール → ロール にアクセスし、API 権限を含むロールを作成します – グローバルロールの設定 を参照
  4. ロールの割り当て: API アクセスが必要なユーザーまたは M2M アプリケーションにロールを割り当てます
RBAC が初めてですか?:

ロールベースのアクセス制御ガイド からステップバイステップのセットアップ手順を始めましょう。

クライアントアプリケーションの更新

クライアントで適切なスコープをリクエストする:

通常、クライアント設定を次のいずれか、または複数を含めるように更新します:

  • OAuth フローでの scope パラメーター
  • API リソースアクセス用の resource パラメーター
  • 組織コンテキスト用の organization_id
コーディング前に:

テストするユーザーまたは M2M アプリが、API に必要な権限を含む適切なロールまたは組織ロールに割り当てられていることを確認してください。

API プロジェクトの初期化

新しい Vert.x Web プロジェクトを初期化するには、Maven プロジェクトを手動で作成できます:

pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>com.example</groupId>
<artifactId>your-api-name</artifactId>
<version>1.0-SNAPSHOT</version>

<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<vertx.version>4.5.0</vertx.version>
</properties>

<dependencies>
<dependency>
<groupId>io.vertx</groupId>
<artifactId>vertx-web</artifactId>
<version>${vertx.version}</version>
</dependency>
<dependency>
<groupId>io.vertx</groupId>
<artifactId>vertx-auth-jwt</artifactId>
<version>${vertx.version}</version>
</dependency>
<dependency>
<groupId>io.vertx</groupId>
<artifactId>vertx-web-client</artifactId>
<version>${vertx.version}</version>
</dependency>
</dependencies>
</project>

基本的な Vert.x Web サーバーを作成します:

src/main/java/com/example/MainVerticle.java
package com.example;

import io.vertx.core.AbstractVerticle;
import io.vertx.core.Promise;
import io.vertx.ext.web.Router;
import io.vertx.ext.web.handler.BodyHandler;

public class MainVerticle extends AbstractVerticle {

@Override
public void start(Promise<Void> startPromise) throws Exception {
Router router = Router.router(vertx);

router.route().handler(BodyHandler.create());

router.get("/hello").handler(ctx -> {
ctx.response()
.putHeader("content-type", "text/plain")
.end("Hello from Vert.x Web!");
});

vertx.createHttpServer()
.requestHandler(router)
.listen(3000, http -> {
if (http.succeeded()) {
startPromise.complete();
System.out.println("HTTP server started on port 3000");
} else {
startPromise.fail(http.cause());
}
});
}
}
src/main/java/com/example/Application.java
package com.example;

import io.vertx.core.Vertx;

public class Application {
public static void main(String[] args) {
Vertx vertx = Vertx.vertx();
vertx.deployVerticle(new MainVerticle());
}
}
注記:

ルートやハンドラー、その他の機能の設定方法については、 Vert.x Web のドキュメント を参照してください。

定数とユーティリティの初期化

トークンの抽出と検証を処理するために、コード内で必要な定数やユーティリティを定義してください。有効なリクエストには、Authorization ヘッダーが Bearer <アクセス トークン (Access token)> の形式で含まれている必要があります。

AuthorizationException.java
public class AuthorizationException extends RuntimeException {
private final int statusCode;

public AuthorizationException(String message) {
this(message, 403); // デフォルトは 403 Forbidden
}

public AuthorizationException(String message, int statusCode) {
super(message);
this.statusCode = statusCode;
}

public int getStatusCode() {
return statusCode;
}
}

Logto テナント情報の取得

Logto が発行したトークンを検証するには、次の値が必要です:

  • JSON Web Key Set (JWKS) URI:JWT 署名を検証するために使用される Logto の公開鍵の URL。
  • 発行者 (Issuer):期待される発行者値(Logto の OIDC URL)。

まず、Logto テナントのエンドポイントを見つけます。これはさまざまな場所で確認できます:

  • Logto コンソールの 設定ドメイン で確認できます。
  • Logto で設定した任意のアプリケーション設定内の 設定エンドポイント & 資格情報 で確認できます。

OpenID Connect ディスカバリーエンドポイントから取得する

これらの値は、Logto の OpenID Connect ディスカバリーエンドポイントから取得できます:

https://<your-logto-endpoint>/oidc/.well-known/openid-configuration

以下はレスポンス例です(他のフィールドは省略しています):

{
"jwks_uri": "https://your-tenant.logto.app/oidc/jwks",
"issuer": "https://your-tenant.logto.app/oidc"
}

Logto では JWKS URI や発行者 (Issuer) のカスタマイズができないため、これらの値をコード内にハードコーディングすることも可能です。ただし、将来的に設定が変更された場合のメンテナンス負荷が増加する可能性があるため、本番アプリケーションでは推奨されません。

  • JWKS URI:https://<your-logto-endpoint>/oidc/jwks
  • 発行者 (Issuer):https://<your-logto-endpoint>/oidc

トークンと権限の検証

トークンを抽出し OIDC 設定を取得した後、次の点を検証してください:

  • 署名:JWT は有効であり、Logto(JWKS 経由)によって署名されている必要があります。
  • 発行者 (Issuer):Logto テナントの発行者 (Issuer) と一致している必要があります。
  • オーディエンス (Audience):Logto に登録された API のリソースインジケーター、または該当する場合は組織コンテキストと一致している必要があります。
  • 有効期限:トークンが有効期限切れでないこと。
  • 権限 (スコープ) (Permissions (scopes)):トークンに API / アクションに必要なスコープが含まれている必要があります。スコープは scope クレーム内のスペース区切り文字列です。
  • 組織コンテキスト:組織レベルの API リソースを保護する場合、organization_id クレームを検証してください。

JWT の構造やクレームについて詳しくは JSON Web Token を参照してください。

各権限モデルで確認すべきこと

クレームや検証ルールは権限モデルによって異なります:

  • オーディエンスクレーム (aud): API リソースインジケーター
  • 組織クレーム (organization_id): なし
  • チェックするスコープ(権限) (scope): API リソース権限

非 API 組織権限の場合、組織コンテキストは aud クレーム(例:urn:logto:organization:abc123)で表されます。organization_id クレームは組織レベル API リソーストークンにのみ存在します。

ヒント:

セキュアなマルチテナント API のため、必ず権限(スコープ)とコンテキスト(オーディエンス、組織)の両方を検証してください。

検証ロジックの追加

フレームワークによって異なる JWT ライブラリを使用しています。必要な依存関係をインストールしてください:

pom.xml に以下を追加してください:

<dependency>
<groupId>io.vertx</groupId>
<artifactId>vertx-web</artifactId>
</dependency>
<dependency>
<groupId>io.vertx</groupId>
<artifactId>vertx-auth-jwt</artifactId>
</dependency>
<dependency>
<groupId>io.vertx</groupId>
<artifactId>vertx-web-client</artifactId>
</dependency>
JwtAuthHandler.java
import io.vertx.core.Future;
import io.vertx.core.Handler;
import io.vertx.core.Vertx;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.auth.jwt.JWTAuth;
import io.vertx.ext.auth.jwt.JWTAuthOptions;
import io.vertx.ext.web.RoutingContext;
import io.vertx.ext.web.client.WebClient;
import java.util.List;
import java.util.ArrayList;

public class JwtAuthHandler implements Handler<RoutingContext> {

private final JWTAuth jwtAuth;
private final WebClient webClient;
private final String expectedIssuer;
private final String jwksUri;

public JwtAuthHandler(Vertx vertx) {
this.webClient = WebClient.create(vertx);
this.jwtAuth = JWTAuth.create(vertx, new JWTAuthOptions());

// デプロイ時にこれらの環境変数を必ず設定してください
this.expectedIssuer = System.getenv("JWT_ISSUER");
this.jwksUri = System.getenv("JWKS_URI");

// JWKS を取得して JWT 認証を構成
fetchJWKS().onSuccess(jwks -> {
// JWKS の構成(簡略化 - 適切な JWKS パーサーが必要な場合があります)
});
}

@Override
public void handle(RoutingContext context) {
String authHeader = context.request().getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
context.response()
.setStatusCode(401)
.putHeader("Content-Type", "application/json")
.end("{\"error\": \"Authorization header missing or invalid\"}");
return;
}

String token = authHeader.substring(7);
jwtAuth.authenticate(new JsonObject().put("jwt", token))
.onSuccess(user -> {
try {
JsonObject principal = user.principal();
verifyPayload(principal);
context.put("auth", principal);
context.next();
} catch (AuthorizationException e) {
context.response()
.setStatusCode(e.getStatusCode()) // 例外のステータスコードを使用
.putHeader("Content-Type", "application/json")
.end("{\"error\": \"" + e.getMessage() + "\"}");
} catch (Exception e) {
context.response()
.setStatusCode(401)
.putHeader("Content-Type", "application/json")
.end("{\"error\": \"Invalid token\"}");
}
})
.onFailure(err -> {
context.response()
.setStatusCode(401)
.putHeader("Content-Type", "application/json")
.end("{\"error\": \"Invalid token: " + err.getMessage() + "\"}");
});
}

private Future<JsonObject> fetchJWKS() {
return webClient.getAbs(this.jwksUri)
.send()
.map(response -> response.bodyAsJsonObject());
}

private void verifyPayload(JsonObject principal) {
// Vert.x 用に発行者 (Issuer) を手動で検証
String issuer = principal.getString("iss");
if (issuer == null || !expectedIssuer.equals(issuer)) {
throw new AuthorizationException("Invalid issuer: " + issuer);
}

// 権限モデルに基づく追加の検証ロジックをここに実装してください
// 下記のヘルパーメソッドを使用してクレーム (Claim) を抽出できます
}

// Vert.x JWT 用ヘルパーメソッド
private List<String> extractAudiences(JsonObject principal) {
JsonArray audiences = principal.getJsonArray("aud");
if (audiences != null) {
List<String> result = new ArrayList<>();
for (Object aud : audiences) {
result.add(aud.toString());
}
return result;
}
return List.of();
}

private String extractScopes(JsonObject principal) {
return principal.getString("scope");
}

private String extractOrganizationId(JsonObject principal) {
return principal.getString("organization_id");
}
}

権限 (Permission) モデルに従って、適切な検証ロジックを実装してください:

// audience クレームが API リソースインジケーターと一致するか確認
List<String> audiences = extractAudiences(token); // フレームワーク固有の抽出
if (!audiences.contains("https://your-api-resource-indicator")) {
throw new AuthorizationException("Invalid audience");
}

// グローバル API リソースに必要なスコープを確認
List<String> requiredScopes = Arrays.asList("api:read", "api:write"); // 実際に必要なスコープに置き換えてください
String scopes = extractScopes(token); // フレームワーク固有の抽出
List<String> tokenScopes = scopes != null ? Arrays.asList(scopes.split(" ")) : List.of();

if (!tokenScopes.containsAll(requiredScopes)) {
throw new AuthorizationException("Insufficient scope");
}

クレーム (Claims) を抽出するためのヘルパーメソッドはフレームワークごとに異なります。上記のフレームワーク固有のバリデーションファイルで実装の詳細を確認してください。

ミドルウェアを API に適用する

これで、保護された API ルートにミドルウェアを適用します。

MainVerticle.java
import io.vertx.core.AbstractVerticle;
import io.vertx.core.Promise;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.Router;
import io.vertx.ext.web.RoutingContext;

public class MainVerticle extends AbstractVerticle {

@Override
public void start(Promise<Void> startPromise) throws Exception {
Router router = Router.router(vertx);

// 保護されたルートにミドルウェアを適用
router.route("/api/protected*").handler(new JwtAuthHandler(vertx));
router.get("/api/protected").handler(this::protectedEndpoint);

vertx.createHttpServer()
.requestHandler(router)
.listen(8080, result -> {
if (result.succeeded()) {
startPromise.complete();
} else {
startPromise.fail(result.cause());
}
});
}

private void protectedEndpoint(RoutingContext context) {
// コンテキストから JWT プリンシパルに直接アクセス
JsonObject principal = context.get("auth");
if (principal == null) {
context.response()
.setStatusCode(500)
.putHeader("Content-Type", "application/json")
.end("{\"error\": \"JWT principal not found\"}");
return;
}

String scopes = principal.getString("scope");
JsonObject response = new JsonObject()
.put("sub", principal.getString("sub"))
.put("client_id", principal.getString("client_id"))
.put("organization_id", principal.getString("organization_id"))
.put("scopes", scopes != null ? scopes.split(" ") : new String[0])
.put("audience", principal.getJsonArray("aud"));

context.response()
.putHeader("Content-Type", "application/json")
.end(response.encode());
}
}

保護された API のテスト

アクセス トークン (Access tokens) の取得

クライアントアプリケーションから: クライアント統合を設定している場合、アプリは自動的にトークンを取得できます。アクセス トークン (Access token) を抽出し、API リクエストで使用してください。

curl / Postman でのテスト用:

  1. ユーザートークン: クライアントアプリの開発者ツールを使い、localStorage またはネットワークタブからアクセス トークン (Access token) をコピーします。

  2. マシン間通信トークン: クライアントクレデンシャルフローを使用します。以下は curl を使った非公式な例です:

    curl -X POST https://your-tenant.logto.app/oidc/token \
    -H "Content-Type: application/x-www-form-urlencoded" \
    -d "grant_type=client_credentials" \
    -d "client_id=your-m2m-client-id" \
    -d "client_secret=your-m2m-client-secret" \
    -d "resource=https://your-api-resource-indicator" \
    -d "scope=api:read api:write"

    resourcescope パラメーターは API リソースや権限に応じて調整が必要です。API が組織スコープの場合は organization_id パラメーターも必要になる場合があります。

ヒント:

トークンの内容を確認したい場合は、 JWT デコーダー を使って JWT をデコード・検証できます。

保護されたエンドポイントのテスト

有効なトークンリクエスト
curl -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." \
http://localhost:3000/api/protected

期待されるレスポンス:

{
"auth": {
"sub": "user123",
"clientId": "app456",
"organizationId": "org789",
"scopes": ["api:read", "api:write"],
"audience": ["https://your-api-resource-indicator"]
}
}
トークンなし
curl http://localhost:3000/api/protected

期待されるレスポンス (401):

{
"error": "Authorization header is missing"
}
無効なトークン
curl -H "Authorization: Bearer invalid-token" \
http://localhost:3000/api/protected

期待されるレスポンス (401):

{
"error": "Invalid token"
}

権限モデルごとのテスト

グローバルスコープで保護された API のテストシナリオ:

  • 有効なスコープ: 必要な API スコープ(例: api:read, api:write)を含むトークンでテスト
  • スコープ不足: 必要なスコープがない場合は 403 Forbidden を期待
  • 誤ったオーディエンス: オーディエンスが API リソースと一致しない場合は 403 Forbidden を期待
# 必要なスコープがないトークン - 403 を期待
curl -H "Authorization: Bearer token-without-required-scopes" \
http://localhost:3000/api/protected

さらに詳しく

実践でのロールベースのアクセス制御 (RBAC):アプリケーションのための安全な認可 (Authorization) の実装

マルチテナント SaaS アプリケーションの構築:設計から実装までの完全ガイド