Keycloak 13: 外部ユーザーデータソース連携のためのカスタムSPI実装

Keycloak概要と外部データ連携の基礎

Keycloakは、現代のアプリケーションやサービス向けに設計されたオープンソースのアイデンティティ管理およびアクセス管理ソリューションです。これはWildFly上に構築されており、組み込みデータベース(H2)をテスト環境で使用できますが、本番環境ではPostgreSQL、Oracle、MySQLなどの外部RDBMSへの設定が推奨されます。

Keycloakの基本的なセットアップとSpring Boot連携

まず、Keycloakインスタンスを起動し、基本的なレルム、ユーザー、クライアントを設定します。ここでは、Dockerを利用した起動例を示します。

Keycloakインスタンスの起動

以下のコマンドでKeycloak 13.0.0コンテナを起動します。管理ユーザーとしてadmin/adminが設定されます。

docker run -p 10010:8080 -e KEYCLOAK_USER=admin -e KEYCLOAK_PASSWORD=admin quay.io/keycloak/keycloak:13.0.0

起動後、http://127.0.0.1:10010/にアクセスし、管理コンソール(Administration Console)からadminアカウントでログインできます。

レルムとクライアントの準備

Keycloakでは「レルム」がテナントのような概念として機能します。ここでは「Demo」というレルムを作成し、それに紐づくクライアントを設定します。

  1. 管理コンソールで「Add realm」をクリックし、「Demo」という名前で新しいレルムを作成します。
  2. 作成した「Demo」レルムを選択し、「Clients」メニューから「Create」をクリックします。
  3. クライアントIDを「spring-boot-app」とし、プロトコルは「openid-connect」を選択します。
  4. 「Access Type」を「confidential」に設定し、「Valid Redirect URIs」にアプリケーションのコールバックURL(例: http://localhost:8080/*)を追加します。
  5. クライアントが「confidential」であるため、「Credentials」タブが表示されます。ここで自動生成されたシークレット(秘密鍵)を控えておきます。

Spring Bootアプリケーションの連携

Spring BootアプリケーションでKeycloakを利用するには、KeycloakアダプターのMaven依存関係を追加し、設定ファイルにKeycloak接続情報を記述します。

Maven依存関係

<!-- pom.xml -->
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.keycloak.bom</groupId>
            <artifactId>keycloak-adapter-bom</artifactId>
            <version>13.0.0</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <dependency>
        <groupId>org.keycloak</groupId>
        <artifactId>keycloak-spring-boot-starter</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <!-- 必要に応じてその他の依存関係 -->
</dependencies>

アプリケーション設定 (application.properties)

KeycloakサーバーのURL、レルム名、クライアントID、シークレットを設定します。また、アクセスの制限を定義します。

# Keycloak設定
keycloak.realm=Demo
keycloak.auth-server-url=http://127.0.0.1:10010/auth
keycloak.ssl-required=external
keycloak.resource=spring-boot-app
keycloak.credentials.secret=YOUR_CLIENT_SECRET_HERE # クライアント作成時に取得したシークレット
keycloak.use-resource-role-mappings=true
keycloak.verify-token-audience=true

# セキュリティ制約
keycloak.security-constraints[0].authRoles[0]=app-user # このロールを持つユーザーのみアクセス可能
keycloak.security-constraints[0].securityCollections[0].patterns[0]=/api/*

app-userロールはKeycloakの「Demo」レルム内で作成し、ユーザーに割り当てる必要があります。

APIエンドポイントの例

package com.example.keycloakdemo.webapi;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.security.access.annotation.Secured; // ロールベースのセキュリティに必要

@RestController
@RequestMapping("/api")
public class AppController {

    @GetMapping("/secure-info")
    @Secured("ROLE_app-user") // app-userロールを持つユーザーのみアクセス可能
    public String getSecureInformation() {
        return "これはセキュアな情報です!";
    }

    @GetMapping("/public-info")
    public String getPublicInformation() {
        return "これは公開情報です。";
    }
}

Keycloak User Storage SPIによるユーザー管理の拡張

KeycloakのUser Storage SPI (Service Provider Interface) を利用すると、Keycloakの内部データベースではなく、既存の外部ユーザーデータソース(例えば、別のRDBMSやLDAP)を使用してユーザー認証や管理を行うことができます。

User Storage SPIの概要

Keycloakがユーザー認証プロセスを実行する際、まずユーザーキャッシュ、次にKeycloakの内部データベースを探索します。これらの場所で見つからない場合、登録されているUser Storage SPIプロバイダーが順に呼び出され、外部データソースからのユーザー検索を試みます。

SPIプロバイダーはJARファイルとしてパッケージ化され、Keycloakのランタイム環境にデプロイされます。デプロイ後、Keycloakの管理コンソールで「User Federation」として設定を有効にする必要があります。

プロバイダーの実装

User Storage SPIを実装するには、主にUserStorageProvider、UserLookupProvider、UserQueryProvider、CredentialInputValidatorといったインターフェースを実装するクラス群が必要です。

  • UserStorageProvider: SPIプロバイダーの基本インターフェース。
  • UserLookupProvider: ユーザー名やIDに基づいてユーザーを検索するためのインターフェース。
  • UserQueryProvider: 複数のユーザーを検索するための複雑なクエリ機能を提供。
  • CredentialInputValidator: ログインパスワードなどの認証情報を検証するインターフェース。

メインプロバイダークラスの例

ここでは、外部データベースからユーザー情報を取得・検証するプロバイダーの骨格を示します。

package com.example.keycloak.external;

import org.keycloak.component.ComponentModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.credential.CredentialInput;
import org.keycloak.models.credential.CredentialInputValidator;
import org.keycloak.storage.UserStorageProvider;
import org.keycloak.storage.user.UserLookupProvider;
import org.keycloak.storage.user.UserQueryProvider;

import java.sql.Connection; // データベース接続の例
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;

public class ExternalDbUserProvider implements UserStorageProvider,
        UserLookupProvider, UserQueryProvider, CredentialInputValidator {

    private final KeycloakSession keycloakSession;
    private final ComponentModel componentConfig;

    public ExternalDbUserProvider(KeycloakSession session, ComponentModel config) {
        this.keycloakSession = session;
        this.componentConfig = config;
    }

    @Override
    public void close() {
        // リソースのクリーンアップ
    }

    @Override
    public UserModel getUserById(RealmModel realm, String id) {
        // IDでユーザーを検索するロジック(UUIDなど)
        // 実際のDB検索ロジックを実装
        return findUserByUsername(realm, id); // シンプル化のため、ここではユーザー名検索を流用
    }

    @Override
    public UserModel getUserByUsername(RealmModel realm, String username) {
        // ユーザー名でユーザーを検索するロジック
        // 外部DBからユーザー情報を取得し、KeycloakのUserModelにマッピング
        try (Connection conn = DbUtil.getConnection(componentConfig)) {
            PreparedStatement ps = conn.prepareStatement("SELECT user_id, username, password_hash, email FROM external_users WHERE username = ?");
            ps.setString(1, username);
            try (ResultSet rs = ps.executeQuery()) {
                if (rs.next()) {
                    return new CustomUserAdapter(keycloakSession, realm, componentConfig,
                            rs.getString("user_id"),
                            rs.getString("username"),
                            rs.getString("password_hash"),
                            rs.getString("email"));
                }
            }
        } catch (SQLException e) {
            e.printStackTrace(); // ロギングを適切に行う
        }
        return null;
    }

    @Override
    public UserModel getUserByEmail(RealmModel realm, String email) {
        // メールアドレスでユーザーを検索するロジック
        try (Connection conn = DbUtil.getConnection(componentConfig)) {
            PreparedStatement ps = conn.prepareStatement("SELECT user_id, username, password_hash, email FROM external_users WHERE email = ?");
            ps.setString(1, email);
            try (ResultSet rs = ps.executeQuery()) {
                if (rs.next()) {
                    return new CustomUserAdapter(keycloakSession, realm, componentConfig,
                            rs.getString("user_id"),
                            rs.getString("username"),
                            rs.getString("password_hash"),
                            rs.getString("email"));
                }
            }
        } catch (SQLException e) {
            e.printStackTrace();
        }
        return null;
    }
    
    @Override
    public boolean supports             (CredentialInput input) {
        return input.getType().equals(CredentialModel.PASSWORD);
    }

    @Override
    public boolean isValid(RealmModel realm, UserModel user, CredentialInput input) {
        if (!supports(input) || !(user instanceof CustomUserAdapter)) {
            return false;
        }
        CustomUserAdapter customUser = (CustomUserAdapter) user;
        String providedPassword = input.getChallengeResponse();
        // 外部データベースのパスワードハッシュと比較するロジック
        // 例: BCrypt.checkpw(providedPassword, customUser.getPasswordHash())
        return providedPassword.equals(customUser.getPasswordHash()); // 簡略化のため平文比較
    }

    // UserQueryProvider関連メソッド
    @Override
    public Stream<UserModel> searchForUserStream(RealmModel realm, String search, Integer firstResult, Integer maxResults) {
        // ユーザーを検索するロジック
        // 外部DBからユーザー情報を取得し、CustomUserAdapterのストリームを返す
        return Collections.<UserModel>emptyList().stream(); // 実装に応じて変更
    }

    @Override
    public Stream<UserModel> searchForUserStream(RealmModel realm, Map<String, String> params, Integer firstResult, Integer maxResults) {
        // パラメータでユーザーを検索するロジック
        return Collections.<UserModel>emptyList().stream();
    }

    @Override
    public Stream<UserModel> getGroupMembersStream(RealmModel realm, GroupModel group, Integer firstResult, Integer maxResults) {
        return Collections.<UserModel>emptyList().stream();
    }

    @Override
    public Stream<UserModel> searchForUserByUserAttributeStream(RealmModel realm, String attributeName, String attributeValue) {
        return Collections.<UserModel>emptyList().stream();
    }
}

// カスタムユーザーアダプター
class CustomUserAdapter extends org.keycloak.storage.adapter.Abstract  UserAdapter {
    private final String userId;
    private final String username;
    private final String passwordHash;
    private final String emailAddress;

    public CustomUserAdapter(KeycloakSession session, RealmModel realm, ComponentModel model,
                             String userId, String username, String passwordHash, String emailAddress) {
        super(session, realm, model);
        this.userId = userId;
        this.username = username;
        this.passwordHash = passwordHash;
        this.emailAddress = emailAddress;
    }

    @Override
    public String getId() {
        return userId;
    }

    @Override
    public String getUsername() {
        return username;
    }

    public String getPasswordHash() {
        return passwordHash;
    }

    @Override
    public String getEmail() {
        return emailAddress;
    }
    // その他の必要なUserModelインターフェースメソッドを実装
}

// データベースユーティリティ(簡略化された例)
class DbUtil {
    public static Connection getConnection(ComponentModel config) throws SQLException {
        // configからJDBC URL、ユーザー名、パスワードを取得して接続を確立
        String jdbcUrl = config.get(ExternalDbUserProviderFactory.JDBC_CONNECTION_URL_KEY);
        String dbUser = config.get(ExternalDbUserProviderFactory.DB_USER_KEY);
        String dbPass = config.get(ExternalDbUserProviderFactory.DB_PASSWORD_KEY);
        // 例: DriverManager.getConnection(jdbcUrl, dbUser, dbPass);
        // 本番ではDataSourceなどを使用
        return null; // ダミー実装
    }
}

プロバイダーファクトリクラスの例

ファクトリクラスは、プロバイダーインスタンスの生成と、Keycloak管理コンソールでの設定プロパティの定義を担当します。これにより、管理者はGUIを通じてJDBC接続情報などを設定できます。

package com.example.keycloak.external;

import org.keycloak.component.ComponentModel;
import org.keycloak.component.ComponentValidationException;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.provider.ProviderConfigurationBuilder;
import org.keycloak.storage.UserStorageProviderFactory;

import java.sql.Connection;
import java.sql.SQLException;
import java.util.List;

public class ExternalDbUserProviderFactory implements UserStorageProviderFactory<ExternalDbUserProvider> {

    public static final String PROVIDER_ID = "external-user-db-provider";

    public static final String JDBC_DRIVER_CLASS_KEY = "jdbcDriverClass";
    public static final String JDBC_CONNECTION_URL_KEY = "jdbcConnectionUrl";
    public static final String DB_USER_KEY = "dbUsername";
    public static final String DB_PASSWORD_KEY = "dbPassword";
    public static final String VALIDATION_QUERY_KEY = "validationQuery";

    private static final List<ProviderConfigProperty> configProperties;

    static {
        configProperties = ProviderConfigurationBuilder.create()
                .property()
                .name(JDBC_DRIVER_CLASS_KEY)
                .label("JDBC Driver Class")
                .type(ProviderConfigProperty.STRING_TYPE)
                .defaultValue("org.h2.Driver")
                .helpText("JDBCドライバーの完全修飾クラス名")
                .add()
                .property()
                .name(JDBC_CONNECTION_URL_KEY)
                .label("JDBC URL")
                .type(ProviderConfigProperty.STRING_TYPE)
                .defaultValue("jdbc:h2:mem:external_users_db")
                .helpText("ユーザーデータベースへのJDBC接続URL")
                .add()
                .property()
                .name(DB_USER_KEY)
                .label("データベースユーザー名")
                .type(ProviderConfigProperty.STRING_TYPE)
                .helpText("データベース接続に使用するユーザー名")
                .add()
                .property()
                .name(DB_PASSWORD_KEY)
                .label("データベースパスワード")
                .type(ProviderConfigProperty.STRING_TYPE)
                .secret(true) // GUI上でマスクされる
                .helpText("データベース接続に使用するパスワード")
                .add()
                .property()
                .name(VALIDATION_QUERY_KEY)
                .label("SQL検証クエリ")
                .type(ProviderConfigProperty.STRING_TYPE)
                .defaultValue("SELECT 1")
                .helpText("データベース接続検証のためのSQLクエリ")
                .add()
                .build();
    }

    @Override
    public ExternalDbUserProvider create(KeycloakSession session, ComponentModel model) {
        return new ExternalDbUserProvider(session, model);
    }

    @Override
    public String getId() {
        return PROVIDER_ID;
    }

    @Override
    public List<ProviderConfigProperty> getConfigProperties() {
        return configProperties;
    }

    @Override
    public void validateConfiguration(KeycloakSession session, RealmModel realm, ComponentModel config) throws ComponentValidationException {
        // 設定値を使用してデータベース接続をテスト
        try (Connection conn = DbUtil.getConnection(config)) {
            if (conn == null) {
                throw new SQLException("Failed to establish database connection.");
            }
            conn.createStatement().execute(config.get(VALIDATION_QUERY_KEY));
            System.out.println("External DB connection validated successfully.");
        } catch (SQLException ex) {
            System.err.println("Database connection validation failed: " + ex.getMessage());
            throw new ComponentValidationException("外部データベース接続の検証に失敗しました。", ex);
        }
    }
}

Keycloakへのデプロイと設定

User Storage SPIプロバイダーは、通常MavenでJARファイルとしてビルドされ、Keycloakのデプロイメントディレクトリ(standalone/deployments/)にコピーするか、JBoss CLIを使用してデプロイされます。

また、JARファイルにはMETA-INF/services/org.keycloak.storage.UserStorageProviderFactoryという名前のファイルを含め、その中にファクトリクラスの完全修飾名を記述する必要があります。

com.example.keycloak.external.ExternalDbUserProviderFactory

デプロイ後、Keycloak管理コンソールで以下の手順を実行します。

  1. 「Demo」レルムを選択し、「User Federation」メニューに移動します。
  2. 「Add provider」ドロップダウンから「external-user-db-provider」(ExternalDbUserProviderFactory.PROVIDER_IDで定義したID)を選択します。
  3. 表示される設定画面で、JDBCドライバー、URL、ユーザー名、パスワードなどの接続情報を入力し、「Save」をクリックします。
  4. 設定が保存され、プロバイダーが有効になると、Keycloakは外部データベースをユーザーソースとして利用するようになります。

Keycloakにおけるロール管理と外部ユーザー認証

外部ユーザーデータソースに存在するユーザーにも、Keycloakを介してロールを割り当て、アクセス制御を行うことができます。これは、外部プロバイダーから取得したユーザーモデルに対してKeycloakのロールをマッピングすることで実現されます。

外部ユーザーへのロール付与と制限

まず、Keycloakの「Demo」レルム内に、アプリケーションが使用するロール(例: app-user)を作成します。次に、外部ユーザーがログインした際に、そのユーザーにこのロールを付与します。これはUser Storage SPIプロバイダーの実装内で、CustomUserAdapterがgetRealmRoleMappingsStream()などのメソッドをオーバーライドし、動的にロール情報を提供することで可能になります。

application.propertiesのセキュリティ制約を更新し、複数のロールを持つユーザーがアクセスできるように設定することも可能です。

# セキュリティ制約の更新
keycloak.security-constraints[0].authRoles[0]=app-user
keycloak.security-constraints[0].authRoles[1]=admin-user # 例: 新しい管理者ロール
keycloak.security-constraints[0].securityCollections[0].patterns[0]=/api/secure-info

この設定により、app-userまたはadmin-userのいずれかのロールを持つ外部ユーザーが、Spring Bootアプリケーションの/api/secure-infoエンドポイントにアクセスできるようになります。

タグ: Keycloak UserStorageSPI Spring Boot Java EE Identity and Access Management

10月17日 11:41 投稿